update 26.7.25

This commit is contained in:
kun
2026-07-26 00:30:28 +08:00
parent 59b80ba269
commit 4218e6cdfc
66 changed files with 13639 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
DB_HOST=192.168.0.112
DB_PORT=4200
DB_NAME=postgres
DB_SCHEMA=bid
DB_USER=kun
DB_PASSWORD=nxY7HUoJYjUgn
DB_SSLMODE=
+8
View File
@@ -0,0 +1,8 @@
APP_PORT=8000
DB_HOST=192.168.0.112
DB_PORT=4200
DB_NAME=postgres
DB_SCHEMA=bid
DB_USER=user
DB_PASSWORD=password
DB_SSLMODE=
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim
ARG PIP_INDEX_URL=https://pypi.org/simple
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt .
RUN python -m pip install --upgrade pip \
--index-url "$PIP_INDEX_URL" \
&& python -m pip install --retries 10 --timeout 120 \
--index-url "$PIP_INDEX_URL" \
-r requirements.txt
COPY --chown=app:app market_api ./market_api
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"
CMD ["uvicorn", "market_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+14
View File
@@ -0,0 +1,14 @@
# market_api
用于把月度招投标 Excel 数据导入 PostgreSQL,并在后续提供统计口径查询的 FastAPI 服务。
## 本地运行
```bash
pip install -r requirements.txt
```
```bash
source .venv/bin/activate
uvicorn market_api.main:app --reload
```
+17
View File
@@ -0,0 +1,17 @@
services:
market-api:
build:
context: .
dockerfile: Dockerfile
image: market-api:latest
ports:
- "${APP_PORT:-8000}:8000"
environment:
DB_HOST: ${DB_HOST:-192.168.0.112}
DB_PORT: ${DB_PORT:-4200}
DB_NAME: ${DB_NAME:-postgres}
DB_SCHEMA: ${DB_SCHEMA:-bid}
DB_USER: ${DB_USER:-user}
DB_PASSWORD: ${DB_PASSWORD:-}
DB_SSLMODE: ${DB_SSLMODE:-}
restart: unless-stopped
+17
View File
@@ -0,0 +1,17 @@
services:
market-api:
build:
context: .
dockerfile: Dockerfile
image: market-api:latest
ports:
- "${APP_PORT:-6100}:8000"
environment:
DB_HOST: ${DB_HOST:-192.168.0.112}
DB_PORT: ${DB_PORT:-4200}
DB_NAME: ${DB_NAME:-postgres}
DB_SCHEMA: ${DB_SCHEMA:-bid}
DB_USER: ${DB_USER:-user}
DB_PASSWORD: ${DB_PASSWORD:-}
DB_SSLMODE: ${DB_SSLMODE:-}
restart: unless-stopped
+2
View File
@@ -0,0 +1,2 @@
"""FastAPI service for monthly market data imports and statistics."""
+2
View File
@@ -0,0 +1,2 @@
"""Award statistics services."""
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
VALID_CT_VALUES = {"", ""}
def build_ct_filter(ct: str) -> tuple[str]:
normalized_ct = ct.strip()
if normalized_ct not in VALID_CT_VALUES:
raise ValueError("统计CT必须是 是 或 否")
return (normalized_ct,)
def format_wan_amount(amount: Decimal | int | float | None) -> str:
value = Decimal("0") if amount is None else Decimal(str(amount))
return format(value.normalize(), "f")
def format_share(share: Decimal | int | float | None) -> str:
if share is None:
return "/"
value = Decimal(str(share)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return f"{value:.1f}%"
def format_pp_change(
current_share: Decimal | int | float | None,
baseline_share: Decimal | int | float | None,
) -> str:
if current_share is None or baseline_share is None:
return "/"
change = (Decimal(str(current_share)) - Decimal(str(baseline_share))).quantize(
Decimal("0.1"),
rounding=ROUND_HALF_UP,
)
return f"{change:.1f}"
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from market_api.award.common import build_ct_filter, format_wan_amount
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import build_scope_filter, normalize_month
TELECOM_AMOUNT_COLUMN = "电信金额_万元"
UNICOM_AMOUNT_COLUMN = "联通金额_万元"
LARGE_DEAL_THRESHOLD_WAN = Decimal("300")
ALLOWED_AMOUNT_COLUMNS = {TELECOM_AMOUNT_COLUMN, UNICOM_AMOUNT_COLUMN}
@dataclass(frozen=True)
class CompetitorLargeDealRow:
buyer: str
industry: str
project_name: str
amount_wan: Decimal
class CompetitorLargeDealsRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def large_deals(
self,
amount_column: str,
year: int,
month: int,
ct_filter: tuple[str, ...],
scope_filter: tuple[str, str] | None,
) -> list[CompetitorLargeDealRow]:
if amount_column not in ALLOWED_AMOUNT_COLUMNS:
raise ValueError("不支持的大单金额字段")
quoted_amount_column = quote_identifier(amount_column)
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} = %s",
]
params: list[object] = [year, f"{month}"]
ct_value = ct_filter[0]
ct_column = quote_identifier("CT类")
if ct_value == "":
where_parts.append(f"{ct_column} IS NOT NULL")
where_parts.append(f"{ct_column} <> %s")
params.append("")
else:
where_parts.append(f"{ct_column} = %s")
params.append("")
where_parts.append(f"{quoted_amount_column} > %s")
params.append(LARGE_DEAL_THRESHOLD_WAN)
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
buyer_column = quote_identifier("招标人")
industry_column = quote_identifier("行业")
project_column = quote_identifier("项目名称")
sql = (
f"SELECT {buyer_column}, {industry_column}, {project_column}, "
f"{quoted_amount_column} "
f"FROM {qualify_table(self.schema, '运营商市场中标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"ORDER BY {quoted_amount_column} DESC, {buyer_column} ASC, "
f"{project_column} ASC"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [
CompetitorLargeDealRow(
str(buyer or ""),
str(industry or ""),
str(project_name or ""),
Decimal(str(amount_wan or 0)),
)
for buyer, industry, project_name, amount_wan in rows
]
def _deal_rows(rows: list[CompetitorLargeDealRow]) -> list[dict[str, object]]:
return [
{
"招标人": row.buyer,
"行业": row.industry,
"项目名称": row.project_name,
"中标金额(万元)": format_wan_amount(row.amount_wan),
}
for row in rows
]
def build_competitor_large_deals_summary(
repository: object,
year: int,
month: str | int,
ct: str,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
ct_filter = build_ct_filter(ct)
normalized_ct = ct_filter[0]
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
telecom_rows = repository.large_deals(
TELECOM_AMOUNT_COLUMN,
year,
month_number,
ct_filter,
scope_filter,
)
unicom_rows = repository.large_deals(
UNICOM_AMOUNT_COLUMN,
year,
month_number,
ct_filter,
scope_filter,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计CT": normalized_ct,
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"电信大单": _deal_rows(telecom_rows),
"联通大单": _deal_rows(unicom_rows),
}
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from market_api.award.common import build_ct_filter, format_share, format_wan_amount
from market_api.award.overall import (
OPERATOR_AMOUNT_COLUMNS,
OPERATORS,
AwardOperatorAggregate,
)
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import build_scope_filter, normalize_month
AWARD_INDUSTRIES = (
"党政",
"金融",
"教育",
"工业能源",
"医卫",
"交通",
"商客",
"农业文旅",
"互联网",
"融合创新",
)
REPORT_OPERATORS = ("电信", "移动", "联通")
class AwardIndustryRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_by_industry(
self,
year: int,
months: list[int],
ct_filter: tuple[str, ...],
scope_filter: tuple[str, str] | None,
) -> dict[str, dict[str, AwardOperatorAggregate]]:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
industry_placeholders = ", ".join(["%s"] * len(AWARD_INDUSTRIES))
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
f"{quote_identifier('行业')} IN ({industry_placeholders})",
]
params: list[object] = [
year,
*[f"{month}" for month in months],
*AWARD_INDUSTRIES,
]
ct_value = ct_filter[0]
ct_column = quote_identifier("CT类")
if ct_value == "":
where_parts.append(f"{ct_column} IS NOT NULL")
where_parts.append(f"{ct_column} <> %s")
params.append("")
else:
where_parts.append(f"{ct_column} = %s")
params.append("")
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
industry_column = quote_identifier("行业")
select_parts = [industry_column]
for operator in OPERATORS:
amount_column = quote_identifier(OPERATOR_AMOUNT_COLUMNS[operator])
select_parts.append(f"COUNT({amount_column})")
select_parts.append(f"COALESCE(SUM({amount_column}), 0)")
sql = (
f"SELECT {', '.join(select_parts)} "
f"FROM {qualify_table(self.schema, '运营商市场中标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {industry_column}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
result: dict[str, dict[str, AwardOperatorAggregate]] = {}
for row in rows:
industry = str(row[0])
result[industry] = {
operator: AwardOperatorAggregate(
int(row[index * 2 + 1] or 0),
Decimal(str(row[index * 2 + 2] or 0)),
)
for index, operator in enumerate(OPERATORS)
}
return result
def _empty_operator_aggregate() -> AwardOperatorAggregate:
return AwardOperatorAggregate(0, Decimal("0"))
def _share(value: Decimal | int, total: Decimal | int) -> Decimal | None:
total_value = Decimal(str(total))
if total_value == 0:
return None
return Decimal(str(value)) / total_value * Decimal("100")
def _count_row(
industry: str,
operator_data: dict[str, AwardOperatorAggregate],
) -> dict[str, object]:
total_count = sum(
operator_data.get(operator, _empty_operator_aggregate()).count
for operator in OPERATORS
)
row: dict[str, object] = {"行业": industry}
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}个数"] = current.count
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}个数份额"] = format_share(_share(current.count, total_count))
return row
def _amount_row(
industry: str,
operator_data: dict[str, AwardOperatorAggregate],
) -> dict[str, object]:
total_amount = sum(
operator_data.get(operator, _empty_operator_aggregate()).amount_wan
for operator in OPERATORS
)
row: dict[str, object] = {"行业": industry}
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}金额(万元)"] = format_wan_amount(current.amount_wan)
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}金额份额"] = format_share(_share(current.amount_wan, total_amount))
return row
def _industry_rows(
current_by_industry: dict[str, dict[str, AwardOperatorAggregate]],
) -> dict[str, list[dict[str, object]]]:
count_rows: list[dict[str, object]] = []
amount_rows: list[dict[str, object]] = []
for industry in AWARD_INDUSTRIES:
operator_data = current_by_industry.get(industry, {})
count_rows.append(_count_row(industry, operator_data))
amount_rows.append(_amount_row(industry, operator_data))
return {"个数数据": count_rows, "金额数据": amount_rows}
def build_award_industry_summary(
repository: object,
year: int,
month: str | int,
ct: str,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
ct_filter = build_ct_filter(ct)
normalized_ct = ct_filter[0]
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
cumulative_months = list(range(1, month_number + 1))
cumulative = repository.aggregate_by_industry(
year,
cumulative_months,
ct_filter,
scope_filter,
)
monthly = repository.aggregate_by_industry(
year,
[month_number],
ct_filter,
scope_filter,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计CT": normalized_ct,
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"累计数据": _industry_rows(cumulative),
"月度数据": _industry_rows(monthly),
}
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from market_api.award.common import (
build_ct_filter,
format_pp_change,
format_share,
format_wan_amount,
)
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import build_scope_filter, normalize_month
OPERATORS = ("移动", "电信", "联通")
OPERATOR_AMOUNT_COLUMNS = {
"移动": "移动金额_万元",
"电信": "电信金额_万元",
"联通": "联通金额_万元",
}
@dataclass(frozen=True)
class AwardOperatorAggregate:
count: int
amount_wan: Decimal
class AwardOverallRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate(
self,
year: int,
months: list[int],
ct_filter: tuple[str, ...],
scope_filter: tuple[str, str] | None,
) -> dict[str, AwardOperatorAggregate]:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
]
params: list[object] = [year, *[f"{month}" for month in months]]
ct_value = ct_filter[0]
ct_column = quote_identifier("CT类")
if ct_value == "":
where_parts.append(f"{ct_column} IS NOT NULL")
where_parts.append(f"{ct_column} <> %s")
params.append("")
else:
where_parts.append(f"{ct_column} = %s")
params.append("")
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
select_parts: list[str] = []
for operator in OPERATORS:
amount_column = quote_identifier(OPERATOR_AMOUNT_COLUMNS[operator])
select_parts.append(f"COUNT({amount_column})")
select_parts.append(f"COALESCE(SUM({amount_column}), 0)")
sql = (
f"SELECT {', '.join(select_parts)} "
f"FROM {qualify_table(self.schema, '运营商市场中标数据')} "
f"WHERE {' AND '.join(where_parts)}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
row = cursor.fetchone()
if row is None:
row = (0, Decimal("0"), 0, Decimal("0"), 0, Decimal("0"))
return {
operator: AwardOperatorAggregate(
int(row[index * 2] or 0),
Decimal(str(row[index * 2 + 1] or 0)),
)
for index, operator in enumerate(OPERATORS)
}
def _operator_share(value: Decimal | int, total: Decimal | int) -> Decimal | None:
total_value = Decimal(str(total))
if total_value == 0:
return None
return Decimal(str(value)) / total_value * Decimal("100")
def _empty_aggregate() -> AwardOperatorAggregate:
return AwardOperatorAggregate(0, Decimal("0"))
def _operator_rows(
current_by_operator: dict[str, AwardOperatorAggregate],
baseline_by_operator: dict[str, AwardOperatorAggregate] | None,
count_key: str,
amount_key: str,
count_change_key: str,
amount_change_key: str,
) -> list[dict[str, object]]:
current_total_count = sum(
current_by_operator.get(operator, _empty_aggregate()).count
for operator in OPERATORS
)
current_total_amount = sum(
current_by_operator.get(operator, _empty_aggregate()).amount_wan
for operator in OPERATORS
)
baseline_total_count = (
None
if baseline_by_operator is None
else sum(
baseline_by_operator.get(operator, _empty_aggregate()).count
for operator in OPERATORS
)
)
baseline_total_amount = (
None
if baseline_by_operator is None
else sum(
baseline_by_operator.get(operator, _empty_aggregate()).amount_wan
for operator in OPERATORS
)
)
rows: list[dict[str, object]] = []
for operator in OPERATORS:
current = current_by_operator.get(operator, _empty_aggregate())
current_count_share = _operator_share(current.count, current_total_count)
current_amount_share = _operator_share(current.amount_wan, current_total_amount)
if baseline_by_operator is None:
baseline_count_share = None
baseline_amount_share = None
else:
baseline = baseline_by_operator.get(operator, _empty_aggregate())
baseline_count_share = _operator_share(baseline.count, baseline_total_count or 0)
baseline_amount_share = _operator_share(
baseline.amount_wan,
baseline_total_amount or 0,
)
rows.append(
{
"运营商": operator,
count_key: current.count,
amount_key: format_wan_amount(current.amount_wan),
"个数份额": format_share(current_count_share),
"金额份额": format_share(current_amount_share),
count_change_key: format_pp_change(
current_count_share,
baseline_count_share,
),
amount_change_key: format_pp_change(
current_amount_share,
baseline_amount_share,
),
}
)
return rows
def build_award_overall_summary(
repository: object,
year: int,
month: str | int,
ct: str,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
ct_filter = build_ct_filter(ct)
normalized_ct = ct_filter[0]
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
cumulative_months = list(range(1, month_number + 1))
current_cumulative = repository.aggregate(
year,
cumulative_months,
ct_filter,
scope_filter,
)
baseline_cumulative = repository.aggregate(
year - 1,
cumulative_months,
ct_filter,
scope_filter,
)
current_monthly = repository.aggregate(year, [month_number], ct_filter, scope_filter)
if month_number == 1:
previous_monthly = None
else:
previous_monthly = repository.aggregate(
year,
[month_number - 1],
ct_filter,
scope_filter,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计CT": normalized_ct,
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"累计数据": _operator_rows(
current_cumulative,
baseline_cumulative,
"累计中标个数",
"累计中标金额(万元)",
"个数份额同比变化(百分点)",
"金额份额同比变化(百分点)",
),
"月度数据": _operator_rows(
current_monthly,
previous_monthly,
"单月中标个数",
"单月中标金额(万元)",
"个数份额环比变化(百分点)",
"金额份额环比变化(百分点)",
),
}
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from market_api.award.common import build_ct_filter, format_share, format_wan_amount
from market_api.award.industry import REPORT_OPERATORS
from market_api.award.overall import (
OPERATOR_AMOUNT_COLUMNS,
OPERATORS,
AwardOperatorAggregate,
)
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import normalize_month
AWARD_REGIONS = (
"战客中心",
"浦东",
"南区",
"松江",
"北区",
"西区",
"闵行",
"宝山",
"嘉定",
"青浦",
"奉贤",
"崇明",
"金山",
"其他",
)
class AwardRegionRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_by_region(
self,
year: int,
months: list[int],
ct_filter: tuple[str, ...],
) -> dict[str, dict[str, AwardOperatorAggregate]]:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
region_placeholders = ", ".join(["%s"] * len(AWARD_REGIONS))
region_column = quote_identifier("归属区域_属地")
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
f"{region_column} IN ({region_placeholders})",
]
params: list[object] = [
year,
*[f"{month}" for month in months],
*AWARD_REGIONS,
]
ct_value = ct_filter[0]
ct_column = quote_identifier("CT类")
if ct_value == "":
where_parts.append(f"{ct_column} IS NOT NULL")
where_parts.append(f"{ct_column} <> %s")
params.append("")
else:
where_parts.append(f"{ct_column} = %s")
params.append("")
select_parts = [region_column]
for operator in OPERATORS:
amount_column = quote_identifier(OPERATOR_AMOUNT_COLUMNS[operator])
select_parts.append(f"COUNT({amount_column})")
select_parts.append(f"COALESCE(SUM({amount_column}), 0)")
sql = (
f"SELECT {', '.join(select_parts)} "
f"FROM {qualify_table(self.schema, '运营商市场中标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {region_column}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
result: dict[str, dict[str, AwardOperatorAggregate]] = {}
for row in rows:
region = str(row[0])
result[region] = {
operator: AwardOperatorAggregate(
int(row[index * 2 + 1] or 0),
Decimal(str(row[index * 2 + 2] or 0)),
)
for index, operator in enumerate(OPERATORS)
}
return result
def validate_award_region_scope(scope: str, scope_value: str | None) -> None:
if scope != "上海":
raise ValueError("区域中标分析仅支持统计方式为上海")
if scope_value is not None and scope_value.strip():
raise ValueError("区域中标分析的统计参数必须留空")
def _empty_operator_aggregate() -> AwardOperatorAggregate:
return AwardOperatorAggregate(0, Decimal("0"))
def _share(value: Decimal | int, total: Decimal | int) -> Decimal | None:
total_value = Decimal(str(total))
if total_value == 0:
return None
return Decimal(str(value)) / total_value * Decimal("100")
def _count_row(
region: str,
operator_data: dict[str, AwardOperatorAggregate],
) -> dict[str, object]:
total_count = sum(
operator_data.get(operator, _empty_operator_aggregate()).count
for operator in OPERATORS
)
row: dict[str, object] = {"属地": region}
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}个数"] = current.count
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}个数份额"] = format_share(_share(current.count, total_count))
return row
def _amount_row(
region: str,
operator_data: dict[str, AwardOperatorAggregate],
) -> dict[str, object]:
total_amount = sum(
operator_data.get(operator, _empty_operator_aggregate()).amount_wan
for operator in OPERATORS
)
row: dict[str, object] = {"属地": region}
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}金额(万元)"] = format_wan_amount(current.amount_wan)
for operator in REPORT_OPERATORS:
current = operator_data.get(operator, _empty_operator_aggregate())
row[f"{operator}金额份额"] = format_share(_share(current.amount_wan, total_amount))
return row
def _region_rows(
current_by_region: dict[str, dict[str, AwardOperatorAggregate]],
) -> dict[str, list[dict[str, object]]]:
count_rows: list[dict[str, object]] = []
amount_rows: list[dict[str, object]] = []
for region in AWARD_REGIONS:
operator_data = current_by_region.get(region, {})
count_rows.append(_count_row(region, operator_data))
amount_rows.append(_amount_row(region, operator_data))
return {"个数数据": count_rows, "金额数据": amount_rows}
def build_award_region_summary(
repository: object,
year: int,
month: str | int,
ct: str,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
validate_award_region_scope(scope, scope_value)
month_number = normalize_month(month)
ct_filter = build_ct_filter(ct)
normalized_ct = ct_filter[0]
cumulative_months = list(range(1, month_number + 1))
cumulative = repository.aggregate_by_region(
year,
cumulative_months,
ct_filter,
)
monthly = repository.aggregate_by_region(
year,
[month_number],
ct_filter,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计CT": normalized_ct,
"统计方式": scope,
"统计参数": None,
"累计数据": _region_rows(cumulative),
"月度数据": _region_rows(monthly),
}
+2
View File
@@ -0,0 +1,2 @@
"""Application configuration helpers."""
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
def load_local_dotenv(path: Path = Path(".env")) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
@dataclass(frozen=True)
class DatabaseSettings:
host: str
port: int
database: str
user: str
password: str | None
sslmode: str | None
@classmethod
def from_env(cls) -> "DatabaseSettings":
load_local_dotenv()
return cls(
host=os.getenv("DB_HOST", "192.168.0.112"),
port=int(os.getenv("DB_PORT", "4200")),
database=os.getenv("DB_NAME", "postgres"),
user=os.getenv("DB_USER", "kun"),
password=os.getenv("DB_PASSWORD", "nxY7HUoJYjUgn"),
sslmode=os.getenv("DB_SSLMODE") or None,
)
def connect_kwargs(self) -> dict[str, object]:
kwargs: dict[str, object] = {
"host": self.host,
"port": self.port,
"dbname": self.database,
"user": self.user,
}
if self.password:
kwargs["password"] = self.password
if self.sslmode:
kwargs["sslmode"] = self.sslmode
return kwargs
@dataclass(frozen=True)
class AppSettings:
database: DatabaseSettings
schema: str
@classmethod
def from_env(cls) -> "AppSettings":
return cls(
database=DatabaseSettings.from_env(),
schema=os.getenv("DB_SCHEMA", "bid"),
)
def get_settings() -> AppSettings:
return AppSettings.from_env()
+2
View File
@@ -0,0 +1,2 @@
"""Database helpers."""
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from market_api.core.config import DatabaseSettings
from market_api.db.sql import (
build_copy_sql,
build_create_schema_sql,
build_create_table_sql,
build_drop_table_sql,
record_column_names,
)
from market_api.importer.mappings import SheetSpec
def connect(settings: DatabaseSettings) -> Any:
import psycopg
return psycopg.connect(**settings.connect_kwargs())
def replace_table(
conn: Any,
schema: str,
spec: SheetSpec,
records: Iterable[dict[str, object]],
) -> int:
columns = record_column_names(spec)
row_count = 0
with conn.cursor() as cursor:
cursor.execute(build_create_schema_sql(schema))
cursor.execute(build_drop_table_sql(schema, spec))
cursor.execute(build_create_table_sql(schema, spec))
with cursor.copy(build_copy_sql(schema, spec)) as copy:
for record in records:
copy.write_row([record.get(column) for column in columns])
row_count += 1
return row_count
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from market_api.importer.mappings import SheetSpec
def quote_identifier(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"'
def qualify_table(schema: str, table: str) -> str:
return f"{quote_identifier(schema)}.{quote_identifier(table)}"
def column_sql_type(kind: str) -> str:
match kind:
case "integer":
return "integer"
case "numeric":
return "numeric"
case "timestamp":
return "timestamp"
case _:
return "text"
def record_column_names(spec: SheetSpec) -> list[str]:
return ["源行号", *[column.db_name for column in spec.columns]]
def build_create_schema_sql(schema: str) -> str:
return f"CREATE SCHEMA IF NOT EXISTS {quote_identifier(schema)}"
def build_drop_table_sql(schema: str, spec: SheetSpec) -> str:
return f"DROP TABLE IF EXISTS {qualify_table(schema, spec.table_name)}"
def build_create_table_sql(schema: str, spec: SheetSpec) -> str:
columns = [' "源行号" integer NOT NULL']
for column in spec.columns:
columns.append(
f" {quote_identifier(column.db_name)} {column_sql_type(column.kind)}"
)
column_block = ",\n".join(columns)
return f"CREATE TABLE {qualify_table(schema, spec.table_name)} (\n{column_block}\n)"
def build_copy_sql(schema: str, spec: SheetSpec) -> str:
columns = ", ".join(quote_identifier(name) for name in record_column_names(spec))
return f"COPY {qualify_table(schema, spec.table_name)} ({columns}) FROM STDIN"
+2
View File
@@ -0,0 +1,2 @@
"""Excel import mappings and services."""
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ColumnSpec:
excel_index: int
source_header: str
db_name: str
kind: str = "text"
@dataclass(frozen=True)
class SheetSpec:
sheet_name: str
table_name: str
columns: tuple[ColumnSpec, ...]
def _column(
excel_number: int,
source_header: str,
db_name: str | None = None,
kind: str = "text",
) -> ColumnSpec:
return ColumnSpec(
excel_index=excel_number - 1,
source_header=source_header,
db_name=db_name or source_header,
kind=kind,
)
OPEN_MARKET_TENDER_SPEC = SheetSpec(
sheet_name="公开市场招标数据",
table_name="公开市场招标数据",
columns=(
_column(1, "年份", kind="integer"),
_column(2, "月份"),
_column(3, "量化月份", kind="integer"),
_column(4, "数据ID"),
_column(5, "来源网站"),
_column(6, "发布链接"),
_column(7, "采集时间", kind="timestamp"),
_column(8, "发布时间", kind="timestamp"),
_column(9, "标题"),
_column(10, "正文内容"),
_column(11, ""),
_column(12, "地市"),
_column(13, "区/县", "区_县"),
_column(14, "项目编号"),
_column(15, "预算金额(万元)", "预算金额_万元", "numeric"),
_column(16, "预算金额分层"),
_column(17, "招标单位"),
_column(18, "招标方式"),
_column(19, "开标时间", kind="timestamp"),
_column(20, "报名截止时间", kind="timestamp"),
_column(21, "投标资质"),
_column(22, "行业"),
_column(23, "行业二级"),
_column(24, "行业三级"),
_column(25, "属地"),
_column(26, "集团编号"),
_column(27, "集团十五大行业"),
_column(28, "归属区域(行政)", "归属区域_行政"),
_column(29, "归属区域(属地)", "归属区域_属地"),
_column(30, "分类"),
_column(31, "是否139客户"),
),
)
OPERATOR_WINNING_SPEC = SheetSpec(
sheet_name="运营商市场中标数据",
table_name="运营商市场中标数据",
columns=(
_column(1, "年份", kind="integer"),
_column(2, "月份"),
_column(3, "量化月份", kind="integer"),
_column(4, "序号"),
_column(5, "ID"),
_column(6, "应用场景"),
_column(7, "招标人"),
_column(8, "客群"),
_column(9, "行业"),
_column(10, "集团十五大行业"),
_column(11, "项目编号"),
_column(12, "标题"),
_column(13, "项目名称"),
_column(14, "招标链接"),
_column(15, "中标日期", kind="timestamp"),
_column(16, "月份", "中标月份"),
_column(17, "采购内容"),
_column(18, "预算金额(万元)", "预算金额_万元", "numeric"),
_column(19, "中标总金额(万元)", "中标总金额_万元", "numeric"),
_column(20, "电信金额(万元)", "电信金额_万元", "numeric"),
_column(21, "移动金额(万元)", "移动金额_万元", "numeric"),
_column(22, "联通金额(万元)", "联通金额_万元", "numeric"),
_column(23, "广电金额(万元)", "广电金额_万元", "numeric"),
_column(24, "其他金额(万元)", "其他金额_万元", "numeric"),
_column(25, "中标单位"),
_column(26, "运营商"),
_column(27, "是否多家中标"),
_column(28, "是否单一来源"),
_column(29, "项目归属区域"),
_column(30, "商机归属区域"),
_column(31, "二级属地"),
_column(32, "商机编号"),
_column(33, "全网商机编号"),
_column(34, "项目编号", "商机项目编号"),
_column(35, "集团编号"),
_column(36, "客户经理"),
_column(37, "招标日期", kind="timestamp"),
_column(38, "中标链接"),
_column(39, "是否遗漏商机"),
_column(40, "大单是否及时跟进"),
_column(41, "备注"),
_column(42, "投标情况(商机未捕捉/未参投/竞标失败)", "投标情况商机未捕捉_未参投_竞标失败"),
_column(43, "是否友商前期存量项目(是/否)", "是否友商前期存量项目"),
_column(44, "涉及标准产品"),
_column(45, "涉及解决方案"),
_column(46, "是否涉及专业公司支撑(集成、铁通等/不涉及)", "是否涉及专业公司支撑"),
_column(47, "专业公司未支撑原因"),
_column(48, "专业公司支撑是否有不足"),
_column(49, "对专业公司的意见点"),
_column(50, "前期走访及其它动作"),
_column(51, "标书得分对标分析(商务、技术、其它)", "标书得分对标分析商务_技术_其它"),
_column(52, "丢标原因说明"),
_column(53, "一级原因"),
_column(54, "二级原因"),
_column(55, "备注说明"),
_column(56, "延续型证明(网址)", "延续型证明网址"),
_column(57, "商机前置介入时间", kind="integer"),
_column(58, "计算月份", kind="integer"),
_column(59, "N运营商共同中标项目", kind="integer"),
_column(60, "归属区域(行政)", "归属区域_行政"),
_column(61, "归属区域(属地)", "归属区域_属地"),
_column(62, "CT类"),
_column(63, "客户属性"),
_column(64, "大区区划"),
),
)
ALL_SHEET_SPECS = (OPEN_MARKET_TENDER_SPEC, OPERATOR_WINNING_SPEC)
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
import math
import re
from datetime import date, datetime, time
from decimal import Decimal, InvalidOperation
from typing import Sequence
from openpyxl.utils.datetime import from_excel
from market_api.importer.mappings import SheetSpec
BLANK_TEXT_VALUES = {"", "/", "", "-", "--", "", "——", "", "null", "none", "nan"}
def is_blank(value: object) -> bool:
if value is None:
return True
if isinstance(value, float) and math.isnan(value):
return True
if isinstance(value, str):
return value.strip().lower() in BLANK_TEXT_VALUES
return False
def normalize_cell(value: object, kind: str) -> object:
if is_blank(value):
return None
match kind:
case "integer":
return normalize_integer(value)
case "numeric":
return normalize_numeric(value)
case "timestamp":
return normalize_timestamp(value)
case _:
return normalize_text(value)
def normalize_text(value: object) -> str | None:
if is_blank(value):
return None
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def normalize_numeric(value: object) -> Decimal | None:
if isinstance(value, bool) or is_blank(value):
return None
if isinstance(value, int):
return Decimal(value)
if isinstance(value, float):
if math.isnan(value) or math.isinf(value):
return None
return Decimal(str(value))
text = normalize_text(value)
if text is None:
return None
cleaned = (
text.replace(",", "")
.replace("", "")
.replace("万元", "")
.replace("", "")
.strip()
)
if not re.fullmatch(r"[-+]?\d+(\.\d+)?", cleaned):
return None
try:
return Decimal(cleaned)
except InvalidOperation:
return None
def normalize_integer(value: object) -> int | None:
numeric = normalize_numeric(value)
if numeric is None:
return None
if numeric == numeric.to_integral_value():
return int(numeric)
return None
def normalize_timestamp(value: object) -> datetime | None:
if is_blank(value):
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, date):
return datetime.combine(value, time.min)
if isinstance(value, int | float) and not isinstance(value, bool):
try:
converted = from_excel(value)
except (TypeError, ValueError, OverflowError):
return None
if isinstance(converted, datetime):
return converted.replace(tzinfo=None)
if isinstance(converted, time):
return datetime.combine(date(1899, 12, 30), converted)
return None
text = normalize_text(value)
if text is None:
return None
text = text.replace("T", " ").removesuffix("Z")
for fmt in (
"%Y-%m-%d %H:%M:%S",
"%Y/%m/%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y/%m/%d %H:%M",
"%Y-%m-%d",
"%Y/%m/%d",
"%Y年%m月%d",
):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
return None
def row_to_record(
row: Sequence[object],
spec: SheetSpec,
source_row_number: int,
) -> dict[str, object]:
record: dict[str, object] = {"源行号": source_row_number}
for column in spec.columns:
value = row[column.excel_index] if column.excel_index < len(row) else None
record[column.db_name] = normalize_cell(value, column.kind)
return record
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from market_api.core.config import AppSettings
from market_api.db.postgres import connect, replace_table
from market_api.importer.mappings import ALL_SHEET_SPECS, SheetSpec
from market_api.importer.workbook import iter_sheet_records, validate_monthly_workbook
@dataclass(frozen=True)
class TableImportSummary:
sheet_name: str
table_name: str
rows: int
@dataclass(frozen=True)
class ImportSummary:
source_filename: str
schema: str
tables: tuple[TableImportSummary, ...]
@property
def total_rows(self) -> int:
return sum(table.rows for table in self.tables)
def import_monthly_excel(
settings: AppSettings,
workbook_path: str | Path,
source_filename: str | None = None,
specs: tuple[SheetSpec, ...] = ALL_SHEET_SPECS,
) -> ImportSummary:
path = Path(workbook_path)
if not path.exists():
raise FileNotFoundError(f"Excel 文件不存在:{path}")
validate_monthly_workbook(path, specs)
table_summaries: list[TableImportSummary] = []
with connect(settings.database) as conn:
for spec in specs:
count = replace_table(
conn,
settings.schema,
spec,
iter_sheet_records(path, spec),
)
table_summaries.append(
TableImportSummary(
sheet_name=spec.sheet_name,
table_name=spec.table_name,
rows=count,
)
)
return ImportSummary(
source_filename=source_filename or path.name,
schema=settings.schema,
tables=tuple(table_summaries),
)
def is_allowed_upload_filename(filename: str) -> bool:
return bool(filename) and Path(filename).suffix.lower() == ".xlsx"
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
from openpyxl import load_workbook
from market_api.importer.mappings import SheetSpec
from market_api.importer.rows import row_to_record
def normalize_header(value: object) -> str:
if value is None:
return ""
return "".join(str(value).strip().split())
def validate_header_row(headers: tuple[object, ...], spec: SheetSpec) -> None:
problems: list[str] = []
for column in spec.columns:
actual = normalize_header(headers[column.excel_index]) if column.excel_index < len(headers) else ""
expected = normalize_header(column.source_header)
if actual != expected:
problems.append(
f"{column.excel_index + 1} 列期望 {column.source_header!r},实际 {actual!r}"
)
if problems:
preview = "".join(problems[:5])
extra = f";另有 {len(problems) - 5} 个问题" if len(problems) > 5 else ""
raise ValueError(f"{spec.sheet_name} 表头不匹配:{preview}{extra}")
def validate_monthly_workbook(path: str | Path, specs: tuple[SheetSpec, ...]) -> None:
workbook = load_workbook(path, read_only=True, data_only=True)
try:
for spec in specs:
if spec.sheet_name not in workbook.sheetnames:
raise ValueError(f"Excel 中找不到 sheet{spec.sheet_name}")
worksheet = workbook[spec.sheet_name]
rows = worksheet.iter_rows(values_only=True)
try:
headers = next(rows)
except StopIteration as exc:
raise ValueError(f"{spec.sheet_name} 是空 sheet") from exc
validate_header_row(headers, spec)
finally:
workbook.close()
def iter_sheet_records(path: str | Path, spec: SheetSpec) -> Iterator[dict[str, object]]:
workbook = load_workbook(path, read_only=True, data_only=True)
try:
if spec.sheet_name not in workbook.sheetnames:
raise ValueError(f"Excel 中找不到 sheet{spec.sheet_name}")
worksheet = workbook[spec.sheet_name]
rows = worksheet.iter_rows(values_only=True)
try:
headers = next(rows)
except StopIteration:
return
validate_header_row(headers, spec)
for source_row_number, row in enumerate(rows, start=2):
if any(value not in (None, "") for value in row):
yield row_to_record(row, spec, source_row_number)
finally:
workbook.close()
+492
View File
@@ -0,0 +1,492 @@
from __future__ import annotations
from pathlib import Path
from shutil import copyfileobj
from tempfile import NamedTemporaryFile
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from market_api.award.common import build_ct_filter
from market_api.award.competitor_large_deals import (
CompetitorLargeDealsRepository,
build_competitor_large_deals_summary,
)
from market_api.award.industry import (
AwardIndustryRepository,
build_award_industry_summary,
)
from market_api.award.overall import (
AwardOverallRepository,
build_award_overall_summary,
)
from market_api.award.region import (
AwardRegionRepository,
build_award_region_summary,
validate_award_region_scope,
)
from market_api.core.config import get_settings
from market_api.db.postgres import connect
from market_api.importer.service import import_monthly_excel, is_allowed_upload_filename
from market_api.tender.class_trend import (
TenderClassTrendRepository,
build_class_trend_summary,
)
from market_api.tender.customer_139 import (
TenderCustomer139Repository,
build_customer_139_key_projects_summary,
build_customer_139_summary,
)
from market_api.tender.industry import TenderIndustryRepository, build_industry_summary
from market_api.tender.classification import (
TenderClassRepository,
build_tender_class_summary,
)
from market_api.tender.major_buyers import (
TenderMajorBuyersRepository,
build_major_buyers_summary,
)
from market_api.tender.overall import (
TenderOverallRepository,
build_overall_summary,
build_scope_filter,
normalize_month,
)
from market_api.tender.region import (
TenderRegionRepository,
build_region_summary,
validate_region_scope,
)
from market_api.tender.time import TenderTimeRepository, build_tender_time_summary
app = FastAPI(title="Market API", version="0.1.0")
@app.exception_handler(HTTPException)
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse:
detail = exc.detail if isinstance(exc.detail, str) else "请求处理失败"
return JSONResponse(
status_code=exc.status_code,
content={"错误信息": detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
def handle_request_validation_error(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
return JSONResponse(
status_code=422,
content={
"错误信息": "请求参数校验失败",
"错误详情": [
{
"位置": " -> ".join(str(part) for part in error.get("loc", [])),
"提示": error.get("msg", ""),
"类型": error.get("type", ""),
}
for error in exc.errors()
],
},
)
@app.get("/health")
def health() -> dict[str, str]:
return {"状态": "正常"}
@app.get("/tender/overall")
def tender_overall(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderOverallRepository(conn, settings.schema)
return build_overall_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/industry")
def tender_industry(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderIndustryRepository(conn, settings.schema)
return build_industry_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/region")
def tender_region(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
validate_region_scope(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderRegionRepository(conn, settings.schema)
return build_region_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/major-buyers")
def tender_major_buyers(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderMajorBuyersRepository(conn, settings.schema)
return build_major_buyers_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/class")
def tender_class(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderClassRepository(conn, settings.schema)
return build_tender_class_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/class-trend")
def tender_class_trend(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderClassTrendRepository(conn, settings.schema)
return build_class_trend_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/time")
def tender_time(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderTimeRepository(conn, settings.schema)
return build_tender_time_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/customer-139")
def tender_customer_139(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderCustomer139Repository(conn, settings.schema)
return build_customer_139_summary(repository, year, month, scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/tender/customer-139/key-projects")
def tender_customer_139_key_projects(
year: int,
month: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = TenderCustomer139Repository(conn, settings.schema)
return build_customer_139_key_projects_summary(
repository,
year,
month,
scope,
scope_value,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/award/overall")
def award_overall(
year: int,
month: str,
ct: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_ct_filter(ct)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = AwardOverallRepository(conn, settings.schema)
return build_award_overall_summary(
repository,
year,
month,
ct,
scope,
scope_value,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/award/industry")
def award_industry(
year: int,
month: str,
ct: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_ct_filter(ct)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = AwardIndustryRepository(conn, settings.schema)
return build_award_industry_summary(
repository,
year,
month,
ct,
scope,
scope_value,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/award/region")
def award_region(
year: int,
month: str,
ct: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_ct_filter(ct)
validate_award_region_scope(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = AwardRegionRepository(conn, settings.schema)
return build_award_region_summary(
repository,
year,
month,
ct,
scope,
scope_value,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.get("/award/competitor-large-deals")
def award_competitor_large_deals(
year: int,
month: str,
ct: str,
scope: str,
scope_value: str | None = None,
) -> dict[str, object]:
try:
normalize_month(month)
build_ct_filter(ct)
build_scope_filter(scope, scope_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
try:
with connect(settings.database) as conn:
repository = CompetitorLargeDealsRepository(conn, settings.schema)
return build_competitor_large_deals_summary(
repository,
year,
month,
ct,
scope,
scope_value,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"统计失败:{exc}") from exc
@app.post("/imports/monthly")
def import_monthly(file: UploadFile = File(...)) -> dict[str, object]:
upload_filename = Path(file.filename or "").name
if not is_allowed_upload_filename(upload_filename):
raise HTTPException(status_code=400, detail="请上传 .xlsx 格式的月更新数据文件")
with NamedTemporaryFile(prefix="market-api-upload-", suffix=".xlsx") as temporary_file:
copyfileobj(file.file, temporary_file)
temporary_file.flush()
temporary_file.seek(0)
try:
summary = import_monthly_excel(
get_settings(),
temporary_file.name,
source_filename=upload_filename,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"导入失败:{exc}") from exc
return {
"源文件名": summary.source_filename,
"数据库模式": summary.schema,
"总行数": summary.total_rows,
"导入表": [
{
"工作表名称": table.sheet_name,
"数据库表名": table.table_name,
"导入行数": table.rows,
}
for table in summary.tables
],
}
+1
View File
@@ -0,0 +1 @@
"""Tender statistics services."""
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.classification import ENTERPRISE_CATEGORY, GOVERNMENT_CATEGORY
from market_api.tender.common import (
TenderAggregate,
build_scope_filter,
format_yi_yuan,
normalize_month,
)
CATEGORY_COLUMNS = {
GOVERNMENT_CATEGORY: ("政府侧招标次数", "政府侧招标预算金额(亿元)"),
ENTERPRISE_CATEGORY: ("企业侧招标次数", "企业侧招标预算金额(亿元)"),
}
@dataclass(frozen=True)
class MonthPeriod:
year: int
month: int
@property
def label(self) -> str:
return f"{self.year}{self.month}"
class TenderClassTrendRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_by_category(
self,
year: int,
month: int,
scope_filter: tuple[str, str] | None,
) -> dict[str, TenderAggregate]:
categories = tuple(CATEGORY_COLUMNS)
category_placeholders = ", ".join(["%s"] * len(categories))
category_column = quote_identifier("分类")
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} = %s",
f"{category_column} IN ({category_placeholders})",
]
params: list[object] = [year, f"{month}", *categories]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
sql = (
f"SELECT {category_column}, COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {category_column}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return {
str(category): TenderAggregate(int(count or 0), Decimal(str(amount_wan or 0)))
for category, count, amount_wan in rows
}
def build_recent_months(year: int, month: str | int, count: int = 3) -> list[MonthPeriod]:
if count <= 0:
raise ValueError("统计月份数量必须大于 0")
month_number = normalize_month(month)
current_index = year * 12 + month_number - 1
periods: list[MonthPeriod] = []
for offset in range(count - 1, -1, -1):
month_index = current_index - offset
period_year = month_index // 12
period_month = month_index % 12 + 1
periods.append(MonthPeriod(period_year, period_month))
return periods
def _month_row(
period: MonthPeriod,
aggregates_by_category: dict[str, TenderAggregate],
) -> dict[str, object]:
row: dict[str, object] = {"月份": period.label}
for category, (count_column, amount_column) in CATEGORY_COLUMNS.items():
aggregate = aggregates_by_category.get(category, TenderAggregate(0, Decimal("0")))
row[count_column] = aggregate.count
row[amount_column] = format_yi_yuan(aggregate.amount_wan)
return row
def build_class_trend_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
periods = build_recent_months(year, month_number)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"月度数据": [
_month_row(
period,
repository.aggregate_by_category(period.year, period.month, scope_filter),
)
for period in periods
],
}
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import (
TenderAggregate,
build_scope_filter,
format_growth,
format_yi_yuan,
normalize_month,
)
GOVERNMENT_CATEGORY = "政府"
ENTERPRISE_CATEGORY = "企业"
VALID_CATEGORIES = {GOVERNMENT_CATEGORY, ENTERPRISE_CATEGORY}
class TenderClassRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate(
self,
year: int,
months: list[int],
scope_filter: tuple[str, str] | None,
category: str | None,
) -> TenderAggregate:
if not months:
raise ValueError("统计月份不能为空")
if category is not None and category not in VALID_CATEGORIES:
raise ValueError("分类必须是 政府 或 企业")
month_placeholders = ", ".join(["%s"] * len(months))
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
]
params: list[object] = [year, *[f"{month}" for month in months]]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
if category is not None:
where_parts.append(f"{quote_identifier('分类')} = %s")
params.append(category)
sql = (
"SELECT COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
row = cursor.fetchone()
if row is None:
return TenderAggregate(0, Decimal("0"))
count, amount_wan = row
return TenderAggregate(int(count or 0), Decimal(str(amount_wan or 0)))
def _share(value: Decimal | int, total: Decimal | int) -> Decimal | None:
total_value = Decimal(str(total))
if total_value == 0:
return None
return Decimal(str(value)) / total_value * Decimal("100")
def _format_share(value: Decimal | None) -> str:
if value is None:
return "/"
rounded = value.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)
return f"{rounded:.1f}%"
def _category_report_row(
current: TenderAggregate,
total: TenderAggregate,
previous: TenderAggregate | None,
) -> dict[str, object]:
if previous is None:
count_growth = "/"
amount_growth = "/"
else:
count_growth = format_growth(current.count, previous.count)
amount_growth = format_growth(current.amount_wan, previous.amount_wan)
return {
"招标次数": current.count,
"招标次数环比增幅": count_growth,
"招标预算金额(亿元)": format_yi_yuan(current.amount_wan),
"招标预算金额环比增幅": amount_growth,
"招标次数占比": _format_share(_share(current.count, total.count)),
"招标金额占比": _format_share(_share(current.amount_wan, total.amount_wan)),
}
def build_tender_class_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
cumulative_months = list(range(1, month_number + 1))
total = repository.aggregate(year, cumulative_months, scope_filter, None)
government = repository.aggregate(year, cumulative_months, scope_filter, GOVERNMENT_CATEGORY)
enterprise = repository.aggregate(year, cumulative_months, scope_filter, ENTERPRISE_CATEGORY)
if month_number == 1:
previous_government = None
previous_enterprise = None
else:
previous_cumulative_months = list(range(1, month_number))
previous_government = repository.aggregate(
year,
previous_cumulative_months,
scope_filter,
GOVERNMENT_CATEGORY,
)
previous_enterprise = repository.aggregate(
year,
previous_cumulative_months,
scope_filter,
ENTERPRISE_CATEGORY,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"政府侧累计数据": _category_report_row(government, total, previous_government),
"企业侧累计数据": _category_report_row(enterprise, total, previous_enterprise),
}
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
VALID_SCOPES = {"上海", "行政区", "属地"}
SCOPE_FILTER_COLUMNS = {
"行政区": "归属区域_行政",
"属地": "归属区域_属地",
}
@dataclass(frozen=True)
class TenderAggregate:
count: int
amount_wan: Decimal
def normalize_month(month: str | int) -> int:
if isinstance(month, int):
month_number = month
else:
raw_month = str(month).strip()
if raw_month.endswith(""):
raw_month = raw_month[:-1]
if not raw_month.isdecimal():
raise ValueError("统计月份必须是 1-12 或 x月 格式")
month_number = int(raw_month)
if not 1 <= month_number <= 12:
raise ValueError("统计月份必须在 1-12 之间")
return month_number
def build_scope_filter(scope: str, scope_value: str | None) -> tuple[str, str] | None:
normalized_value = scope_value.strip() if scope_value is not None else ""
if scope not in VALID_SCOPES:
raise ValueError("统计方式必须是 上海、行政区 或 属地")
if scope == "上海":
if normalized_value:
raise ValueError("统计方式为上海时统计参数必须留空")
return None
if not normalized_value:
raise ValueError(f"统计方式为{scope}时统计参数不能为空")
return SCOPE_FILTER_COLUMNS[scope], normalized_value
def format_yi_yuan(amount_wan: Decimal | int | float | None) -> str:
amount = Decimal("0") if amount_wan is None else Decimal(str(amount_wan))
amount_yi = (amount / Decimal("10000")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{amount_yi:.2f}"
def format_growth(current: Decimal | int, baseline: Decimal | int) -> str:
baseline_value = Decimal(str(baseline))
if baseline_value == 0:
return "/"
current_value = Decimal(str(current))
growth = ((current_value - baseline_value) / baseline_value * Decimal("100")).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
return f"{growth:+.2f}"
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import (
TenderAggregate,
build_scope_filter,
format_yi_yuan,
normalize_month,
)
CUSTOMER_139_VALUE = ""
KEY_PROJECT_LIMIT = 10
@dataclass(frozen=True)
class Customer139KeyProject:
unit: str | None
title: str | None
amount_wan: Decimal
class TenderCustomer139Repository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_monthly(
self,
year: int,
month: int,
scope_filter: tuple[str, str] | None,
) -> TenderAggregate:
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} = %s",
f"{quote_identifier('是否139客户')} = %s",
]
params: list[object] = [year, f"{month}", CUSTOMER_139_VALUE]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
sql = (
"SELECT COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
row = cursor.fetchone()
if row is None:
return TenderAggregate(0, Decimal("0"))
count, amount_wan = row
return TenderAggregate(int(count or 0), Decimal(str(amount_wan or 0)))
def top_key_projects(
self,
year: int,
month: int,
scope_filter: tuple[str, str] | None,
limit: int = KEY_PROJECT_LIMIT,
) -> list[Customer139KeyProject]:
if limit <= 0:
raise ValueError("重点项目数量必须大于 0")
unit_column = quote_identifier("招标单位")
title_column = quote_identifier("标题")
amount_column = quote_identifier("预算金额_万元")
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} = %s",
f"{quote_identifier('是否139客户')} = %s",
]
params: list[object] = [year, f"{month}", CUSTOMER_139_VALUE]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
sql = (
f"SELECT {unit_column}, {title_column}, "
f"COALESCE({amount_column}, 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"ORDER BY amount_wan DESC, {title_column} ASC, {unit_column} ASC "
f"LIMIT {limit}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [
Customer139KeyProject(
None if unit is None else str(unit),
None if title is None else str(title),
Decimal(str(amount_wan or 0)),
)
for unit, title, amount_wan in rows
]
def format_wan_amount(amount: Decimal | int | float | None) -> str:
value = Decimal("0") if amount is None else Decimal(str(amount))
return format(value.normalize(), "f")
def _project_rows(projects: list[Customer139KeyProject]) -> list[dict[str, object]]:
return [
{
"招标单位": project.unit,
"项目名称": project.title,
"招标预算金额(万元)": format_wan_amount(project.amount_wan),
}
for project in projects
]
def build_customer_139_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
aggregate = repository.aggregate_monthly(year, month_number, scope_filter)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"月度数据": {
"招标次数": aggregate.count,
"招标预算金额(亿元)": format_yi_yuan(aggregate.amount_wan),
},
}
def build_customer_139_key_projects_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
projects = repository.top_key_projects(year, month_number, scope_filter)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"重点项目": _project_rows(projects),
}
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import (
TenderAggregate,
build_scope_filter,
format_growth,
format_yi_yuan,
normalize_month,
)
INDUSTRIES = (
"党政",
"金融",
"教育",
"工业能源",
"医卫",
"交通",
"商客",
"农业文旅",
"互联网",
"融合创新",
)
EMPTY_AGGREGATE = TenderAggregate(0, Decimal("0"))
class TenderIndustryRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_by_industry(
self,
year: int,
months: list[int],
scope_filter: tuple[str, str] | None,
) -> dict[str, TenderAggregate]:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
industry_placeholders = ", ".join(["%s"] * len(INDUSTRIES))
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
f"{quote_identifier('行业')} IN ({industry_placeholders})",
]
params: list[object] = [
year,
*[f"{month}" for month in months],
*INDUSTRIES,
]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
industry_column = quote_identifier("行业")
sql = (
f"SELECT {industry_column}, COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {industry_column}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return {
str(industry): TenderAggregate(int(count or 0), Decimal(str(amount_wan or 0)))
for industry, count, amount_wan in rows
}
def _industry_row(
industry: str,
current: TenderAggregate,
baseline: TenderAggregate | None,
count_growth_key: str,
amount_growth_key: str,
) -> dict[str, object]:
if baseline is None:
count_growth = "/"
amount_growth = "/"
else:
count_growth = format_growth(current.count, baseline.count)
amount_growth = format_growth(current.amount_wan, baseline.amount_wan)
return {
"行业": industry,
"个数": current.count,
"金额(亿元)": format_yi_yuan(current.amount_wan),
count_growth_key: count_growth,
amount_growth_key: amount_growth,
}
def _industry_rows(
current_by_industry: dict[str, TenderAggregate],
baseline_by_industry: dict[str, TenderAggregate] | None,
count_growth_key: str,
amount_growth_key: str,
) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for industry in INDUSTRIES:
current = current_by_industry.get(industry, EMPTY_AGGREGATE)
baseline = (
None
if baseline_by_industry is None
else baseline_by_industry.get(industry, EMPTY_AGGREGATE)
)
rows.append(_industry_row(industry, current, baseline, count_growth_key, amount_growth_key))
return rows
def build_industry_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
cumulative_months = list(range(1, month_number + 1))
current_cumulative = repository.aggregate_by_industry(year, cumulative_months, scope_filter)
baseline_cumulative = repository.aggregate_by_industry(
year - 1,
cumulative_months,
scope_filter,
)
current_monthly = repository.aggregate_by_industry(year, [month_number], scope_filter)
if month_number == 1:
previous_monthly = None
else:
previous_monthly = repository.aggregate_by_industry(year, [month_number - 1], scope_filter)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"累计数据": _industry_rows(
current_cumulative,
baseline_cumulative,
"个数同比增幅",
"金额同比增幅",
),
"月度数据": _industry_rows(
current_monthly,
previous_monthly,
"个数环比增幅",
"金额环比增幅",
),
}
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import build_scope_filter, normalize_month
GOVERNMENT_CATEGORY = "政府"
ENTERPRISE_CATEGORY = "企业"
MEANINGLESS_UNIT_KEYWORDS = ("", "未知", "未披露", "匿名", "不详", "保密", "无名")
@dataclass(frozen=True)
class MajorBuyerRow:
unit: str
count: int
amount_wan: Decimal
class TenderMajorBuyersRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def top_buyers(
self,
category: str,
year: int,
months: list[int],
scope_filter: tuple[str, str] | None,
) -> list[MajorBuyerRow]:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
unit_column = quote_identifier("招标单位")
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
f"{quote_identifier('分类')} = %s",
f"{unit_column} IS NOT NULL",
f"{unit_column} <> %s",
*[f"{unit_column} NOT LIKE %s" for _ in MEANINGLESS_UNIT_KEYWORDS],
]
params: list[object] = [
year,
*[f"{month}" for month in months],
category,
"",
*[f"%{keyword}%" for keyword in MEANINGLESS_UNIT_KEYWORDS],
]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
sql = (
f"SELECT {unit_column}, COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {unit_column} "
f"ORDER BY amount_wan DESC, tender_count DESC, {unit_column} ASC "
"LIMIT 10"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [
MajorBuyerRow(str(unit), int(count or 0), Decimal(str(amount_wan or 0)))
for unit, count, amount_wan in rows
]
def format_wan_amount(amount: Decimal | int | float | None) -> str:
value = Decimal("0") if amount is None else Decimal(str(amount))
return format(value.normalize(), "f")
def _report_rows(rows: list[MajorBuyerRow]) -> list[dict[str, object]]:
return [
{
"招标单位": row.unit,
"招标次数": row.count,
"招标预算金额(万元)": format_wan_amount(row.amount_wan),
}
for row in rows
]
def build_major_buyers_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
months = list(range(1, month_number + 1))
government_rows = repository.top_buyers(
GOVERNMENT_CATEGORY,
year,
months,
scope_filter,
)
enterprise_rows = repository.top_buyers(
ENTERPRISE_CATEGORY,
year,
months,
scope_filter,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"政府侧招标单位": _report_rows(government_rows),
"企业侧招标单位": _report_rows(enterprise_rows),
}
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import (
TenderAggregate,
build_scope_filter,
format_growth,
format_yi_yuan,
normalize_month,
)
class TenderOverallRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate(
self,
year: int,
months: list[int],
scope_filter: tuple[str, str] | None,
) -> TenderAggregate:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
]
params: list[object] = [year, *[f"{month}" for month in months]]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
sql = (
"SELECT COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
row = cursor.fetchone()
if row is None:
return TenderAggregate(0, Decimal("0"))
count, amount_wan = row
return TenderAggregate(int(count or 0), Decimal(str(amount_wan or 0)))
def build_overall_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
cumulative_months = list(range(1, month_number + 1))
current_cumulative = repository.aggregate(year, cumulative_months, scope_filter)
baseline_cumulative = repository.aggregate(year - 1, cumulative_months, scope_filter)
current_monthly = repository.aggregate(year, [month_number], scope_filter)
if month_number == 1:
count_monthly_growth = "/"
amount_monthly_growth = "/"
else:
previous_monthly = repository.aggregate(year, [month_number - 1], scope_filter)
count_monthly_growth = format_growth(current_monthly.count, previous_monthly.count)
amount_monthly_growth = format_growth(
current_monthly.amount_wan,
previous_monthly.amount_wan,
)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"累计数据": {
"招标次数": current_cumulative.count,
"招标预算金额(亿元)": format_yi_yuan(current_cumulative.amount_wan),
"招标次数同比增幅": format_growth(
current_cumulative.count,
baseline_cumulative.count,
),
"招标金额同比增幅": format_growth(
current_cumulative.amount_wan,
baseline_cumulative.amount_wan,
),
},
"月度数据": {
"招标次数": current_monthly.count,
"招标预算金额(亿元)": format_yi_yuan(current_monthly.amount_wan),
"招标次数环比增幅": count_monthly_growth,
"招标金额环比增幅": amount_monthly_growth,
},
}
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.common import (
TenderAggregate,
format_growth,
format_yi_yuan,
normalize_month,
)
REGIONS = (
"战客",
"浦东",
"南区",
"西区",
"北区",
"闵行",
"松江",
"宝山",
"青浦",
"奉贤",
"嘉定",
"金山",
"崇明",
"互拓",
"其他",
)
EMPTY_AGGREGATE = TenderAggregate(0, Decimal("0"))
class TenderRegionRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_by_region(self, year: int, months: list[int]) -> dict[str, TenderAggregate]:
if not months:
raise ValueError("统计月份不能为空")
month_placeholders = ", ".join(["%s"] * len(months))
region_placeholders = ", ".join(["%s"] * len(REGIONS))
region_column = quote_identifier("归属区域_属地")
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
f"{region_column} IN ({region_placeholders})",
]
params: list[object] = [
year,
*[f"{month}" for month in months],
*REGIONS,
]
sql = (
f"SELECT {region_column}, COUNT(*) AS tender_count, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {region_column}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return {
str(region): TenderAggregate(int(count or 0), Decimal(str(amount_wan or 0)))
for region, count, amount_wan in rows
}
def validate_region_scope(scope: str, scope_value: str | None) -> None:
if scope != "上海":
raise ValueError("区域招标分析仅支持统计方式为上海")
if scope_value is not None and scope_value.strip():
raise ValueError("区域招标分析的统计参数必须留空")
def _region_row(
region: str,
current: TenderAggregate,
baseline: TenderAggregate | None,
count_growth_key: str,
amount_growth_key: str,
) -> dict[str, object]:
if baseline is None:
count_growth = "/"
amount_growth = "/"
else:
count_growth = format_growth(current.count, baseline.count)
amount_growth = format_growth(current.amount_wan, baseline.amount_wan)
return {
"属地": region,
"个数": current.count,
"金额(亿元)": format_yi_yuan(current.amount_wan),
count_growth_key: count_growth,
amount_growth_key: amount_growth,
}
def _region_rows(
current_by_region: dict[str, TenderAggregate],
baseline_by_region: dict[str, TenderAggregate] | None,
count_growth_key: str,
amount_growth_key: str,
) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for region in REGIONS:
current = current_by_region.get(region, EMPTY_AGGREGATE)
baseline = (
None
if baseline_by_region is None
else baseline_by_region.get(region, EMPTY_AGGREGATE)
)
rows.append(_region_row(region, current, baseline, count_growth_key, amount_growth_key))
return rows
def build_region_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
validate_region_scope(scope, scope_value)
month_number = normalize_month(month)
cumulative_months = list(range(1, month_number + 1))
current_cumulative = repository.aggregate_by_region(year, cumulative_months)
baseline_cumulative = repository.aggregate_by_region(year - 1, cumulative_months)
current_monthly = repository.aggregate_by_region(year, [month_number])
if month_number == 1:
previous_monthly = None
else:
previous_monthly = repository.aggregate_by_region(year, [month_number - 1])
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": "上海",
"统计参数": None,
"累计数据": _region_rows(
current_cumulative,
baseline_cumulative,
"个数同比增幅",
"金额同比增幅",
),
"月度数据": _region_rows(
current_monthly,
previous_monthly,
"个数环比增幅",
"金额环比增幅",
),
}
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from market_api.db.sql import qualify_table, quote_identifier
from market_api.tender.classification import ENTERPRISE_CATEGORY, GOVERNMENT_CATEGORY
from market_api.tender.common import build_scope_filter, format_yi_yuan, normalize_month
CATEGORY_COLUMNS = {
GOVERNMENT_CATEGORY: "政府侧招采金额(亿元)",
ENTERPRISE_CATEGORY: "企业侧招采金额(亿元)",
}
@dataclass(frozen=True)
class QuarterPeriod:
year: int
quarter: int
@property
def months(self) -> list[int]:
start_month = (self.quarter - 1) * 3 + 1
return [start_month, start_month + 1, start_month + 2]
@property
def label(self) -> str:
return f"{self.year}{self.quarter}季度"
class TenderTimeRepository:
def __init__(self, conn: Any, schema: str):
self.conn = conn
self.schema = schema
def aggregate_by_category(
self,
year: int,
months: list[int],
scope_filter: tuple[str, str] | None,
) -> dict[str, Decimal]:
if not months:
raise ValueError("统计月份不能为空")
categories = tuple(CATEGORY_COLUMNS)
month_placeholders = ", ".join(["%s"] * len(months))
category_placeholders = ", ".join(["%s"] * len(categories))
category_column = quote_identifier("分类")
where_parts = [
f"{quote_identifier('年份')} = %s",
f"{quote_identifier('月份')} IN ({month_placeholders})",
f"{category_column} IN ({category_placeholders})",
]
params: list[object] = [
year,
*[f"{month}" for month in months],
*categories,
]
if scope_filter is not None:
column_name, scope_value = scope_filter
where_parts.append(f"{quote_identifier(column_name)} = %s")
params.append(scope_value)
sql = (
f"SELECT {category_column}, "
f"COALESCE(SUM({quote_identifier('预算金额_万元')}), 0) AS amount_wan "
f"FROM {qualify_table(self.schema, '公开市场招标数据')} "
f"WHERE {' AND '.join(where_parts)} "
f"GROUP BY {category_column}"
)
with self.conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return {str(category): Decimal(str(amount_wan or 0)) for category, amount_wan in rows}
def build_recent_completed_quarters(
year: int,
month: str | int,
count: int = 5,
) -> list[QuarterPeriod]:
if count <= 0:
raise ValueError("统计季度数量必须大于 0")
month_number = normalize_month(month)
latest_quarter = month_number // 3
latest_year = year
if latest_quarter == 0:
latest_year -= 1
latest_quarter = 4
quarters: list[QuarterPeriod] = []
for offset in range(count):
quarter_index = (latest_year * 4 + latest_quarter - 1) - offset
quarter_year = quarter_index // 4
quarter = quarter_index % 4 + 1
quarters.append(QuarterPeriod(quarter_year, quarter))
return list(reversed(quarters))
def _quarter_row(
period: QuarterPeriod,
amounts_by_category: dict[str, Decimal],
) -> dict[str, object]:
row: dict[str, object] = {"季度": period.label}
for category, column_name in CATEGORY_COLUMNS.items():
row[column_name] = format_yi_yuan(amounts_by_category.get(category, Decimal("0")))
return row
def build_tender_time_summary(
repository: object,
year: int,
month: str | int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
month_number = normalize_month(month)
normalized_scope_value = scope_value.strip() if scope_value is not None else None
scope_filter = build_scope_filter(scope, normalized_scope_value)
quarters = build_recent_completed_quarters(year, month_number)
return {
"统计年份": year,
"统计月份": f"{month_number}",
"统计方式": scope,
"统计参数": normalized_scope_value if scope != "上海" else None,
"季度数据": [
_quarter_row(
quarter,
repository.aggregate_by_category(quarter.year, quarter.months, scope_filter),
)
for quarter in quarters
],
}
Binary file not shown.
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
# PPT 自动更新模块总览
本目录用于管理服务月报 PPT 的自动更新脚本。当前已完成的是上海市整体服务月报;后续行政区服务月报会作为独立模块建设,避免模板、口径和脚本路径混淆。
## 模块划分
```text
ppt_update/
README.md 总入口
shanghai_city/ 上海市整体服务月报
README.md 上海市整体服务月报使用指南
page_updates/
P1_update.py
...
P9_update.py
administrative_district/ 行政区服务月报
README.md
page_updates/
P1_update.py
...
P6_update.py
```
## 模板对应关系
| 模块 | 模板 | 状态 |
| --- | --- | --- |
| `shanghai_city` | `ppt_templates/上海市服务月报.pptx` | 已完成 P1-P9 |
| `administrative_district` | `ppt_templates/行政区服务月报.pptx` | 已完成 P1-P7 |
## 上海市整体服务月报
上海市整体服务月报的脚本位于:
```text
ppt_update/shanghai_city/page_updates/
```
单页脚本示例:
```bash
python3 ppt_update/shanghai_city/page_updates/P1_update.py \
--pptx ppt_templates/上海市服务月报.pptx \
--output output/上海市服务月报-P1更新.pptx \
--year 2026 \
--month 4月 \
--api-base http://127.0.0.1:8000
```
完整逻辑、页面口径、接口来源和验证方式见:
```text
ppt_update/shanghai_city/README.md
```
## 行政区服务月报
行政区服务月报的脚本位于:
```text
ppt_update/administrative_district/page_updates/
```
单页脚本示例:
```bash
python3 ppt_update/administrative_district/page_updates/P1_update.py \
--pptx ppt_templates/行政区服务月报.pptx \
--output output/浦东新区服务月报-P1更新.pptx \
--year 2026 \
--month 4月 \
--district 浦东新区 \
--api-base http://127.0.0.1:8000
```
完整逻辑、页面口径、接口来源和验证方式见:
```text
ppt_update/administrative_district/README.md
```
+1
View File
@@ -0,0 +1 @@
"""PPTX update modules for service monthly reports."""
@@ -0,0 +1,151 @@
# 行政区服务月报 PPT 自动更新指南
本目录用于按页更新 `ppt_templates/行政区服务月报.pptx`。当前行政区模板共 7 页,每一页由一个单文件 Python 脚本负责读取 FastAPI 数据、计算页面口径,并写回 PPTX 中对应的文本、表格、原生图表和内嵌 workbook。
行政区脚本统一使用行政区口径:
```text
scope=行政区
scope_value=<行政区名称>
```
运营商中标侧接口额外固定使用:
```text
ct=是
```
## 核心原则
- 每一页脚本都是独立文件,位于 `ppt_update/administrative_district/page_updates/P{页码}_update.py`
- 所有数据只来自本机 FastAPI,不读取旧 `scripts/`,不使用 JSON fallback。
- 默认 FastAPI 地址为 `http://127.0.0.1:8000`
- 每个脚本必须传入 `--district`,例如 `--district 浦东新区`
- 脚本只更新自己负责的页面,其余页面和模板样式尽量保持不变。
- 脚本不额外处理字体、字号、文本框位置或模板版式。
- 无法完全自动分析的内容保留双花括号事实占位符,格式为 `{{数据事实;请基于数据进行概括总结分析,凸显数据特征}}` 或同义短提示。
- 可比较字段为 `/` 时,表格单元格显示 `/`;分析描述保留数据事实,并将 `/` 转为事实占位符或可比不足提示。
- 支持输入 PPTX 与输出 PPTX 为同一路径,脚本会用临时文件安全替换。
## 单页更新用法
```bash
python3 ppt_update/administrative_district/page_updates/P1_update.py \
--pptx ppt_templates/行政区服务月报.pptx \
--output output/浦东新区服务月报-P1更新.pptx \
--year 2026 \
--month 4月 \
--district 浦东新区 \
--api-base http://127.0.0.1:8000
```
## 整份 PPT 串行更新
```bash
python3 ppt_update/administrative_district/page_updates/P1_update.py --pptx ppt_templates/行政区服务月报.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
python3 ppt_update/administrative_district/page_updates/P2_update.py --pptx output/浦东新区服务月报-自动更新.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
python3 ppt_update/administrative_district/page_updates/P3_update.py --pptx output/浦东新区服务月报-自动更新.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
python3 ppt_update/administrative_district/page_updates/P4_update.py --pptx output/浦东新区服务月报-自动更新.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
python3 ppt_update/administrative_district/page_updates/P5_update.py --pptx output/浦东新区服务月报-自动更新.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
python3 ppt_update/administrative_district/page_updates/P6_update.py --pptx output/浦东新区服务月报-自动更新.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
python3 ppt_update/administrative_district/page_updates/P7_update.py --pptx output/浦东新区服务月报-自动更新.pptx --output output/浦东新区服务月报-自动更新.pptx --year 2026 --month 4月 --district 浦东新区 --api-base http://127.0.0.1:8000
```
## 页面更新设计卡
| 页面 | 页面主题 | FastAPI 接口清单 | 模板可更新对象 | 自动生成内容 | 后置保留内容 |
| --- | --- | --- | --- | --- | --- |
| P1 | 行政区招标总览驾驶舱 | `/tender/overall`, `/tender/industry`, `/tender/major-buyers`, `/tender/class`, `/tender/time`, `/tender/customer-139` | 文本占位符、季度节奏表 | 行政区标题、累计金额/次数、同比、行业 Top、客户 Top5、政府/企业侧累计卡片、季度表、139 月度规模和次数 | 规划、行业、客群分析类文本以事实占位符保留;规划要点保留模板占位符 |
| P2 | 累计行业招标分析 | `/tender/industry` | 表格、`chart1``Workbook1`、行业分析占位符 | 累计行业 Top10 表、行业招标金额/次数图、行业 Top3 总览与 Top10 事实占位符 | 行业特征总结保留双花括号,供后续概括 |
| P3 | 月度招标节奏与近三月趋势 | `/tender/overall`, `/tender/industry`, `/tender/class-trend` | 月度整体表、月度行业表、`chart2`-`chart5``Workbook2`-`Workbook5`、趋势占位符 | 单月整体指标、月度行业 Top3、近三月政府侧/企业侧招标次数与金额图 | 月度整体、行业、趋势分析保留事实占位符 |
| P4 | 政府/企业 Top5 客户与 139 重点项目 | `/tender/major-buyers`, `/tender/customer-139`, `/tender/customer-139/key-projects` | 政府侧客户表、企业侧客户表、139 重点项目表、标题占位符、P4 备注页 | 标题周期、Top10 改 Top5、政府/企业 Top5 客户、139 月度摘要、139 重点项目 Top5、备注页写入 139 重点项目 Top10 | 139 总览与项目共性保留事实占位符 |
| P5 | 行政区运营商市场中标总览 | `/award/overall`, `/award/industry`, 当前月、去年同期、上月 `/award/overall` | 文本占位符、`chart6`-`chart11``Workbook6`-`Workbook11` | 累计/单月中标 KPI、同比/环比、运营商图表、行业累计金额分布图、行业累计个数分布图 | 运营商和行业判断保留短事实占位符 |
| P6 | 累计行业中标分析 | `/award/industry` | 行业份额表、`chart12``chart13``Workbook12``Workbook13` | 行业 Top10 竞争份额表、累计中标个数/金额图 | 行业总览与 Top3 行业竞争分析保留事实占位符 |
| P7 | 当月友商中标大单 | `/award/competitor-large-deals` | 电信大单表、联通大单表 | 电信/联通当月中标大单 Top8;空数据写“无” | 无 |
## 关键字段映射
### 招标侧 P1-P4
- P1 总体 KPI`/tender/overall.累计数据.招标预算金额(亿元)``招标金额同比增幅``招标次数``招标次数同比增幅`
- P1 行业列表:`/tender/industry.累计数据`,按 `金额(亿元)``金额同比增幅` 排序。
- P1 季度表:`/tender/time.季度数据` 最近 5 个季度,写入政府/企业侧招采金额。
- P1 政府/企业卡片:`/tender/class.政府侧累计数据``企业侧累计数据`
- P1/P4 139 客户:`/tender/customer-139.月度数据.招标次数``招标预算金额(亿元)`
- P2 表格和图表:`/tender/industry.累计数据`,按累计金额降序取 Top10。
- P3 月度表:`/tender/overall.月度数据``/tender/industry.月度数据`,行业按月度金额降序取 Top3。
- P3 趋势图:`/tender/class-trend.月度数据` 最近 3 个月;金额接口字段为亿元,写入趋势金额图时转为万元。
- P4 客户表:`/tender/major-buyers.政府侧招标单位``企业侧招标单位`,过滤无意义客户名称后按金额降序取 Top5。
- P4 重点项目表:`/tender/customer-139/key-projects.重点项目`,按预算金额降序取 Top5;同一排序下的 Top10 写入第 4 页备注。
### 中标侧 P5-P7
- P5 总览:`/award/overall.累计数据``月度数据`,分别计算累计总量、单月总量、去年同期累计同比、上月单月环比。
- P5 行业图:`chart10` 使用 `/award/industry.累计数据.金额数据`,按三家运营商合计中标金额排序;`chart11` 使用 `/award/industry.累计数据.个数数据`,按三家运营商合计中标个数排序。
- P6 行业份额:`/award/industry.累计数据.个数数据``金额数据`,按累计中标金额排序取 Top10。
- P7 大单:`/award/competitor-large-deals.电信大单``联通大单`,按中标金额降序取 Top8。
## 后置占位符规则
部分内容需要后续智能体或人工基于事实进行概括总结,不在脚本里直接生成最终判断。脚本会保留双花括号,但会先填入数据事实:
```text
{{月度金额Top3行业为党政5.00亿、商客4.60亿、医卫4.20亿,合计占比100.0%;请基于数据进行概括总结分析,凸显数据特征}}
```
模板文本框空间较紧的页面会使用短提示或纯事实占位符,例如:
```text
{{移动累计:10个/1.00亿,金额份额20.0%,同比+2pp}}
{{行业Top3:党政1.80/商客1.77/医卫1.74亿,占比34.1%}}
```
这种双花括号不是未更新错误,而是给后续二次修订保留的明确标记。检查残留时应关注 `{{P1...}}``{{P7...}}` 这类模板占位符是否仍存在。
## 异常策略
所有页面均采用严格错误策略:
- FastAPI 连接失败、HTTP 非 200、返回值不是 JSON:直接报错。
- 必填数据节点缺失:直接报错。
- 必填字段为空或不是数字:直接报错。
- 排序所需数据不足:直接报错。
- 模板缺少占位符、表格、图表或 workbook:直接报错。
- 不自动补默认值,不静默跳过,不降级到旧数据。
- 同比、环比、份额变化等可比较字段为 `/` 时,展示字段原样写 `/`;描述字段保留金额、次数、份额等事实,再交给后置占位符总结。
## 测试与验证
运行行政区页面专项测试:
```bash
python3 -m unittest tests/test_administrative_district_updates.py
```
脚本编译检查:
```bash
python3 -m py_compile ppt_update/administrative_district/page_updates/P{1,2,3,4,5,6,7}_update.py
```
全量回归:
```bash
python3 -m unittest discover -s tests
```
建议每次修改模板或脚本后至少执行:
1. 对应单页测试或脚本编译检查。
2. 行政区专项测试和全量回归测试。
3. 用 FastAPI 真实生成输出 PPTX。
4. 拆包检查无 `{{P1...}}``{{P7...}}` 模板占位符和 `XXXX` 残留;事实占位符可按设计保留。
5. 使用 LibreOffice 或 PowerPoint 渲染核验重点页,确认文本未溢出、表格未扩展、图表/workbook 与表格内容同步。
## 维护建议
- 新增或调整模板占位符时,先改对应测试,再改模板与脚本。
- 页面口径变更时,优先在对应 `P{页码}_update.py` 文件顶部注释和本 README 中同步说明。
- 不要把跨页公共逻辑抽到旧 `scripts/` 中;当前设计要求每页脚本自包含,便于智能体按页理解和执行。
- 如果 FastAPI 接口字段发生变化,先更新页面测试中的样例响应,再修改脚本解析逻辑。
- 如果模板页内图表编号、workbook 编号或表格数量变化,必须同步更新对应页面测试,防止脚本写错对象。
@@ -0,0 +1 @@
"""Administrative district service monthly report update scripts."""
@@ -0,0 +1,561 @@
#!/usr/bin/env python3
"""Update slide 1 of the administrative district service monthly PPTX.
取数逻辑:
- `/tender/overall?year={year}&month={month}&scope=行政区&scope_value={district}`
更新累计招标金额、累计招标次数和同比指标。
- `/tender/industry?year={year}&month={month}&scope=行政区&scope_value={district}`
更新增长最强行业、规模最大行业和经营主线。
- `/tender/major-buyers?year={year}&month={month}&scope=行政区&scope_value={district}`
更新政府侧、企业侧 Top5 客户。
- `/tender/class?year={year}&month={month}&scope=行政区&scope_value={district}`
更新政府侧、企业侧累计招标规模、个数和占比。
"""
from __future__ import annotations
import argparse
import json
import re
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
PAGE_NUMBER = 1
SLIDE_XML = "ppt/slides/slide1.xml"
DATA_SOURCES = [
"/tender/overall",
"/tender/industry",
"/tender/major-buyers",
"/tender/class",
"/tender/time",
"/tender/customer-139",
]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
class P1UpdateError(ValueError):
"""Raised when P1 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class P1UpdateModel:
replacements: dict[str, str]
quarter_table_rows: list[list[str]]
sequential_replacements: dict[str, list[str]]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P1UpdateError(f"P1 更新失败:{field_path} 为空,无法更新第一页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P1UpdateError(f"P1 更新失败:{field_path} 不是可计算数字,无法更新第一页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P1UpdateError(f"P1 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P1UpdateError(f"P1 更新失败:{field_path} 为空,无法更新第一页。")
return text
def _required_dict(response: dict[str, Any], endpoint: str, key: str) -> dict[str, Any]:
value = response.get(key)
if not isinstance(value, dict):
raise P1UpdateError(f"P1 更新失败:{endpoint} 响应缺少 {key}")
return value
def _required_rows(response: dict[str, Any], endpoint: str, key: str) -> list[dict[str, Any]]:
value = response.get(key)
if not isinstance(value, list) or not value:
raise P1UpdateError(f"P1 更新失败:{endpoint} 响应缺少 {key}")
rows = [row for row in value if isinstance(row, dict)]
if len(rows) != len(value):
raise P1UpdateError(f"P1 更新失败:{endpoint}.{key} 中存在非对象行。")
return rows
def _format_growth(value: Any, field_path: str) -> str:
number = _parse_decimal(value, field_path)
sign = "+" if number > 0 else ""
return f"{sign}{_format_decimal(number)}%"
def _format_growth_symbol(value: Any, field_path: str) -> str:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return "/"
return _format_growth(value, field_path)
def _format_optional_growth(value: Any, field_path: str) -> str:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return "/"
return _format_growth(value, field_path)
def _format_amount_yi(value: Any, field_path: str) -> str:
return _format_decimal(_parse_decimal(value, field_path))
def _format_amount_wan_from_yi(value: Any, field_path: str) -> str:
return _format_decimal((_parse_decimal(value, field_path) * Decimal("10000")).quantize(Decimal("0.01")))
def _top_rows_by_amount(rows: list[dict[str, Any]], count: int) -> list[dict[str, Any]]:
if len(rows) < count:
raise P1UpdateError(f"P1 更新失败:/tender/industry.累计数据 少于 {count} 行。")
return sorted(
rows,
key=lambda row: _parse_decimal(row.get("金额(亿元)"), "/tender/industry.累计数据.金额(亿元)"),
reverse=True,
)[:count]
def _top_rows_by_growth(rows: list[dict[str, Any]], count: int) -> list[dict[str, Any]]:
candidates: list[tuple[Decimal, dict[str, Any]]] = []
for row in rows:
raw_growth = row.get("金额同比增幅")
if raw_growth is None or str(raw_growth).strip() == "/":
continue
candidates.append(
(
_parse_decimal(raw_growth, "/tender/industry.累计数据.金额同比增幅"),
row,
)
)
if len(candidates) < count:
raise P1UpdateError(f"P1 更新失败:可排序的行业金额同比增幅少于 {count} 行。")
return [row for _, row in sorted(candidates, key=lambda item: item[0], reverse=True)[:count]]
def _top_buyers(response: dict[str, Any], section: str, count: int) -> list[dict[str, Any]]:
rows = _required_rows(response, "/tender/major-buyers", section)
if len(rows) < count:
raise P1UpdateError(f"P1 更新失败:/tender/major-buyers.{section} 少于 {count} 行。")
return rows[:count]
def _side_class(response: dict[str, Any], key: str) -> dict[str, Any]:
return _required_dict(response, "/tender/class", key)
def _industry_amount_label(row: dict[str, Any]) -> str:
industry = _required_text(row, "行业", "/tender/industry.累计数据.行业")
amount = _format_amount_yi(row.get("金额(亿元)"), f"/tender/industry.累计数据.{industry}.金额(亿元)")
return f"{industry} {amount}亿元"
def _industry_growth_label(row: dict[str, Any]) -> str:
industry = _required_text(row, "行业", "/tender/industry.累计数据.行业")
growth = _format_growth(row.get("金额同比增幅"), f"/tender/industry.累计数据.{industry}.金额同比增幅")
return f"{industry} {growth}"
def _planning_line(row: dict[str, Any], growth_mode: bool) -> str:
industry = _required_text(row, "行业", "/tender/industry.累计数据.行业")
amount = _format_amount_yi(row.get("金额(亿元)"), f"/tender/industry.累计数据.{industry}.金额(亿元)")
count = int(_parse_decimal(row.get("个数"), f"/tender/industry.累计数据.{industry}.个数"))
growth = _format_optional_growth(row.get("金额同比增幅"), f"/tender/industry.累计数据.{industry}.金额同比增幅")
if growth_mode:
return _wrap_followup_prompt(f"{industry}累计招标金额{amount}亿元、招标次数{count}次,金额同比{growth}")
return _wrap_followup_prompt(f"{industry}累计招标金额{amount}亿元、招标次数{count}")
def _industry_line(row: dict[str, Any]) -> str:
industry = _required_text(row, "行业", "/tender/industry.累计数据.行业")
amount = _format_amount_yi(row.get("金额(亿元)"), f"/tender/industry.累计数据.{industry}.金额(亿元)")
count = int(_parse_decimal(row.get("个数"), f"/tender/industry.累计数据.{industry}.个数"))
count_growth = _format_optional_growth(row.get("个数同比增幅"), f"/tender/industry.累计数据.{industry}.个数同比增幅")
amount_growth = _format_optional_growth(row.get("金额同比增幅"), f"/tender/industry.累计数据.{industry}.金额同比增幅")
return _wrap_followup_prompt(
f"{industry}累计招标金额{amount}亿元、招标次数{count}次,个数同比{count_growth}、金额同比{amount_growth}"
)
def _buyer_line(row: dict[str, Any], label: str) -> str:
unit = _required_text(row, "招标单位", f"/tender/major-buyers.{label}.招标单位")
count = int(_parse_decimal(row.get("招标次数"), f"/tender/major-buyers.{label}.{unit}.招标次数"))
amount = _parse_decimal(row.get("招标预算金额(万元)"), f"/tender/major-buyers.{label}.{unit}.招标预算金额(万元)")
return _wrap_followup_prompt(
f"{unit}招标{count}次、预算{_format_decimal(amount)}万元,侧别{label.removesuffix('招标单位')}"
)
def _wrap_followup_prompt(fact_text: str) -> str:
return f"{{{{{fact_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _quarter_parts(label: str) -> tuple[str, str]:
match = re.fullmatch(r"\s*(\d{4})年([1-4])季度\s*", label)
if not match:
raise P1UpdateError(f"P1 更新失败:/tender/time.季度数据.季度={label!r} 格式不正确。")
return f"{match.group(1)}", f"Q{match.group(2)}"
def _quarter_table_rows(response: dict[str, Any]) -> list[list[str]]:
rows = _required_rows(response, "/tender/time", "季度数据")
if len(rows) < 5:
raise P1UpdateError("P1 更新失败:/tender/time.季度数据 少于 5 行。")
selected = rows[-5:]
years: list[str] = []
quarters: list[str] = []
government_amounts: list[str] = []
enterprise_amounts: list[str] = []
for row in selected:
label = _required_text(row, "季度", "/tender/time.季度数据.季度")
year_label, quarter_label = _quarter_parts(label)
years.append(year_label)
quarters.append(quarter_label)
government_amounts.append(
f"{_parse_decimal(row.get('政府侧招采金额(亿元)'), f'/tender/time.季度数据.{label}.政府侧招采金额(亿元)').quantize(Decimal('0.01'))}亿"
)
enterprise_amounts.append(
f"{_parse_decimal(row.get('企业侧招采金额(亿元)'), f'/tender/time.季度数据.{label}.企业侧招采金额(亿元)').quantize(Decimal('0.01'))}亿"
)
year_row = ["时间类型"]
previous_year = ""
for year_label in years:
year_row.append(year_label if year_label != previous_year else "")
previous_year = year_label
return [
year_row,
["", *quarters],
["政府", *government_amounts],
["企业", *enterprise_amounts],
]
def _customer_139_summary_replacements(response: dict[str, Any]) -> dict[str, str]:
monthly = response.get("月度数据")
if not isinstance(monthly, dict):
raise P1UpdateError("P1 更新失败:/tender/customer-139 缺少 月度数据。")
count = int(_parse_decimal(monthly.get("招标次数"), "/tender/customer-139.月度数据.招标次数"))
amount_yi = _parse_decimal(
monthly.get("招标预算金额(亿元)"),
"/tender/customer-139.月度数据.招标预算金额(亿元)",
).quantize(Decimal("0.01"))
return {
"{{规模万元}} 万": f"{amount_yi}亿元",
"{{招标个数}}": str(count),
}
def build_p1_update_model(
overall_response: dict[str, Any],
industry_response: dict[str, Any],
major_buyers_response: dict[str, Any],
class_response: dict[str, Any],
time_response: dict[str, Any],
customer_139_response: dict[str, Any],
district: str,
) -> P1UpdateModel:
cumulative = _required_dict(overall_response, "/tender/overall", "累计数据")
industry_rows = _required_rows(industry_response, "/tender/industry", "累计数据")
amount_top5 = _top_rows_by_amount(industry_rows, 5)
growth_top5 = _top_rows_by_growth(industry_rows, 5)
government_buyers = _top_buyers(major_buyers_response, "政府侧招标单位", 5)
enterprise_buyers = _top_buyers(major_buyers_response, "企业侧招标单位", 5)
government_side = _side_class(class_response, "政府侧累计数据")
enterprise_side = _side_class(class_response, "企业侧累计数据")
replacements = {
"{{某某行政区}}": district,
"{{累计金额}}": _format_amount_yi(cumulative.get("招标预算金额(亿元)"), "/tender/overall.累计数据.招标预算金额(亿元)"),
"{{金额同比}}": _format_growth_symbol(cumulative.get("招标金额同比增幅"), "/tender/overall.累计数据.招标金额同比增幅"),
"{{累计次数}}": str(int(_parse_decimal(cumulative.get("招标次数"), "/tender/overall.累计数据.招标次数"))),
"{{次数同比}}": _format_growth_symbol(cumulative.get("招标次数同比增幅"), "/tender/overall.累计数据.招标次数同比增幅"),
"{{规划1及分析结果,最后修订}}": _planning_line(amount_top5[0], growth_mode=False),
"{{规划2及分析结果,最后修订}}": _planning_line(growth_top5[0], growth_mode=True),
"{{行业1及分析结果}}": _industry_line(amount_top5[0]),
"{{行业2及分析结果}}": _industry_line(amount_top5[1]),
"{{客群1及分析结果}}": _buyer_line(government_buyers[0], "政府侧招标单位"),
"{{客群2及分析结果}}": _buyer_line(enterprise_buyers[0], "企业侧招标单位"),
"{{政府侧招标规模万元}}": _format_amount_wan_from_yi(
government_side.get("招标预算金额(亿元)"),
"/tender/class.政府侧累计数据.招标预算金额(亿元)",
),
"{{政府侧招标金额占比}}": _required_text(
government_side,
"招标金额占比",
"/tender/class.政府侧累计数据.招标金额占比",
),
"{{政府侧招标个数}}": str(
int(_parse_decimal(government_side.get("招标次数"), "/tender/class.政府侧累计数据.招标次数"))
),
"{{政府侧招标次数占比}}": _required_text(
government_side,
"招标次数占比",
"/tender/class.政府侧累计数据.招标次数占比",
),
"{{企业侧招标规模万元}}": _format_amount_wan_from_yi(
enterprise_side.get("招标预算金额(亿元)"),
"/tender/class.企业侧累计数据.招标预算金额(亿元)",
),
"{{企业侧招标金额占比}}": _required_text(
enterprise_side,
"招标金额占比",
"/tender/class.企业侧累计数据.招标金额占比",
),
"{{企业侧招标个数}}": str(
int(_parse_decimal(enterprise_side.get("招标次数"), "/tender/class.企业侧累计数据.招标次数"))
),
"{{企业侧招标次数占比}}": _required_text(
enterprise_side,
"招标次数占比",
"/tender/class.企业侧累计数据.招标次数占比",
),
}
replacements.update(_customer_139_summary_replacements(customer_139_response))
for index, row in enumerate(amount_top5, start=1):
replacements[f"{{{{行业{index}金额}}}}"] = _industry_amount_label(row)
for index, row in enumerate(growth_top5, start=1):
replacements[f"{{{{行业{index}增幅}}}}"] = _industry_growth_label(row)
for index, row in enumerate(government_buyers, start=1):
replacements[f"{{{{政府侧客户 {index}}}}}"] = _required_text(
row,
"招标单位",
f"/tender/major-buyers.政府侧招标单位[{index}].招标单位",
)
for index, row in enumerate(enterprise_buyers, start=1):
replacements[f"{{{{企业侧客户 {index}}}}}"] = _required_text(
row,
"招标单位",
f"/tender/major-buyers.企业侧招标单位[{index}].招标单位",
)
return P1UpdateModel(
replacements=replacements,
quarter_table_rows=_quarter_table_rows(time_response),
sequential_replacements={
"{{环比增幅%}}": [
_format_growth_symbol(
government_side.get("招标预算金额环比增幅"),
"/tender/class.政府侧累计数据.招标预算金额环比增幅",
),
_format_growth_symbol(
government_side.get("招标次数环比增幅"),
"/tender/class.政府侧累计数据.招标次数环比增幅",
),
_format_growth_symbol(
enterprise_side.get("招标预算金额环比增幅"),
"/tender/class.企业侧累计数据.招标预算金额环比增幅",
),
_format_growth_symbol(
enterprise_side.get("招标次数环比增幅"),
"/tender/class.企业侧累计数据.招标次数环比增幅",
),
],
},
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P1UpdateError(f"P1 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P1UpdateError(f"P1 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P1UpdateError(f"P1 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P1UpdateError(f"P1 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P1UpdateError(f"P1 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p1_responses(
api_base: str,
year: int,
month: str,
district: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
district = district.strip()
if not district:
raise P1UpdateError("P1 更新失败:行政区参数 --district 不能为空。")
params = {"year": year, "month": month, "scope": "行政区", "scope_value": district}
return (
fetch_json(api_base, "/tender/overall", params),
fetch_json(api_base, "/tender/industry", params),
fetch_json(api_base, "/tender/major-buyers", params),
fetch_json(api_base, "/tender/class", params),
fetch_json(api_base, "/tender/time", params),
fetch_json(api_base, "/tender/customer-139", params),
)
def _replace_slide_text(
root: ET.Element,
replacements: dict[str, str],
sequential_replacements: dict[str, list[str]],
) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
sequential_counts = {placeholder: 0 for placeholder in sequential_replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
for placeholder, values in sequential_replacements.items():
while placeholder in updated:
index = sequential_counts[placeholder]
if index >= len(values):
raise P1UpdateError(f"P1 更新失败:模板第 1 页占位符 {placeholder} 出现次数多于设计值。")
updated = updated.replace(placeholder, values[index], 1)
sequential_counts[placeholder] += 1
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
sequential_missing = sorted(
placeholder
for placeholder, values in sequential_replacements.items()
if sequential_counts[placeholder] < len(values)
)
if missing or sequential_missing:
raise P1UpdateError(f"P1 更新失败:模板第 1 页缺少占位符:{', '.join(missing + sequential_missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
tx_body = parent.find("./a:txBody", OOXML_NS)
if tx_body is None:
tx_body = ET.SubElement(parent, f"{{{OOXML_NS['a']}}}txBody")
ET.SubElement(tx_body, f"{{{OOXML_NS['a']}}}bodyPr")
ET.SubElement(tx_body, f"{{{OOXML_NS['a']}}}lstStyle")
paragraph = tx_body.find("./a:p", OOXML_NS)
if paragraph is None:
paragraph = ET.SubElement(tx_body, f"{{{OOXML_NS['a']}}}p")
run = ET.SubElement(paragraph, f"{{{OOXML_NS['a']}}}r")
text_node = ET.SubElement(run, f"{{{OOXML_NS['a']}}}t")
text_nodes = [text_node]
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_quarter_table(root: ET.Element, rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P1UpdateError("P1 更新失败:第 1 页未找到季度表。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(rows):
raise P1UpdateError("P1 更新失败:第 1 页季度表行数不足。")
for row_index, row_values in enumerate(rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P1UpdateError("P1 更新失败:第 1 页季度表列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def update_p1_pptx(pptx_path: Path, output_path: Path, model: P1UpdateModel) -> None:
if not pptx_path.exists():
raise P1UpdateError(f"P1 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {"ppt/slides/slide1.xml"}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P1UpdateError(f"P1 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide1.xml"))
_replace_slide_text(slide_root, model.replacements, model.sequential_replacements)
_update_quarter_table(slide_root, model.quarter_table_rows)
updates = {
"ppt/slides/slide1.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p1-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 1 of the administrative district service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument("--district", required=True, help="行政区名称,例如 浦东新区。")
return parser.parse_args()
def main() -> None:
args = parse_args()
responses = fetch_p1_responses(args.api_base, args.year, args.month, args.district)
model = build_p1_update_model(*responses, args.district.strip())
update_p1_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
"""Update slide 2 of the administrative district service monthly PPTX.
本脚本用于单独更新 PPT 第 2 页:累计行业招标分析。
取数逻辑:
- `/tender/industry?year={year}&month={month}&scope=行政区&scope_value={district}`
使用返回值中的 `累计数据`。
更新内容:
- 原生图表:累计行业招标金额与次数分布。
- 表格:各行业累计个数、金额、个数同比增幅、金额同比增幅。
- 文案:根据金额 Top、个数增幅、金额增幅、量价背离等规则生成行业分析。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 2
SLIDE_XML = "ppt/slides/slide2.xml"
DATA_SOURCES = ["/tender/industry"]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
P2_ANALYSIS_PLACEHOLDERS = [
"{{P2行业总览:根据金额Top3、合计占比和整体增长特征生成一句总览}}",
"{{P2行业分析1:按累计金额排序第1行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析2:按累计金额排序第2行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析3:按累计金额排序第3行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析4:按累计金额排序第4行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析5:按累计金额排序第5行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析6:按累计金额排序第6行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析7:按累计金额排序第7行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析8:按累计金额排序第8行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析9:按累计金额排序第9行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析10:按累计金额排序第10行业,介绍行业特点,总结只用一句话说明个数同比、金额同比和特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P2UpdateError(ValueError):
"""Raised when P2 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class IndustryRow:
industry: str
count: int
amount_yi: Decimal
count_growth: Decimal | None
amount_growth: Decimal | None
@dataclass(frozen=True)
class P2UpdateModel:
period_label: str
rows: list[IndustryRow]
table_rows: list[list[str]]
chart_title: str
chart_categories: list[str]
chart_count_values: list[int]
chart_amount_values: list[float]
replacements: dict[str, str]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P2UpdateError(f"P2 更新失败:{field_path} 为空,无法更新第二页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P2UpdateError(f"P2 更新失败:{field_path} 不是可计算数字,无法更新第二页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P2UpdateError(f"P2 更新失败:{field_path}={value!r} 不是数字。") from exc
def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return None
return _parse_decimal(value, field_path)
def _format_growth(value: Decimal | None) -> str:
if value is None:
return "/"
sign = "+" if value > 0 else ""
return f"{sign}{_format_decimal(value)}%"
def _format_amount(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _wrap_followup_prompt(fact_text: str) -> str:
return f"{{{{{fact_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P2UpdateError(f"P2 更新失败:{field_path} 为空,无法更新第二页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P2UpdateError("P2 更新失败:/tender/industry 缺少统计年份或统计月份。")
return f"{year}年1-{month_number}"
def _industry_rows(response: dict[str, Any]) -> list[IndustryRow]:
raw_rows = response.get("累计数据")
if not isinstance(raw_rows, list) or not raw_rows:
raise P2UpdateError("P2 更新失败:/tender/industry 响应缺少 累计数据。")
rows: list[IndustryRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P2UpdateError(f"P2 更新失败:/tender/industry.累计数据[{index}] 不是对象。")
industry = _required_text(raw_row, "行业", f"/tender/industry.累计数据[{index}].行业")
count = int(_parse_decimal(raw_row.get("个数"), f"/tender/industry.累计数据.{industry}.个数"))
rows.append(
IndustryRow(
industry=industry,
count=count,
amount_yi=_parse_decimal(
raw_row.get("金额(亿元)"),
f"/tender/industry.累计数据.{industry}.金额(亿元)",
),
count_growth=_parse_optional_decimal(
raw_row.get("个数同比增幅"),
f"/tender/industry.累计数据.{industry}.个数同比增幅",
),
amount_growth=_parse_optional_decimal(
raw_row.get("金额同比增幅"),
f"/tender/industry.累计数据.{industry}.金额同比增幅",
),
)
)
if len(rows) < 10:
raise P2UpdateError("P2 更新失败:/tender/industry.累计数据 少于 10 行。")
return sorted(rows, key=lambda row: row.amount_yi, reverse=True)[:10]
def _relationship_phrase(row: IndustryRow) -> str:
if row.count_growth is None or row.amount_growth is None:
return "同比暂不可比,需结合低基数或新增项目口径观察"
if row.count_growth >= 0 and row.amount_growth >= 0:
if row.amount_growth > row.count_growth * Decimal("2"):
return "呈量价齐升、大单驱动特征"
return "呈量价齐升、稳步增长特征"
if row.count_growth < 0 <= row.amount_growth:
return "呈量降价升、项目大型化特征"
if row.count_growth >= 0 > row.amount_growth:
return "呈量增价降、单体规模承压特征"
return "呈量价双降、需求回落特征"
def _summary_text(rows: list[IndustryRow]) -> str:
amount_sorted = sorted(rows, key=lambda row: row.amount_yi, reverse=True)
top3 = amount_sorted[:3]
total_amount = sum((row.amount_yi for row in rows), Decimal("0"))
top3_amount = sum((row.amount_yi for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "".join(f"{row.industry}{_format_amount(row.amount_yi)}亿" for row in top3)
return _wrap_followup_prompt(f"累计金额Top3行业为{top_text},合计占比{share.quantize(Decimal('0.1'))}%")
def _analysis_text(index: int, row: IndustryRow) -> str:
return _wrap_followup_prompt(
f"{row.industry}行业:累计{row.count}个/{_format_amount(row.amount_yi)}亿,"
f"个数同比{_format_growth(row.count_growth)},金额同比{_format_growth(row.amount_growth)}"
)
def build_p2_update_model(response: dict[str, Any]) -> P2UpdateModel:
period_label = _period_label(response)
rows = _industry_rows(response)
table_rows = [
[period_label, "个数", "金额(亿元)", "个数增幅", "金额增幅"],
*[
[
row.industry,
str(row.count),
_format_amount(row.amount_yi),
_format_growth(row.count_growth),
_format_growth(row.amount_growth),
]
for row in rows
],
]
replacements = {P2_ANALYSIS_PLACEHOLDERS[0]: _summary_text(rows)}
for index, row in enumerate(rows, start=1):
replacements[P2_ANALYSIS_PLACEHOLDERS[index]] = _analysis_text(index, row)
return P2UpdateModel(
period_label=period_label,
rows=rows,
table_rows=table_rows,
chart_title=f"累计行业招标金额与次数分布({period_label}",
chart_categories=[row.industry for row in rows],
chart_count_values=[row.count for row in rows],
chart_amount_values=[float(row.amount_yi) for row in rows],
replacements=replacements,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P2UpdateError(f"P2 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P2UpdateError(f"P2 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P2UpdateError(f"P2 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P2UpdateError(f"P2 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P2UpdateError(f"P2 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p2_response(api_base: str, year: int, month: str, district: str) -> dict[str, Any]:
district = district.strip()
if not district:
raise P2UpdateError("P2 更新失败:行政区参数 --district 不能为空。")
return fetch_json(
api_base,
"/tender/industry",
{"year": year, "month": month, "scope": "行政区", "scope_value": district},
)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced: set[str] = set()
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
updated = updated.replace(placeholder, value)
replaced.add(placeholder)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(set(replacements) - replaced)
if missing:
raise P2UpdateError(f"P2 更新失败:模板第 2 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P2UpdateError("P2 更新失败:表格单元格缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P2UpdateError("P2 更新失败:第 2 页未找到表格。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P2UpdateError("P2 更新失败:第 2 页表格行数不足。")
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P2UpdateError("P2 更新失败:第 2 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P2UpdateError("P2 更新失败:chart1.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _update_chart1_xml(chart_xml: bytes, model: P2UpdateModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.chart_title)
series_nodes = root.findall(".//c:ser", OOXML_NS)
if len(series_nodes) < 2:
raise P2UpdateError("P2 更新失败:chart1.xml 图表系列少于 2 个。")
series_values = [
[str(value) for value in model.chart_count_values],
[_format_decimal(Decimal(str(value))) for value in model.chart_amount_values],
]
series_names = ["个数", "金额(亿元)"]
for index, series in enumerate(series_nodes[:2]):
name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = series_names[index]
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P2UpdateError("P2 更新失败:chart1.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.chart_categories)
_set_cache_points(value_cache, series_values[index])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook1(workbook_bytes: bytes, model: P2UpdateModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
sheet["A1"] = model.period_label
sheet["B1"] = "个数"
sheet["C1"] = "金额(亿元)"
sheet["D1"] = "金额(万元)"
for row_index, row in enumerate(model.rows, start=2):
sheet.cell(row=row_index, column=1).value = row.industry
sheet.cell(row=row_index, column=2).value = row.count
sheet.cell(row=row_index, column=3).value = float(row.amount_yi)
sheet.cell(row=row_index, column=4).value = float(row.amount_yi * Decimal("10000"))
for row_index in range(len(model.rows) + 2, sheet.max_row + 1):
for column_index in range(1, 5):
sheet.cell(row=row_index, column=column_index).value = None
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p2_pptx(pptx_path: Path, output_path: Path, model: P2UpdateModel) -> None:
if not pptx_path.exists():
raise P2UpdateError(f"P2 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {
"ppt/slides/slide2.xml",
"ppt/charts/chart1.xml",
"ppt/embeddings/Workbook1.xlsx",
}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P2UpdateError(f"P2 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide2.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_table(slide_root, model.table_rows)
updates = {
"ppt/slides/slide2.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
"ppt/charts/chart1.xml": _update_chart1_xml(source.read("ppt/charts/chart1.xml"), model),
"ppt/embeddings/Workbook1.xlsx": _update_workbook1(
source.read("ppt/embeddings/Workbook1.xlsx"),
model,
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p2-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 2 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument("--district", required=True, help="行政区名称,例如 浦东新区。")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p2_response(args.api_base, args.year, args.month, args.district)
model = build_p2_update_model(response)
update_p2_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,663 @@
#!/usr/bin/env python3
"""Update slide 3 of the administrative district service monthly PPTX.
本脚本用于单独更新 PPT 第 3 页:月度招标与近三月政府侧/企业侧趋势。
取数逻辑:
- `/tender/overall?year={year}&month={month}&scope=行政区&scope_value={district}`
使用 `月度数据` 更新单月总体指标和文案。
- `/tender/industry?year={year}&month={month}&scope=行政区&scope_value={district}`
使用 `月度数据` 更新月度行业 Top3 表格和分析文案。
- `/tender/class-trend?year={year}&month={month}&scope=行政区&scope_value={district}`
使用 `月度数据` 更新近三月企业侧、政府侧招标次数/金额图表和趋势占位符。
"""
from __future__ import annotations
import argparse
import json
import re
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 3
SLIDE_XML = "ppt/slides/slide3.xml"
DATA_SOURCES = ["/tender/overall", "/tender/industry", "/tender/class-trend"]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P3_MONTH_PLACEHOLDER = "{{P3统计月份:如2026年4月}}"
P3_ANALYSIS_PLACEHOLDERS = [
"{{P3整体月度分析:说明单月次数、金额、次数环比、金额环比和量价特征}}",
"{{P3行业总览:按月度金额Top3说明金额、合计占比和行业集中度}}",
"{{P3行业分析1:按月度金额排序第1行业,说明个数环比、金额环比和特征}}",
"{{P3行业分析2:按月度金额排序第2行业,说明个数环比、金额环比和特征}}",
"{{P3行业分析3:按月度金额排序第3行业,说明个数环比、金额环比和特征}}",
]
P3_TREND_SUMMARY_PLACEHOLDER = "{{P3一句话扼要说明近三月政府侧招标趋势情况}}"
P3_TREND_DETAIL_PLACEHOLDER = "{{P3简要介绍三个月的变化情况}}"
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P3UpdateError(ValueError):
"""Raised when P3 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class IndustryMonthlyRow:
industry: str
count: int
amount_yi: Decimal
count_growth: Decimal | None
amount_growth: Decimal | None
@dataclass(frozen=True)
class TrendRow:
month_label: str
government_count: int
government_amount_yi: Decimal
enterprise_count: int
enterprise_amount_yi: Decimal
@dataclass(frozen=True)
class ChartModel:
title: str
series_name: str
categories: list[str]
values: list[int | float]
@dataclass(frozen=True)
class P3UpdateModel:
replacements: dict[str, str]
sequential_replacements: dict[str, list[str]]
industry_table_rows: list[list[str]]
overall_table_rows: list[list[str]]
charts: dict[str, ChartModel]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P3UpdateError(f"P3 更新失败:{field_path} 为空,无法更新第三页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P3UpdateError(f"P3 更新失败:{field_path} 不是可计算数字,无法更新第三页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P3UpdateError(f"P3 更新失败:{field_path}={value!r} 不是数字。") from exc
def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return None
return _parse_decimal(value, field_path)
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P3UpdateError(f"P3 更新失败:{field_path} 为空,无法更新第三页。")
return text
def _format_amount(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _format_growth(value: Decimal | None) -> str:
if value is None:
return "/"
sign = "+" if value > 0 else ""
return f"{sign}{_format_decimal(value)}%"
def _wrap_followup_prompt(fact_text: str) -> str:
return f"{{{{{fact_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _month_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P3UpdateError("P3 更新失败:/tender/overall 缺少统计年份或统计月份。")
return f"{year}{month_number}"
def _monthly_overall(response: dict[str, Any]) -> dict[str, Any]:
value = response.get("月度数据")
if not isinstance(value, dict):
raise P3UpdateError("P3 更新失败:/tender/overall 响应缺少 月度数据。")
return value
def _industry_rows(response: dict[str, Any]) -> list[IndustryMonthlyRow]:
raw_rows = response.get("月度数据")
if not isinstance(raw_rows, list) or not raw_rows:
raise P3UpdateError("P3 更新失败:/tender/industry 响应缺少 月度数据。")
sortable_rows: list[tuple[Decimal, dict[str, Any], str, int]] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P3UpdateError(f"P3 更新失败:/tender/industry.月度数据[{index}] 不是对象。")
industry = _required_text(raw_row, "行业", f"/tender/industry.月度数据[{index}].行业")
amount = _parse_decimal(
raw_row.get("金额(亿元)"),
f"/tender/industry.月度数据.{industry}.金额(亿元)",
)
count = int(_parse_decimal(raw_row.get("个数"), f"/tender/industry.月度数据.{industry}.个数"))
sortable_rows.append((amount, raw_row, industry, count))
if len(sortable_rows) < 3:
raise P3UpdateError("P3 更新失败:/tender/industry.月度数据 少于 3 行。")
rows: list[IndustryMonthlyRow] = []
for amount, raw_row, industry, count in sorted(sortable_rows, key=lambda item: item[0], reverse=True)[:3]:
rows.append(
IndustryMonthlyRow(
industry=industry,
count=count,
amount_yi=amount,
count_growth=_parse_optional_decimal(
raw_row.get("个数环比增幅"),
f"/tender/industry.月度数据.{industry}.个数环比增幅",
),
amount_growth=_parse_optional_decimal(
raw_row.get("金额环比增幅"),
f"/tender/industry.月度数据.{industry}.金额环比增幅",
),
)
)
return rows
def _overall_analysis(month_label: str, monthly: dict[str, Any]) -> str:
count = int(_parse_decimal(monthly.get("招标次数"), "/tender/overall.月度数据.招标次数"))
amount = _parse_decimal(monthly.get("招标预算金额(亿元)"), "/tender/overall.月度数据.招标预算金额(亿元)")
count_growth = _parse_optional_decimal(monthly.get("招标次数环比增幅"), "/tender/overall.月度数据.招标次数环比增幅")
amount_growth = _parse_optional_decimal(monthly.get("招标金额环比增幅"), "/tender/overall.月度数据.招标金额环比增幅")
return _wrap_followup_prompt(
f"{month_label}:公开市场招标{count}次/{_format_amount(amount)}亿,"
f"次数环比{_format_growth(count_growth)},金额环比{_format_growth(amount_growth)}"
)
def _industry_summary(rows: list[IndustryMonthlyRow]) -> str:
top3 = rows[:3]
total_amount = sum((row.amount_yi for row in rows), Decimal("0"))
top3_amount = sum((row.amount_yi for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "".join(f"{row.industry}{_format_amount(row.amount_yi)}亿" for row in top3)
return _wrap_followup_prompt(f"月度金额Top3行业为{top_text},合计占比{share.quantize(Decimal('0.1'))}%")
def _industry_analysis(row: IndustryMonthlyRow) -> str:
return _wrap_followup_prompt(
f"{row.industry}行业:{row.count}个/{_format_amount(row.amount_yi)}亿,"
f"个数环比{_format_growth(row.count_growth)},金额环比{_format_growth(row.amount_growth)}"
)
def _month_sort_key(label: str) -> tuple[int, int]:
match = re.fullmatch(r"\s*(\d{4})年(\d{1,2})月\s*", label)
if not match:
raise P3UpdateError(f"P3 更新失败:/tender/class-trend.月度数据.月份={label!r} 格式不正确。")
return int(match.group(1)), int(match.group(2))
def _trend_rows(response: dict[str, Any]) -> list[TrendRow]:
raw_rows = response.get("月度数据")
if not isinstance(raw_rows, list) or len(raw_rows) < 3:
raise P3UpdateError("P3 更新失败:/tender/class-trend.月度数据 少于 3 行。")
rows: list[TrendRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P3UpdateError(f"P3 更新失败:/tender/class-trend.月度数据[{index}] 不是对象。")
month_label = _required_text(raw_row, "月份", f"/tender/class-trend.月度数据[{index}].月份")
rows.append(
TrendRow(
month_label=month_label,
government_count=int(
_parse_decimal(raw_row.get("政府侧招标次数"), f"/tender/class-trend.月度数据.{month_label}.政府侧招标次数")
),
government_amount_yi=_parse_decimal(
raw_row.get("政府侧招标预算金额(亿元)"),
f"/tender/class-trend.月度数据.{month_label}.政府侧招标预算金额(亿元)",
),
enterprise_count=int(
_parse_decimal(raw_row.get("企业侧招标次数"), f"/tender/class-trend.月度数据.{month_label}.企业侧招标次数")
),
enterprise_amount_yi=_parse_decimal(
raw_row.get("企业侧招标预算金额(亿元)"),
f"/tender/class-trend.月度数据.{month_label}.企业侧招标预算金额(亿元)",
),
)
)
return sorted(rows, key=lambda row: _month_sort_key(row.month_label))[-3:]
def _trend_values(rows: list[TrendRow], side: str) -> tuple[list[int], list[Decimal]]:
if side == "政府侧":
return [row.government_count for row in rows], [row.government_amount_yi for row in rows]
if side == "企业侧":
return [row.enterprise_count for row in rows], [row.enterprise_amount_yi for row in rows]
raise P3UpdateError(f"P3 更新失败:未知趋势侧别 {side!r}")
def _trend_summary(rows: list[TrendRow], side: str) -> str:
counts, amounts = _trend_values(rows, side)
return _wrap_followup_prompt(
f"近三月{side}{counts[0]}->{counts[-1]}次,"
f"{_format_amount(amounts[0])}->{_format_amount(amounts[-1])}亿"
)
def _trend_detail(rows: list[TrendRow], side: str) -> str:
counts, amounts = _trend_values(rows, side)
parts = [
f"{row.month_label}{count}次/{_format_amount(amount)}亿"
for row, count, amount in zip(rows, counts, amounts, strict=True)
]
return _wrap_followup_prompt("".join(parts))
def _build_charts(rows: list[TrendRow]) -> dict[str, ChartModel]:
months = [row.month_label for row in rows]
return {
"chart2": ChartModel(
title="近三月企业侧招标次数",
series_name="个数",
categories=months,
values=[row.enterprise_count for row in rows],
),
"chart3": ChartModel(
title="近三月企业侧招标金额(万元)",
series_name="金额(万元)",
categories=months,
values=[float(row.enterprise_amount_yi * Decimal("10000")) for row in rows],
),
"chart4": ChartModel(
title="近三月政府侧招标次数",
series_name="个数",
categories=months,
values=[row.government_count for row in rows],
),
"chart5": ChartModel(
title="近三月政府侧招标金额(万元)",
series_name="金额(万元)",
categories=months,
values=[float(row.government_amount_yi * Decimal("10000")) for row in rows],
),
}
def build_p3_update_model(
overall_response: dict[str, Any],
industry_response: dict[str, Any],
class_trend_response: dict[str, Any],
) -> P3UpdateModel:
month_label = _month_label(overall_response)
monthly = _monthly_overall(overall_response)
industry_rows = _industry_rows(industry_response)
trend_rows = _trend_rows(class_trend_response)
overall_count = int(_parse_decimal(monthly.get("招标次数"), "/tender/overall.月度数据.招标次数"))
overall_amount = _parse_decimal(monthly.get("招标预算金额(亿元)"), "/tender/overall.月度数据.招标预算金额(亿元)")
overall_count_growth = _parse_optional_decimal(monthly.get("招标次数环比增幅"), "/tender/overall.月度数据.招标次数环比增幅")
overall_amount_growth = _parse_optional_decimal(monthly.get("招标金额环比增幅"), "/tender/overall.月度数据.招标金额环比增幅")
replacements = {
P3_MONTH_PLACEHOLDER: month_label,
P3_ANALYSIS_PLACEHOLDERS[0]: _overall_analysis(month_label, monthly),
P3_ANALYSIS_PLACEHOLDERS[1]: _industry_summary(industry_rows),
}
for index, row in enumerate(industry_rows, start=1):
replacements[P3_ANALYSIS_PLACEHOLDERS[index + 1]] = _industry_analysis(row)
return P3UpdateModel(
replacements=replacements,
sequential_replacements={
P3_TREND_SUMMARY_PLACEHOLDER: [
_trend_summary(trend_rows, "政府侧"),
_trend_summary(trend_rows, "企业侧"),
],
P3_TREND_DETAIL_PLACEHOLDER: [
_trend_detail(trend_rows, "政府侧"),
_trend_detail(trend_rows, "企业侧"),
],
},
industry_table_rows=[
["行业", "个数", "金额(亿元)", "个数环比", "金额环比"],
*[
[
row.industry,
str(row.count),
_format_amount(row.amount_yi),
_format_growth(row.count_growth),
_format_growth(row.amount_growth),
]
for row in industry_rows
],
],
overall_table_rows=[
["招标次数", "招标预算金额(亿元)", "招标次数环比(%", "招标金额环比(%"],
[
str(overall_count),
_format_amount(overall_amount),
_format_growth(overall_count_growth),
_format_growth(overall_amount_growth),
],
],
charts=_build_charts(trend_rows),
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P3UpdateError(f"P3 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P3UpdateError(f"P3 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P3UpdateError(f"P3 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P3UpdateError(f"P3 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P3UpdateError(f"P3 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p3_responses(
api_base: str,
year: int,
month: str,
district: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
district = district.strip()
if not district:
raise P3UpdateError("P3 更新失败:行政区参数 --district 不能为空。")
params = {"year": year, "month": month, "scope": "行政区", "scope_value": district}
return (
fetch_json(api_base, "/tender/overall", params),
fetch_json(api_base, "/tender/industry", params),
fetch_json(api_base, "/tender/class-trend", params),
)
def _replace_slide_text(
root: ET.Element,
replacements: dict[str, str],
sequential_replacements: dict[str, list[str]],
) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
sequential_counts = {placeholder: 0 for placeholder in sequential_replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
for placeholder, values in sequential_replacements.items():
while placeholder in updated:
index = sequential_counts[placeholder]
if index >= len(values):
raise P3UpdateError(f"P3 更新失败:模板第 3 页占位符 {placeholder} 出现次数多于设计值。")
updated = updated.replace(placeholder, values[index], 1)
sequential_counts[placeholder] += 1
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
required_counts = {placeholder: 1 for placeholder in replacements}
required_counts[P3_MONTH_PLACEHOLDER] = 2
missing = sorted(
placeholder
for placeholder, required_count in required_counts.items()
if replaced_counts[placeholder] < required_count
)
sequential_missing = sorted(
placeholder
for placeholder, values in sequential_replacements.items()
if sequential_counts[placeholder] < len(values)
)
if missing or sequential_missing:
raise P3UpdateError(f"P3 更新失败:模板第 3 页缺少占位符:{', '.join(missing + sequential_missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
tx_body = parent.find("./a:txBody", OOXML_NS)
if tx_body is None:
tx_body = ET.SubElement(parent, _qn("a", "txBody"))
ET.SubElement(tx_body, _qn("a", "bodyPr"))
ET.SubElement(tx_body, _qn("a", "lstStyle"))
paragraph = tx_body.find("./a:p", OOXML_NS)
if paragraph is None:
paragraph = ET.SubElement(tx_body, _qn("a", "p"))
run = ET.SubElement(paragraph, _qn("a", "r"))
text_node = ET.SubElement(run, _qn("a", "t"))
text_nodes = [text_node]
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_table(table: ET.Element, rows: list[list[str]], label: str) -> None:
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(rows):
raise P3UpdateError(f"P3 更新失败:第 3 页{label}表格行数不足。")
for extra_row in xml_rows[len(rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P3UpdateError(f"P3 更新失败:第 3 页{label}表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _table_frames_by_y(root: ET.Element) -> tuple[ET.Element, ET.Element]:
frames: list[tuple[int, ET.Element]] = []
for graphic_frame in root.findall(".//p:graphicFrame", OOXML_NS):
table = graphic_frame.find(".//a:tbl", OOXML_NS)
if table is None:
continue
off = graphic_frame.find("./p:xfrm/a:off", OOXML_NS)
y = int(off.attrib.get("y", "0")) if off is not None else 0
frames.append((y, table))
if len(frames) != 2:
raise P3UpdateError("P3 更新失败:第 3 页应包含 2 张表。")
overall_table, industry_table = [table for _, table in sorted(frames, key=lambda item: item[0])]
return overall_table, industry_table
def _update_slide_tables(root: ET.Element, model: P3UpdateModel) -> None:
overall_table, industry_table = _table_frames_by_y(root)
_update_table(overall_table, model.overall_table_rows, "月度整体")
_update_table(industry_table, model.industry_table_rows, "月度行业")
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P3UpdateError(f"P3 更新失败:{chart_name}.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _chart_value_text(value: int | float) -> str:
return _format_decimal(Decimal(str(value)))
def _update_chart_xml(chart_xml: bytes, chart_name: str, model: ChartModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.title, chart_name)
series_nodes = root.findall(".//c:ser", OOXML_NS)
if not series_nodes:
raise P3UpdateError(f"P3 更新失败:{chart_name}.xml 缺少图表系列。")
series = series_nodes[0]
name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = model.series_name
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P3UpdateError(f"P3 更新失败:{chart_name}.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.categories)
_set_cache_points(value_cache, [_chart_value_text(value) for value in model.values])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook(workbook_bytes: bytes, chart_model: ChartModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
clear_columns = max(sheet.max_column, 2)
for row_index in range(1, clear_rows + 1):
for column_index in range(1, clear_columns + 1):
sheet.cell(row=row_index, column=column_index).value = None
sheet["A1"] = "月份"
sheet["B1"] = chart_model.series_name
for row_index, (category, value) in enumerate(zip(chart_model.categories, chart_model.values, strict=True), start=2):
sheet.cell(row=row_index, column=1).value = category
sheet.cell(row=row_index, column=2).value = value
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p3_pptx(pptx_path: Path, output_path: Path, model: P3UpdateModel) -> None:
if not pptx_path.exists():
raise P3UpdateError(f"P3 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
chart_parts = {f"ppt/charts/chart{index}.xml" for index in range(2, 6)}
workbook_parts = {f"ppt/embeddings/Workbook{index}.xlsx" for index in range(2, 6)}
required_parts = {"ppt/slides/slide3.xml", *chart_parts, *workbook_parts}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P3UpdateError(f"P3 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide3.xml"))
_replace_slide_text(slide_root, model.replacements, model.sequential_replacements)
_update_slide_tables(slide_root, model)
updates: dict[str, bytes] = {
"ppt/slides/slide3.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
for index in range(2, 6):
chart_key = f"chart{index}"
chart_part = f"ppt/charts/{chart_key}.xml"
workbook_part = f"ppt/embeddings/Workbook{index}.xlsx"
updates[chart_part] = _update_chart_xml(source.read(chart_part), chart_key, model.charts[chart_key])
updates[workbook_part] = _update_workbook(source.read(workbook_part), model.charts[chart_key])
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p3-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 3 of the administrative district service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument("--district", required=True, help="行政区名称,例如 浦东新区。")
return parser.parse_args()
def main() -> None:
args = parse_args()
responses = fetch_p3_responses(args.api_base, args.year, args.month, args.district)
model = build_p3_update_model(*responses)
update_p3_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""Update slide 4 of the administrative district service monthly PPTX.
本脚本用于单独更新 PPT 第 4 页:政府侧、企业侧 Top5 客户与 139 客户重点项目。
取数逻辑:
- `/tender/major-buyers?year={year}&month={month}&scope=行政区&scope_value={district}`
使用 `政府侧招标单位` 和 `企业侧招标单位` 两组数据。
- `/tender/customer-139?year={year}&month={month}&scope=行政区&scope_value={district}`
使用 `月度数据` 更新 139 客户当月招标总览提示。
- `/tender/customer-139/key-projects?year={year}&month={month}&scope=行政区&scope_value={district}`
使用 `重点项目` 更新月度 139 客户招标重点项目表。
更新内容:
- 标题中的统计周期,并将模板标题 Top10 改为 Top5。
- 两张客户表:政府侧有效 Top5 客户、企业侧有效 Top5 客户。
- 139 客户月度总览与项目共性后置分析占位符。
- 一张重点项目表:月度 139 客户招标重点项目 Top5。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
PAGE_NUMBER = 4
SLIDE_XML = "ppt/slides/slide4.xml"
SLIDE_RELS_XML = "ppt/slides/_rels/slide4.xml.rels"
NOTES_SLIDE_XML = "ppt/notesSlides/notesSlide4.xml"
NOTES_SLIDE_RELS_XML = "ppt/notesSlides/_rels/notesSlide4.xml.rels"
CONTENT_TYPES_XML = "[Content_Types].xml"
DATA_SOURCES = [
"/tender/major-buyers",
"/tender/customer-139",
"/tender/customer-139/key-projects",
]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
NOTES_SLIDE_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
)
NOTES_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
)
SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
NOTES_SLIDE_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"
)
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P4_PERIOD_PLACEHOLDER = "{{P4统计周期:如2026年1-4月}}"
P4_MONTHLY_PERIOD_PLACEHOLDER = "{{P4统计周期:如2026年4月}}"
P4_139_SUMMARY_PLACEHOLDER = "{{139客户月度招标总览:简要描述139客户招标次数、招标预算金额}}"
P4_PROJECT_SUMMARY_PLACEHOLDER = "{{根据项目名称简要概括重点项目共性内容}}"
TOP10_TITLE_TEXT = "Top10客户"
TOP5_TITLE_TEXT = "Top5客户"
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
MEANINGLESS_CUSTOMER_KEYWORDS = ("", "未知", "未披露", "匿名", "不详", "保密", "无名")
TOP_CUSTOMER_COUNT = 5
TOP_PROJECT_COUNT = 5
TOP_PROJECT_NOTE_COUNT = 10
class P4UpdateError(ValueError):
"""Raised when P4 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class BuyerRow:
name: str
count: int
amount_wan: Decimal
@dataclass(frozen=True)
class KeyProjectRow:
buyer: str
project_name: str
amount_wan: Decimal
@dataclass(frozen=True)
class P4UpdateModel:
cumulative_period_label: str
monthly_period_label: str
government_rows: list[BuyerRow]
enterprise_rows: list[BuyerRow]
key_project_rows: list[KeyProjectRow]
notes_project_rows: list[KeyProjectRow]
notes_text: str
government_table_rows: list[list[str]]
enterprise_table_rows: list[list[str]]
key_project_table_rows: list[list[str]]
replacements: dict[str, str]
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P4UpdateError(f"P4 更新失败:{field_path} 为空,无法更新第四页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P4UpdateError(f"P4 更新失败:{field_path} 不是可计算数字,无法更新第四页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P4UpdateError(f"P4 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P4UpdateError(f"P4 更新失败:{field_path} 为空,无法更新第四页。")
return text
def _format_amount_wan(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _format_amount_yi(value: Any, field_path: str) -> str:
return f"{_parse_decimal(value, field_path).quantize(Decimal('0.01'))}"
def _period_labels(response: dict[str, Any], endpoint: str) -> tuple[str, str]:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P4UpdateError(f"P4 更新失败:{endpoint} 缺少统计年份或统计月份。")
return f"{year}年1-{month_number}", f"{year}{month_number}"
def _validate_same_period(
major_buyers_response: dict[str, Any],
customer_139_response: dict[str, Any],
key_projects_response: dict[str, Any],
) -> tuple[str, str]:
periods = {
"/tender/major-buyers": _period_labels(major_buyers_response, "/tender/major-buyers"),
"/tender/customer-139": _period_labels(customer_139_response, "/tender/customer-139"),
"/tender/customer-139/key-projects": _period_labels(
key_projects_response,
"/tender/customer-139/key-projects",
),
}
unique_periods = set(periods.values())
if len(unique_periods) != 1:
detail = "".join(
f"{endpoint}={cumulative}/{monthly}"
for endpoint, (cumulative, monthly) in periods.items()
)
raise P4UpdateError(f"P4 更新失败:三个接口统计周期不一致:{detail}")
return periods["/tender/major-buyers"]
def _is_meaningful_customer_name(value: Any) -> bool:
name = "" if value is None else str(value).strip()
if not name:
return False
return not any(keyword in name for keyword in MEANINGLESS_CUSTOMER_KEYWORDS)
def _buyer_rows(response: dict[str, Any], section: str) -> list[BuyerRow]:
raw_rows = response.get(section)
if not isinstance(raw_rows, list) or not raw_rows:
raise P4UpdateError(f"P4 更新失败:/tender/major-buyers.{section} 缺少数据。")
rows: list[BuyerRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P4UpdateError(f"P4 更新失败:/tender/major-buyers.{section}[{index}] 不是对象。")
name = _required_text(
raw_row,
"招标单位",
f"/tender/major-buyers.{section}[{index}].招标单位",
)
if not _is_meaningful_customer_name(name):
continue
rows.append(
BuyerRow(
name=name,
count=int(_parse_decimal(raw_row.get("招标次数"), f"/tender/major-buyers.{section}.{name}.招标次数")),
amount_wan=_parse_decimal(
raw_row.get("招标预算金额(万元)"),
f"/tender/major-buyers.{section}.{name}.招标预算金额(万元)",
),
)
)
if len(rows) < TOP_CUSTOMER_COUNT:
raise P4UpdateError(
f"P4 更新失败:/tender/major-buyers.{section} 过滤无意义客户后少于 {TOP_CUSTOMER_COUNT} 行。"
)
return sorted(rows, key=lambda row: (-row.amount_wan, -row.count, row.name))[:TOP_CUSTOMER_COUNT]
def _table_rows(rows: list[BuyerRow]) -> list[list[str]]:
return [
["招标单位", "招标次数", "招标预算金额(万元)"],
*[[row.name, str(row.count), _format_amount_wan(row.amount_wan)] for row in rows],
]
def _key_project_rows(response: dict[str, Any]) -> list[KeyProjectRow]:
raw_rows = response.get("重点项目")
if not isinstance(raw_rows, list) or not raw_rows:
raise P4UpdateError("P4 更新失败:/tender/customer-139/key-projects.重点项目 缺少数据。")
rows: list[KeyProjectRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P4UpdateError(
f"P4 更新失败:/tender/customer-139/key-projects.重点项目[{index}] 不是对象。"
)
project_name = _required_text(
raw_row,
"项目名称",
f"/tender/customer-139/key-projects.重点项目[{index}].项目名称",
)
rows.append(
KeyProjectRow(
buyer=_required_text(
raw_row,
"招标单位",
f"/tender/customer-139/key-projects.重点项目[{index}].招标单位",
),
project_name=project_name,
amount_wan=_parse_decimal(
raw_row.get("招标预算金额(万元)"),
f"/tender/customer-139/key-projects.重点项目.{project_name}.招标预算金额(万元)",
),
)
)
return sorted(rows, key=lambda row: (-row.amount_wan, row.project_name, row.buyer))
def _key_project_table_rows(rows: list[KeyProjectRow]) -> list[list[str]]:
return [
["月度139客户招标重点项目", "", ""],
["招标单位", "项目名称", "招标预算金额(万元)"],
*[[row.buyer, row.project_name, _format_amount_wan(row.amount_wan)] for row in rows],
]
def _wrap_followup_prompt(data_text: str, suffix: str = FOLLOWUP_PROMPT_SUFFIX) -> str:
return f"{{{{{data_text}{suffix}}}}}"
def _customer_139_summary_text(response: dict[str, Any], monthly_period_label: str) -> str:
monthly = response.get("月度数据")
if not isinstance(monthly, dict):
raise P4UpdateError("P4 更新失败:/tender/customer-139 缺少 月度数据。")
count = int(_parse_decimal(monthly.get("招标次数"), "/tender/customer-139.月度数据.招标次数"))
amount_yi = _format_amount_yi(
monthly.get("招标预算金额(亿元)"),
"/tender/customer-139.月度数据.招标预算金额(亿元)",
)
return _wrap_followup_prompt(f"{monthly_period_label}139客户{count}次/{amount_yi}亿")
def _project_summary_text(rows: list[KeyProjectRow]) -> str:
highest_amount = max((row.amount_wan for row in rows), default=Decimal("0"))
return _wrap_followup_prompt(
f"139重点项目Top{len(rows)}见右表,最高{_format_amount_wan(highest_amount)}万元",
"请基于项目名称概括重点项目共性内容",
)
def _key_project_notes_text(rows: list[KeyProjectRow], monthly_period_label: str) -> str:
note_rows = rows[:TOP_PROJECT_NOTE_COUNT]
lines = [f"139客户招标及重点项目 Top{len(note_rows)}{monthly_period_label}"]
for index, row in enumerate(note_rows, start=1):
lines.append(
f"{index}. {row.project_name}{row.buyer}{_format_amount_wan(row.amount_wan)}万元"
)
return "\n".join(lines)
def build_p4_update_model(
major_buyers_response: dict[str, Any],
customer_139_response: dict[str, Any],
key_projects_response: dict[str, Any],
) -> P4UpdateModel:
cumulative_period_label, monthly_period_label = _validate_same_period(
major_buyers_response,
customer_139_response,
key_projects_response,
)
government_rows = _buyer_rows(major_buyers_response, "政府侧招标单位")
enterprise_rows = _buyer_rows(major_buyers_response, "企业侧招标单位")
all_key_project_rows = _key_project_rows(key_projects_response)
key_project_rows = all_key_project_rows[:TOP_PROJECT_COUNT]
notes_project_rows = all_key_project_rows[:TOP_PROJECT_NOTE_COUNT]
return P4UpdateModel(
cumulative_period_label=cumulative_period_label,
monthly_period_label=monthly_period_label,
government_rows=government_rows,
enterprise_rows=enterprise_rows,
key_project_rows=key_project_rows,
notes_project_rows=notes_project_rows,
notes_text=_key_project_notes_text(notes_project_rows, monthly_period_label),
government_table_rows=_table_rows(government_rows),
enterprise_table_rows=_table_rows(enterprise_rows),
key_project_table_rows=_key_project_table_rows(key_project_rows),
replacements={
P4_PERIOD_PLACEHOLDER: cumulative_period_label,
P4_MONTHLY_PERIOD_PLACEHOLDER: monthly_period_label,
P4_139_SUMMARY_PLACEHOLDER: _customer_139_summary_text(
customer_139_response,
monthly_period_label,
),
P4_PROJECT_SUMMARY_PLACEHOLDER: _project_summary_text(key_project_rows),
TOP10_TITLE_TEXT: TOP5_TITLE_TEXT,
},
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P4UpdateError(f"P4 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P4UpdateError(f"P4 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P4UpdateError(f"P4 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P4UpdateError(f"P4 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P4UpdateError(f"P4 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p4_responses(
api_base: str,
year: int,
month: str,
district: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
district = district.strip()
if not district:
raise P4UpdateError("P4 更新失败:行政区参数 --district 不能为空。")
params = {"year": year, "month": month, "scope": "行政区", "scope_value": district}
return (
fetch_json(api_base, "/tender/major-buyers", params),
fetch_json(api_base, "/tender/customer-139", params),
fetch_json(api_base, "/tender/customer-139/key-projects", params),
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
required_counts = {
P4_PERIOD_PLACEHOLDER: 2,
P4_MONTHLY_PERIOD_PLACEHOLDER: 1,
P4_139_SUMMARY_PLACEHOLDER: 1,
P4_PROJECT_SUMMARY_PLACEHOLDER: 1,
TOP10_TITLE_TEXT: 2,
}
missing = sorted(
placeholder
for placeholder, required_count in required_counts.items()
if replaced_counts.get(placeholder, 0) < required_count
)
if missing:
raise P4UpdateError(f"P4 更新失败:模板第 4 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
tx_body = parent.find("./a:txBody", OOXML_NS)
if tx_body is None:
tx_body = ET.SubElement(parent, _qn("a", "txBody"))
ET.SubElement(tx_body, _qn("a", "bodyPr"))
ET.SubElement(tx_body, _qn("a", "lstStyle"))
paragraph = tx_body.find("./a:p", OOXML_NS)
if paragraph is None:
paragraph = ET.SubElement(tx_body, _qn("a", "p"))
run = ET.SubElement(paragraph, _qn("a", "r"))
text_node = ET.SubElement(run, _qn("a", "t"))
text_nodes = [text_node]
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_table(table: ET.Element, table_rows: list[list[str]], label: str) -> None:
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P4UpdateError(f"P4 更新失败:第 4 页{label}表格行数不足。")
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P4UpdateError(f"P4 更新失败:第 4 页{label}表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _table_frames_by_position(root: ET.Element) -> tuple[ET.Element, ET.Element, ET.Element]:
frames: list[tuple[int, int, ET.Element]] = []
for graphic_frame in root.findall(".//p:graphicFrame", OOXML_NS):
table = graphic_frame.find(".//a:tbl", OOXML_NS)
if table is None:
continue
off = graphic_frame.find("./p:xfrm/a:off", OOXML_NS)
x = int(off.attrib.get("x", "0")) if off is not None else 0
y = int(off.attrib.get("y", "0")) if off is not None else 0
frames.append((y, x, table))
if len(frames) != 3:
raise P4UpdateError("P4 更新失败:第 4 页应包含 3 张表。")
top_139 = min(frames, key=lambda item: item[0])
bottom_tables = sorted([frame for frame in frames if frame is not top_139], key=lambda item: item[1])
return top_139[2], bottom_tables[0][2], bottom_tables[1][2]
def _update_slide_tables(root: ET.Element, model: P4UpdateModel) -> None:
key_project_table, government_table, enterprise_table = _table_frames_by_position(root)
_update_table(government_table, model.government_table_rows, "政府侧客户")
_update_table(enterprise_table, model.enterprise_table_rows, "企业侧客户")
_update_table(key_project_table, model.key_project_table_rows, "139重点项目")
def _rel_qn(tag: str) -> str:
return f"{{{REL_NS}}}{tag}"
def _ct_qn(tag: str) -> str:
return f"{{{CONTENT_TYPES_NS}}}{tag}"
def _next_relationship_id(root: ET.Element) -> str:
max_id = 0
for relationship in root.findall("./rel:Relationship", {"rel": REL_NS}):
raw_id = relationship.attrib.get("Id", "")
if raw_id.startswith("rId") and raw_id[3:].isdigit():
max_id = max(max_id, int(raw_id[3:]))
return f"rId{max_id + 1}"
def _ensure_slide_notes_relationship(xml_bytes: bytes) -> bytes:
ET.register_namespace("", REL_NS)
root = ET.fromstring(xml_bytes)
for relationship in root.findall("./rel:Relationship", {"rel": REL_NS}):
if relationship.attrib.get("Type") == NOTES_SLIDE_REL_TYPE:
relationship.set("Target", "../notesSlides/notesSlide4.xml")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
relationship = ET.SubElement(root, _rel_qn("Relationship"))
relationship.set("Id", _next_relationship_id(root))
relationship.set("Type", NOTES_SLIDE_REL_TYPE)
relationship.set("Target", "../notesSlides/notesSlide4.xml")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _ensure_notes_slide_content_type(xml_bytes: bytes) -> bytes:
ET.register_namespace("", CONTENT_TYPES_NS)
root = ET.fromstring(xml_bytes)
for override in root.findall("./ct:Override", {"ct": CONTENT_TYPES_NS}):
if override.attrib.get("PartName") == "/ppt/notesSlides/notesSlide4.xml":
override.set("ContentType", NOTES_SLIDE_CONTENT_TYPE)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
override = ET.SubElement(root, _ct_qn("Override"))
override.set("PartName", "/ppt/notesSlides/notesSlide4.xml")
override.set("ContentType", NOTES_SLIDE_CONTENT_TYPE)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _notes_slide_relationships_xml() -> bytes:
ET.register_namespace("", REL_NS)
root = ET.Element(_rel_qn("Relationships"))
slide_rel = ET.SubElement(root, _rel_qn("Relationship"))
slide_rel.set("Id", "rId1")
slide_rel.set("Type", SLIDE_REL_TYPE)
slide_rel.set("Target", "../slides/slide4.xml")
master_rel = ET.SubElement(root, _rel_qn("Relationship"))
master_rel.set("Id", "rId2")
master_rel.set("Type", NOTES_MASTER_REL_TYPE)
master_rel.set("Target", "../notesMasters/notesMaster1.xml")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _add_notes_paragraph(parent: ET.Element, text: str, bold: bool = False) -> None:
paragraph = ET.SubElement(parent, _qn("a", "p"))
paragraph_properties = ET.SubElement(paragraph, _qn("a", "pPr"))
paragraph_properties.set("lvl", "0")
run = ET.SubElement(paragraph, _qn("a", "r"))
run_properties = ET.SubElement(run, _qn("a", "rPr"))
run_properties.set("lang", "zh-CN")
run_properties.set("altLang", "en-US")
run_properties.set("sz", "1200")
if bold:
run_properties.set("b", "1")
latin = ET.SubElement(run_properties, _qn("a", "latin"))
latin.set("typeface", "仿宋_GB2312")
east_asian = ET.SubElement(run_properties, _qn("a", "ea"))
east_asian.set("typeface", "仿宋_GB2312")
text_node = ET.SubElement(run, _qn("a", "t"))
text_node.text = text
end_run_properties = ET.SubElement(paragraph, _qn("a", "endParaRPr"))
end_run_properties.set("lang", "zh-CN")
end_run_properties.set("altLang", "en-US")
def _notes_slide_xml(notes_text: str) -> bytes:
root = ET.Element(_qn("p", "notes"))
c_sld = ET.SubElement(root, _qn("p", "cSld"))
sp_tree = ET.SubElement(c_sld, _qn("p", "spTree"))
nv_grp = ET.SubElement(sp_tree, _qn("p", "nvGrpSpPr"))
ET.SubElement(nv_grp, _qn("p", "cNvPr"), {"id": "1", "name": ""})
ET.SubElement(nv_grp, _qn("p", "cNvGrpSpPr"))
ET.SubElement(nv_grp, _qn("p", "nvPr"))
grp_sp_pr = ET.SubElement(sp_tree, _qn("p", "grpSpPr"))
xfrm = ET.SubElement(grp_sp_pr, _qn("a", "xfrm"))
ET.SubElement(xfrm, _qn("a", "off"), {"x": "0", "y": "0"})
ET.SubElement(xfrm, _qn("a", "ext"), {"cx": "0", "cy": "0"})
ET.SubElement(xfrm, _qn("a", "chOff"), {"x": "0", "y": "0"})
ET.SubElement(xfrm, _qn("a", "chExt"), {"cx": "0", "cy": "0"})
shape = ET.SubElement(sp_tree, _qn("p", "sp"))
nv_sp_pr = ET.SubElement(shape, _qn("p", "nvSpPr"))
ET.SubElement(nv_sp_pr, _qn("p", "cNvPr"), {"id": "2", "name": "备注占位符 1"})
ET.SubElement(nv_sp_pr, _qn("p", "cNvSpPr"))
nv_pr = ET.SubElement(nv_sp_pr, _qn("p", "nvPr"))
ET.SubElement(nv_pr, _qn("p", "ph"), {"type": "body", "idx": "1"})
sp_pr = ET.SubElement(shape, _qn("p", "spPr"))
shape_xfrm = ET.SubElement(sp_pr, _qn("a", "xfrm"))
ET.SubElement(shape_xfrm, _qn("a", "off"), {"x": "685800", "y": "4400550"})
ET.SubElement(shape_xfrm, _qn("a", "ext"), {"cx": "5486400", "cy": "3600450"})
ET.SubElement(sp_pr, _qn("a", "prstGeom"), {"prst": "rect"}).append(
ET.Element(_qn("a", "avLst"))
)
tx_body = ET.SubElement(shape, _qn("p", "txBody"))
ET.SubElement(
tx_body,
_qn("a", "bodyPr"),
{"lIns": "91440", "tIns": "45720", "rIns": "91440", "bIns": "45720"},
)
ET.SubElement(tx_body, _qn("a", "lstStyle"))
for index, line in enumerate(notes_text.splitlines()):
_add_notes_paragraph(tx_body, line, bold=index == 0)
clr_map_ovr = ET.SubElement(root, _qn("p", "clrMapOvr"))
ET.SubElement(clr_map_ovr, _qn("a", "masterClrMapping"))
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def update_p4_pptx(pptx_path: Path, output_path: Path, model: P4UpdateModel) -> None:
if not pptx_path.exists():
raise P4UpdateError(f"P4 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {CONTENT_TYPES_XML, SLIDE_XML, SLIDE_RELS_XML}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P4UpdateError(f"P4 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read(SLIDE_XML))
_replace_slide_text(slide_root, model.replacements)
_update_slide_tables(slide_root, model)
updates = {
CONTENT_TYPES_XML: _ensure_notes_slide_content_type(source.read(CONTENT_TYPES_XML)),
SLIDE_XML: ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
SLIDE_RELS_XML: _ensure_slide_notes_relationship(source.read(SLIDE_RELS_XML)),
NOTES_SLIDE_XML: _notes_slide_xml(model.notes_text),
NOTES_SLIDE_RELS_XML: _notes_slide_relationships_xml(),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p4-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
existing_names = set(source.namelist())
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
for filename, payload in updates.items():
if filename not in existing_names:
target.writestr(filename, payload)
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 4 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument("--district", required=True, help="行政区名称,例如 浦东新区。")
return parser.parse_args()
def main() -> None:
args = parse_args()
responses = fetch_p4_responses(args.api_base, args.year, args.month, args.district)
model = build_p4_update_model(*responses)
update_p4_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,780 @@
#!/usr/bin/env python3
"""Update slide 5 of the administrative district service monthly PPTX.
本脚本用于单独更新 PPT 第 5 页:运营商市场中标总览。
取数逻辑:
- `/award/overall?year={year}&month={month}&ct=是&scope=行政区&scope_value={district}`
使用 `累计数据` 和 `月度数据` 更新运营商 KPI、正文和 4 张运营商图表。
- `/award/industry?year={year}&month={month}&ct=是&scope=行政区&scope_value={district}`
使用 `累计数据.金额数据` 更新行业规模图表与文案。
- `/award/overall?year={year - 1}&month={month}&ct=是&scope=行政区&scope_value={district}`
作为累计同比基准。
- `/award/overall?year={previous_year}&month={previous_month}&ct=是&scope=行政区&scope_value={district}`
作为单月环比基准。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 5
SLIDE_XML = "ppt/slides/slide5.xml"
DATA_SOURCES = ["/award/overall", "/award/industry"]
OPERATORS = ("移动", "电信", "联通")
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P5_TEXT_PLACEHOLDERS = [
"{{P4累计统计周期:如2026年1-4月}}",
"{{P4月度统计周期:如2026年4月}}",
"{{P4累计中标金额亿}}",
"{{P4累计金额同比}}",
"{{P4累计中标项目数}}",
"{{P4累计项目数同比}}",
"{{P4单月中标金额亿}}",
"{{P4单月金额环比}}",
"{{P4单月中标项目数}}",
"{{P4单月项目数环比}}",
"{{P4累计总览:说明累计金额、项目数同比和整体特征}}",
"{{P4累计移动分析:简要说明总结移动特征}}",
"{{P4累计电信分析:简要说明总结电信特征}}",
"{{P4累计联通分析:简要说明总结联通特征}}",
"{{P4月度总览:说明单月金额、项目数环比和整体特征}}",
"{{P4月度移动分析:简要说明总结移动特征}}",
"{{P4月度电信分析:简要说明总结电信特征}}",
"{{P4月度联通分析:简要说明总结联通特征}}",
"{{P4行业总览:按累计中标金额Top3说明行业规模和集中度}}",
"{{P4行业分析1:按累计中标金额排序第1行业说明运营商竞争格局}}",
"{{P4行业分析2:按累计中标金额排序第2行业说明运营商竞争格局}}",
"{{P4行业分析3:按累计中标金额排序第3行业说明运营商竞争格局}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请概括,凸显特征"
COMPACT_FOLLOWUP_PROMPT_SUFFIX = ""
class P5UpdateError(ValueError):
"""Raised when P5 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class OperatorMetric:
operator: str
count: int
amount_wan: Decimal
count_share: str
amount_share: str
count_change_pp: str
amount_change_pp: str
@dataclass(frozen=True)
class SegmentAmountRow:
name: str
total_amount_wan: Decimal
operator_amounts: dict[str, Decimal]
@dataclass(frozen=True)
class SegmentCountRow:
name: str
total_count: int
operator_counts: dict[str, int]
@dataclass(frozen=True)
class ChartModel:
title: str
series_name: str
categories: list[str]
values: list[int | float]
@dataclass(frozen=True)
class P5UpdateModel:
cumulative_period_label: str
monthly_period_label: str
cumulative_total_count: int
cumulative_total_amount_wan: Decimal
monthly_total_count: int
monthly_total_amount_wan: Decimal
replacements: dict[str, str]
charts: dict[str, ChartModel]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P5UpdateError(f"P5 更新失败:{field_path} 为空,无法更新第五页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P5UpdateError(f"P5 更新失败:{field_path} 不是可计算数字,无法更新第五页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P5UpdateError(f"P5 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P5UpdateError(f"P5 更新失败:{field_path} 为空,无法更新第五页。")
return text
def _month_number(response: dict[str, Any], endpoint: str) -> int:
raw_month = str(response.get("统计月份", "")).strip().removesuffix("")
if not raw_month:
raise P5UpdateError(f"P5 更新失败:{endpoint} 缺少统计月份。")
try:
return int(raw_month)
except ValueError as exc:
raise P5UpdateError(f"P5 更新失败:{endpoint}.统计月份={raw_month!r} 不是月份。") from exc
def _period_labels(response: dict[str, Any]) -> tuple[str, str]:
year = response.get("统计年份")
month_number = _month_number(response, "/award/overall")
if not year:
raise P5UpdateError("P5 更新失败:/award/overall 缺少统计年份。")
return f"{year}年1-{month_number}", f"{year}{month_number}"
def _growth_percent(current: Decimal, baseline: Decimal, field_path: str) -> Decimal | None:
if baseline == 0:
return None
return (current - baseline) / baseline * Decimal("100")
def _format_growth(value: Decimal | None) -> str:
if value is None:
return "/"
sign = "+" if value > 0 else ""
return f"{sign}{value.quantize(Decimal('0.01'))}%"
def _format_pp_change(value: str) -> str:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return "/"
decimal_value = _parse_decimal(value, "运营商份额变化")
sign = "+" if decimal_value > 0 else ""
return f"{sign}{_format_decimal(decimal_value)}pp"
def _format_yi_from_wan(value: Decimal) -> str:
return f"{_format_yi_number_from_wan(value)}亿"
def _format_yi_number_from_wan(value: Decimal) -> str:
return str((value / Decimal("10000")).quantize(Decimal("0.01")))
def _wrap_followup_prompt(fact_text: str, *, compact: bool = False) -> str:
suffix = COMPACT_FOLLOWUP_PROMPT_SUFFIX if compact else FOLLOWUP_PROMPT_SUFFIX
if not suffix:
return f"{{{{{fact_text}}}}}"
return f"{{{{{fact_text}{suffix}}}}}"
def _ensure_operator_rows(rows: Any, section_path: str) -> list[dict[str, Any]]:
if not isinstance(rows, list) or not rows:
raise P5UpdateError(f"P5 更新失败:{section_path} 缺少数据。")
by_operator: dict[str, dict[str, Any]] = {}
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise P5UpdateError(f"P5 更新失败:{section_path}[{index}] 不是对象。")
operator = _required_text(row, "运营商", f"{section_path}[{index}].运营商")
by_operator[operator] = row
missing = [operator for operator in OPERATORS if operator not in by_operator]
if missing:
raise P5UpdateError(f"P5 更新失败:{section_path} 缺少运营商:{', '.join(missing)}")
return [by_operator[operator] for operator in OPERATORS]
def _operator_metrics(
response: dict[str, Any],
section_key: str,
count_key: str,
amount_key: str,
count_change_key: str,
amount_change_key: str,
) -> list[OperatorMetric]:
raw_rows = _ensure_operator_rows(response.get(section_key), f"/award/overall.{section_key}")
metrics: list[OperatorMetric] = []
for row in raw_rows:
operator = _required_text(row, "运营商", "/award/overall.运营商")
metrics.append(
OperatorMetric(
operator=operator,
count=int(_parse_decimal(row.get(count_key), f"/award/overall.{section_key}.{operator}.{count_key}")),
amount_wan=_parse_decimal(row.get(amount_key), f"/award/overall.{section_key}.{operator}.{amount_key}"),
count_share=_required_text(row, "个数份额", f"/award/overall.{section_key}.{operator}.个数份额"),
amount_share=_required_text(row, "金额份额", f"/award/overall.{section_key}.{operator}.金额份额"),
count_change_pp=_format_pp_change(
_required_text(row, count_change_key, f"/award/overall.{section_key}.{operator}.{count_change_key}")
),
amount_change_pp=_format_pp_change(
_required_text(row, amount_change_key, f"/award/overall.{section_key}.{operator}.{amount_change_key}")
),
)
)
return metrics
def _operator_totals(
response: dict[str, Any],
section_key: str,
count_key: str,
amount_key: str,
source_label: str,
) -> tuple[int, Decimal]:
raw_rows = _ensure_operator_rows(response.get(section_key), f"{source_label}.{section_key}")
total_count = 0
total_amount = Decimal("0")
for row in raw_rows:
operator = _required_text(row, "运营商", f"{source_label}.{section_key}.运营商")
total_count += int(_parse_decimal(row.get(count_key), f"{source_label}.{section_key}.{operator}.{count_key}"))
total_amount += _parse_decimal(row.get(amount_key), f"{source_label}.{section_key}.{operator}.{amount_key}")
return total_count, total_amount
def _operator_phrase(metrics: list[OperatorMetric], operator: str, period_type: str, comparison_label: str) -> str:
row = next(metric for metric in metrics if metric.operator == operator)
return _wrap_followup_prompt(
f"{operator}{row.count}个/{_format_yi_from_wan(row.amount_wan)},额{row.amount_share}{comparison_label}{row.amount_change_pp}",
compact=True,
)
def _relationship_phrase(count_growth: Decimal | None, amount_growth: Decimal | None) -> str:
if count_growth is None or amount_growth is None:
return "对比基准为0,增幅暂不可比,需以本期实际规模判断经营机会"
if count_growth >= 0 and amount_growth >= 0:
if amount_growth > count_growth * Decimal("2"):
return "金额增速高于项目数增速,大单驱动特征明显"
return "项目数与金额同步增长,市场保持稳健扩张"
if count_growth < 0 <= amount_growth:
return "项目数承压但金额增长,项目单体规模抬升"
if count_growth >= 0 > amount_growth:
return "项目数增长但金额回落,单体规模有所收缩"
return "项目数与金额同步回落,市场阶段性承压"
def _overall_summary(
period_label: str,
total_count: int,
total_amount_wan: Decimal,
count_growth: Decimal | None,
amount_growth: Decimal | None,
comparison_label: str,
) -> str:
period_type = "单月" if comparison_label == "环比" else "累计"
return _wrap_followup_prompt(
f"{period_type}{total_count}个/{_format_yi_from_wan(total_amount_wan)}"
f"{comparison_label}{_format_growth(count_growth)}/额{_format_growth(amount_growth)}",
compact=True,
)
def _segment_rows(response: dict[str, Any], dimension_key: str, endpoint: str) -> list[SegmentAmountRow]:
raw_amount_rows = response.get("累计数据", {}).get("金额数据") if isinstance(response.get("累计数据"), dict) else None
if not isinstance(raw_amount_rows, list) or not raw_amount_rows:
raise P5UpdateError(f"P5 更新失败:{endpoint}.累计数据.金额数据 缺少数据。")
rows: list[SegmentAmountRow] = []
for index, raw_row in enumerate(raw_amount_rows, start=1):
if not isinstance(raw_row, dict):
raise P5UpdateError(f"P5 更新失败:{endpoint}.累计数据.金额数据[{index}] 不是对象。")
name = _required_text(raw_row, dimension_key, f"{endpoint}.累计数据.金额数据[{index}].{dimension_key}")
operator_amounts = {
operator: _parse_decimal(
raw_row.get(f"{operator}金额(万元)"),
f"{endpoint}.累计数据.金额数据.{name}.{operator}金额(万元)",
)
for operator in OPERATORS
}
rows.append(
SegmentAmountRow(
name=name,
total_amount_wan=sum(operator_amounts.values(), Decimal("0")),
operator_amounts=operator_amounts,
)
)
return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)
def _segment_count_rows(response: dict[str, Any], dimension_key: str, endpoint: str) -> list[SegmentCountRow]:
raw_count_rows = response.get("累计数据", {}).get("个数数据") if isinstance(response.get("累计数据"), dict) else None
if not isinstance(raw_count_rows, list) or not raw_count_rows:
raise P5UpdateError(f"P5 更新失败:{endpoint}.累计数据.个数数据 缺少数据。")
rows: list[SegmentCountRow] = []
for index, raw_row in enumerate(raw_count_rows, start=1):
if not isinstance(raw_row, dict):
raise P5UpdateError(f"P5 更新失败:{endpoint}.累计数据.个数数据[{index}] 不是对象。")
name = _required_text(raw_row, dimension_key, f"{endpoint}.累计数据.个数数据[{index}].{dimension_key}")
operator_counts = {
operator: int(
_parse_decimal(
raw_row.get(f"{operator}个数"),
f"{endpoint}.累计数据.个数数据.{name}.{operator}个数",
)
)
for operator in OPERATORS
}
rows.append(
SegmentCountRow(
name=name,
total_count=sum(operator_counts.values()),
operator_counts=operator_counts,
)
)
return sorted(rows, key=lambda row: row.total_count, reverse=True)
def _dominant_operator(row: SegmentAmountRow) -> tuple[str, Decimal]:
operator, amount = max(row.operator_amounts.items(), key=lambda item: item[1])
share = Decimal("0") if row.total_amount_wan == 0 else amount / row.total_amount_wan * Decimal("100")
return operator, share
def _segment_summary(rows: list[SegmentAmountRow], label: str) -> str:
top3 = rows[:3]
total_amount = sum((row.total_amount_wan for row in rows), Decimal("0"))
top3_amount = sum((row.total_amount_wan for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "/".join(f"{row.name}{_format_yi_number_from_wan(row.total_amount_wan)}" for row in top3)
return _wrap_followup_prompt(
f"行业Top3{top_text}亿,占比{share.quantize(Decimal('0.1'))}%",
compact=True,
)
def _segment_analysis(index: int, row: SegmentAmountRow) -> str:
operator, share = _dominant_operator(row)
return _wrap_followup_prompt(
f"{row.name}{_format_yi_from_wan(row.total_amount_wan)}{operator}{share.quantize(Decimal('0.1'))}%",
compact=True,
)
def _chart_values_from_amount_rows(rows: list[SegmentAmountRow]) -> list[float]:
return [float(row.total_amount_wan) for row in rows]
def _chart_values_from_count_rows(rows: list[SegmentCountRow]) -> list[int]:
return [row.total_count for row in rows]
def build_p5_update_model(
current_overall_response: dict[str, Any],
industry_response: dict[str, Any],
prior_year_overall_response: dict[str, Any],
previous_month_overall_response: dict[str, Any],
district: str,
) -> P5UpdateModel:
cumulative_period_label, monthly_period_label = _period_labels(current_overall_response)
cumulative_metrics = _operator_metrics(
current_overall_response,
"累计数据",
"累计中标个数",
"累计中标金额(万元)",
"个数份额同比变化(百分点)",
"金额份额同比变化(百分点)",
)
monthly_metrics = _operator_metrics(
current_overall_response,
"月度数据",
"单月中标个数",
"单月中标金额(万元)",
"个数份额环比变化(百分点)",
"金额份额环比变化(百分点)",
)
cumulative_total_count = sum(metric.count for metric in cumulative_metrics)
cumulative_total_amount = sum((metric.amount_wan for metric in cumulative_metrics), Decimal("0"))
monthly_total_count = sum(metric.count for metric in monthly_metrics)
monthly_total_amount = sum((metric.amount_wan for metric in monthly_metrics), Decimal("0"))
prior_count, prior_amount = _operator_totals(
prior_year_overall_response,
"累计数据",
"累计中标个数",
"累计中标金额(万元)",
"/award/overall.去年同期",
)
previous_count, previous_amount = _operator_totals(
previous_month_overall_response,
"月度数据",
"单月中标个数",
"单月中标金额(万元)",
"/award/overall.上月",
)
cumulative_count_growth = _growth_percent(
Decimal(cumulative_total_count),
Decimal(prior_count),
"/award/overall.去年同期.累计中标个数",
)
cumulative_amount_growth = _growth_percent(
cumulative_total_amount,
prior_amount,
"/award/overall.去年同期.累计中标金额(万元)",
)
monthly_count_growth = _growth_percent(
Decimal(monthly_total_count),
Decimal(previous_count),
"/award/overall.上月.单月中标个数",
)
monthly_amount_growth = _growth_percent(
monthly_total_amount,
previous_amount,
"/award/overall.上月.单月中标金额(万元)",
)
industry_rows = _segment_rows(industry_response, "行业", "/award/industry")
industry_count_rows = _segment_count_rows(industry_response, "行业", "/award/industry")
replacements = {
"{{某某行政区}}": district,
P5_TEXT_PLACEHOLDERS[0]: cumulative_period_label,
P5_TEXT_PLACEHOLDERS[1]: monthly_period_label,
P5_TEXT_PLACEHOLDERS[2]: _format_yi_from_wan(cumulative_total_amount),
P5_TEXT_PLACEHOLDERS[3]: _format_growth(cumulative_amount_growth),
P5_TEXT_PLACEHOLDERS[4]: f"{cumulative_total_count}",
P5_TEXT_PLACEHOLDERS[5]: _format_growth(cumulative_count_growth),
P5_TEXT_PLACEHOLDERS[6]: _format_yi_from_wan(monthly_total_amount),
P5_TEXT_PLACEHOLDERS[7]: _format_growth(monthly_amount_growth),
P5_TEXT_PLACEHOLDERS[8]: f"{monthly_total_count}",
P5_TEXT_PLACEHOLDERS[9]: _format_growth(monthly_count_growth),
P5_TEXT_PLACEHOLDERS[10]: _overall_summary(
cumulative_period_label,
cumulative_total_count,
cumulative_total_amount,
cumulative_count_growth,
cumulative_amount_growth,
"同比",
),
P5_TEXT_PLACEHOLDERS[14]: _overall_summary(
monthly_period_label,
monthly_total_count,
monthly_total_amount,
monthly_count_growth,
monthly_amount_growth,
"环比",
),
P5_TEXT_PLACEHOLDERS[18]: _segment_summary(industry_rows, "从行业规模"),
}
for index, operator in enumerate(OPERATORS):
replacements[P5_TEXT_PLACEHOLDERS[11 + index]] = _operator_phrase(
cumulative_metrics,
operator,
"累计",
"同比",
)
replacements[P5_TEXT_PLACEHOLDERS[15 + index]] = _operator_phrase(
monthly_metrics,
operator,
"单月",
"环比",
)
for index, row in enumerate(industry_rows[:3], start=1):
replacements[P5_TEXT_PLACEHOLDERS[18 + index]] = _segment_analysis(index, row)
charts = {
"chart6": ChartModel(
title="各运营商累计中标个数",
series_name="累计中标个数",
categories=[metric.operator for metric in cumulative_metrics],
values=[metric.count for metric in cumulative_metrics],
),
"chart7": ChartModel(
title="各运营商累计中标金额(万元)",
series_name="累计中标金额(万元)",
categories=[metric.operator for metric in cumulative_metrics],
values=[float(metric.amount_wan) for metric in cumulative_metrics],
),
"chart8": ChartModel(
title="各运营商单月中标个数",
series_name="单月中标个数",
categories=[metric.operator for metric in monthly_metrics],
values=[metric.count for metric in monthly_metrics],
),
"chart9": ChartModel(
title="各运营商单月中标金额(万元)",
series_name="单月中标金额(万元)",
categories=[metric.operator for metric in monthly_metrics],
values=[float(metric.amount_wan) for metric in monthly_metrics],
),
"chart10": ChartModel(
title=f"运营商各行业累计中标金额分布({cumulative_period_label}",
series_name="累计中标金额(万元)",
categories=[row.name for row in industry_rows],
values=_chart_values_from_amount_rows(industry_rows),
),
"chart11": ChartModel(
title=f"运营商各行业累计中标个数分布({cumulative_period_label}",
series_name="累计中标个数",
categories=[row.name for row in industry_count_rows],
values=_chart_values_from_count_rows(industry_count_rows),
),
}
return P5UpdateModel(
cumulative_period_label=cumulative_period_label,
monthly_period_label=monthly_period_label,
cumulative_total_count=cumulative_total_count,
cumulative_total_amount_wan=cumulative_total_amount,
monthly_total_count=monthly_total_count,
monthly_total_amount_wan=monthly_total_amount,
replacements=replacements,
charts=charts,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P5UpdateError(f"P5 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P5UpdateError(f"P5 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P5UpdateError(f"P5 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P5UpdateError(f"P5 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P5UpdateError(f"P5 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def _previous_month(year: int, month: int) -> tuple[int, int]:
if month == 1:
return year - 1, 12
return year, month - 1
def fetch_p5_responses(
api_base: str,
year: int,
month: str,
district: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
district = district.strip()
if not district:
raise P5UpdateError("P5 更新失败:行政区参数 --district 不能为空。")
month_number = int(str(month).strip().removesuffix(""))
previous_year, previous_month = _previous_month(year, month_number)
common_params = {"ct": "", "scope": "行政区", "scope_value": district}
current_params = {"year": year, "month": month, **common_params}
return (
fetch_json(api_base, "/award/overall", current_params),
fetch_json(api_base, "/award/industry", current_params),
fetch_json(api_base, "/award/overall", {"year": year - 1, "month": month, **common_params}),
fetch_json(
api_base,
"/award/overall",
{"year": previous_year, "month": f"{previous_month}", **common_params},
),
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
if missing:
raise P5UpdateError(f"P5 更新失败:模板第 5 页缺少占位符:{', '.join(missing)}")
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P5UpdateError(f"P5 更新失败:{chart_name}.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _chart_value_text(value: int | float) -> str:
return _format_decimal(Decimal(str(value)))
def _update_chart_xml(chart_xml: bytes, chart_name: str, model: ChartModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.title, chart_name)
series_nodes = root.findall(".//c:ser", OOXML_NS)
if not series_nodes:
raise P5UpdateError(f"P5 更新失败:{chart_name}.xml 缺少图表系列。")
series = series_nodes[0]
name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = model.series_name
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P5UpdateError(f"P5 更新失败:{chart_name}.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.categories)
_set_cache_points(value_cache, [_chart_value_text(value) for value in model.values])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook(workbook_bytes: bytes, category_header: str, chart_model: ChartModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
clear_columns = max(sheet.max_column, 2)
for row_index in range(1, clear_rows + 1):
for column_index in range(1, clear_columns + 1):
sheet.cell(row=row_index, column=column_index).value = None
sheet["A1"] = category_header
sheet["B1"] = chart_model.series_name
for row_index, (category, value) in enumerate(zip(chart_model.categories, chart_model.values, strict=True), start=2):
sheet.cell(row=row_index, column=1).value = category
sheet.cell(row=row_index, column=2).value = value
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p5_pptx(pptx_path: Path, output_path: Path, model: P5UpdateModel) -> None:
if not pptx_path.exists():
raise P5UpdateError(f"P5 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
chart_parts = {f"ppt/charts/chart{index}.xml" for index in range(6, 12)}
workbook_parts = {f"ppt/embeddings/Workbook{index}.xlsx" for index in range(6, 12)}
required_parts = {"ppt/slides/slide5.xml", *chart_parts, *workbook_parts}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P5UpdateError(f"P5 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide5.xml"))
_replace_slide_text(slide_root, model.replacements)
updates: dict[str, bytes] = {
"ppt/slides/slide5.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
for index in range(6, 12):
chart_key = f"chart{index}"
chart_part = f"ppt/charts/{chart_key}.xml"
updates[chart_part] = _update_chart_xml(source.read(chart_part), chart_key, model.charts[chart_key])
category_header = "运营商" if index in {6, 7, 8, 9} else "行业"
workbook_part = f"ppt/embeddings/Workbook{index}.xlsx"
updates[workbook_part] = _update_workbook(
source.read(workbook_part),
category_header,
model.charts[chart_key],
)
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p5-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 5 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument("--district", required=True, help="行政区名称,例如 浦东新区。")
return parser.parse_args()
def main() -> None:
args = parse_args()
responses = fetch_p5_responses(args.api_base, args.year, args.month, args.district)
model = build_p5_update_model(*responses, args.district.strip())
update_p5_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""Update slide 6 of the administrative district service monthly PPTX.
本脚本用于单独更新 PPT 第 6 页:累计行业中标分析。
取数逻辑:
- `/award/industry?year={year}&month={month}&ct=是&scope=行政区&scope_value={district}`
使用 `累计数据.个数数据` 和 `累计数据.金额数据`。
更新内容:
- 正文:按累计中标金额 Top3 生成行业总览和重点行业分析。
- 表格:各行业三家运营商的个数份额、金额份额。
- 原生图表:chart12 行业累计中标个数,chart13 行业累计中标金额。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 6
SLIDE_XML = "ppt/slides/slide6.xml"
DATA_SOURCES = ["/award/industry"]
OPERATORS = ("电信", "移动", "联通")
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P6_TEXT_PLACEHOLDERS = [
"{{P5行业总览:按累计中标金额Top3说明行业集中度和竞争分化}}",
"{{P5行业分析1:按累计中标金额排序第1行业,介绍行业特点,总结说明三家运营商个数份额和金额份额特征}}",
"{{P5行业分析2:按累计中标金额排序第2行业,介绍行业特点,总结说明三家运营商个数份额和金额份额特征}}",
"{{P5行业分析3:按累计中标金额排序第3行业,介绍行业特点,总结说明三家运营商个数份额和金额份额特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P6UpdateError(ValueError):
"""Raised when P6 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class IndustryRow:
industry: str
total_amount_wan: Decimal
counts: dict[str, int]
amounts_wan: dict[str, Decimal]
count_shares: dict[str, str]
amount_shares: dict[str, str]
@dataclass(frozen=True)
class MultiSeriesChartModel:
title: str
categories: list[str]
series: dict[str, list[int | float]]
@dataclass(frozen=True)
class P6UpdateModel:
period_label: str
rows: list[IndustryRow]
table_rows: list[list[str]]
replacements: dict[str, str]
charts: dict[str, MultiSeriesChartModel]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P6UpdateError(f"P6 更新失败:{field_path} 为空,无法更新第六页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P6UpdateError(f"P6 更新失败:{field_path} 不是可计算数字,无法更新第六页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P6UpdateError(f"P6 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P6UpdateError(f"P6 更新失败:{field_path} 为空,无法更新第六页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip().removesuffix("")
if not year or not month:
raise P6UpdateError("P6 更新失败:/award/industry 缺少统计年份或统计月份。")
return f"{year}年1-{month}"
def _raw_rows(response: dict[str, Any], data_key: str) -> list[dict[str, Any]]:
cumulative = response.get("累计数据")
if not isinstance(cumulative, dict):
raise P6UpdateError("P6 更新失败:/award/industry 缺少 累计数据。")
rows = cumulative.get(data_key)
if not isinstance(rows, list) or not rows:
raise P6UpdateError(f"P6 更新失败:/award/industry.累计数据.{data_key} 缺少数据。")
return rows
def _rows_by_industry(rows: list[dict[str, Any]], data_key: str) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise P6UpdateError(f"P6 更新失败:/award/industry.累计数据.{data_key}[{index}] 不是对象。")
industry = _required_text(row, "行业", f"/award/industry.累计数据.{data_key}[{index}].行业")
result[industry] = row
return result
def _industry_rows(response: dict[str, Any]) -> list[IndustryRow]:
count_by_industry = _rows_by_industry(_raw_rows(response, "个数数据"), "个数数据")
amount_by_industry = _rows_by_industry(_raw_rows(response, "金额数据"), "金额数据")
missing_amount = sorted(set(count_by_industry) - set(amount_by_industry))
missing_count = sorted(set(amount_by_industry) - set(count_by_industry))
if missing_amount or missing_count:
raise P6UpdateError(
"P6 更新失败:/award/industry.累计数据 个数数据与金额数据行业不一致。"
)
rows: list[IndustryRow] = []
for industry in count_by_industry:
count_row = count_by_industry[industry]
amount_row = amount_by_industry[industry]
counts = {
operator: int(
_parse_decimal(
count_row.get(f"{operator}个数"),
f"/award/industry.累计数据.个数数据.{industry}.{operator}个数",
)
)
for operator in OPERATORS
}
amounts = {
operator: _parse_decimal(
amount_row.get(f"{operator}金额(万元)"),
f"/award/industry.累计数据.金额数据.{industry}.{operator}金额(万元)",
)
for operator in OPERATORS
}
count_shares = {
operator: _required_text(
count_row,
f"{operator}个数份额",
f"/award/industry.累计数据.个数数据.{industry}.{operator}个数份额",
)
for operator in OPERATORS
}
amount_shares = {
operator: _required_text(
amount_row,
f"{operator}金额份额",
f"/award/industry.累计数据.金额数据.{industry}.{operator}金额份额",
)
for operator in OPERATORS
}
rows.append(
IndustryRow(
industry=industry,
total_amount_wan=sum(amounts.values(), Decimal("0")),
counts=counts,
amounts_wan=amounts,
count_shares=count_shares,
amount_shares=amount_shares,
)
)
if len(rows) < 10:
raise P6UpdateError("P6 更新失败:/award/industry.累计数据 少于 10 个行业。")
return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)[:10]
def _format_yi_from_wan(value: Decimal) -> str:
return f"{_format_yi_number_from_wan(value)}亿"
def _format_yi_number_from_wan(value: Decimal) -> str:
return str((value / Decimal("10000")).quantize(Decimal("0.01")))
def _wrap_followup_prompt(fact_text: str) -> str:
return f"{{{{{fact_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _dominant_operator(shares: dict[str, str]) -> tuple[str, str]:
scored = {
operator: _parse_decimal(share, f"P6.{operator}份额")
for operator, share in shares.items()
}
operator, _ = max(scored.items(), key=lambda item: item[1])
return operator, shares[operator]
def _summary_text(rows: list[IndustryRow]) -> str:
top3 = rows[:3]
total_amount = sum((row.total_amount_wan for row in rows), Decimal("0"))
top3_amount = sum((row.total_amount_wan for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "/".join(f"{row.industry}{_format_yi_number_from_wan(row.total_amount_wan)}" for row in top3)
return _wrap_followup_prompt(
f"行业Top3{top_text}亿,占比{share.quantize(Decimal('0.1'))}%"
)
def _analysis_text(index: int, row: IndustryRow) -> str:
count_operator, count_share = _dominant_operator(row.count_shares)
amount_operator, amount_share = _dominant_operator(row.amount_shares)
return _wrap_followup_prompt(
f"{row.industry}行业:{_format_yi_from_wan(row.total_amount_wan)}"
f"个数{count_operator}{count_share},金额{amount_operator}{amount_share}"
)
def _table_rows(rows: list[IndustryRow]) -> list[list[str]]:
return [
["行业", "电信个数份额", "移动个数份额", "联通个数份额", "电信金额份额", "移动金额份额", "联通金额份额"],
*[
[
row.industry,
row.count_shares["电信"],
row.count_shares["移动"],
row.count_shares["联通"],
row.amount_shares["电信"],
row.amount_shares["移动"],
row.amount_shares["联通"],
]
for row in rows
],
]
def build_p6_update_model(response: dict[str, Any]) -> P6UpdateModel:
period_label = _period_label(response)
rows = _industry_rows(response)
replacements = {
P6_TEXT_PLACEHOLDERS[0]: _summary_text(rows),
}
for index, row in enumerate(rows[:3], start=1):
replacements[P6_TEXT_PLACEHOLDERS[index]] = _analysis_text(index, row)
charts = {
"chart12": MultiSeriesChartModel(
title=f"累计行业中标个数对比({period_label}",
categories=[row.industry for row in rows],
series={
f"{operator}个数": [row.counts[operator] for row in rows]
for operator in OPERATORS
},
),
"chart13": MultiSeriesChartModel(
title=f"累计行业中标金额对比(万元,{period_label}",
categories=[row.industry for row in rows],
series={
f"{operator}金额(万元)": [float(row.amounts_wan[operator]) for row in rows]
for operator in OPERATORS
},
),
}
return P6UpdateModel(
period_label=period_label,
rows=rows,
table_rows=_table_rows(rows),
replacements=replacements,
charts=charts,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=120) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P6UpdateError(f"P6 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P6UpdateError(f"P6 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P6UpdateError(f"P6 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P6UpdateError(f"P6 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P6UpdateError(f"P6 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p6_response(api_base: str, year: int, month: str, district: str) -> dict[str, Any]:
district = district.strip()
if not district:
raise P6UpdateError("P6 更新失败:行政区参数 --district 不能为空。")
return fetch_json(
api_base,
"/award/industry",
{"year": year, "month": month, "ct": "", "scope": "行政区", "scope_value": district},
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
if missing:
raise P6UpdateError(f"P6 更新失败:模板第 6 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P6UpdateError("P6 更新失败:表格单元格缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P6UpdateError("P6 更新失败:第 6 页未找到表格。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P6UpdateError("P6 更新失败:第 6 页表格行数不足。")
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P6UpdateError("P6 更新失败:第 6 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P6UpdateError(f"P6 更新失败:{chart_name}.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _chart_value_text(value: int | float) -> str:
return _format_decimal(Decimal(str(value)))
def _update_chart_xml(chart_xml: bytes, chart_name: str, model: MultiSeriesChartModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.title, chart_name)
series_nodes = root.findall(".//c:ser", OOXML_NS)
series_items = list(model.series.items())
if len(series_nodes) < len(series_items):
raise P6UpdateError(f"P6 更新失败:{chart_name}.xml 图表系列不足。")
for series_node, (series_name, values) in zip(series_nodes, series_items, strict=False):
name_nodes = series_node.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = series_name
category_cache = series_node.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series_node.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P6UpdateError(f"P6 更新失败:{chart_name}.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.categories)
_set_cache_points(value_cache, [_chart_value_text(value) for value in values])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook(workbook_bytes: bytes, chart_model: MultiSeriesChartModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
clear_columns = max(sheet.max_column, len(chart_model.series) + 1)
for row_index in range(1, clear_rows + 1):
for column_index in range(1, clear_columns + 1):
sheet.cell(row=row_index, column=column_index).value = None
sheet["A1"] = "行业"
for column_index, series_name in enumerate(chart_model.series, start=2):
sheet.cell(row=1, column=column_index).value = series_name
for row_index, category in enumerate(chart_model.categories, start=2):
sheet.cell(row=row_index, column=1).value = category
for column_index, values in enumerate(chart_model.series.values(), start=2):
sheet.cell(row=row_index, column=column_index).value = values[row_index - 2]
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p6_pptx(pptx_path: Path, output_path: Path, model: P6UpdateModel) -> None:
if not pptx_path.exists():
raise P6UpdateError(f"P6 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {
"ppt/slides/slide6.xml",
"ppt/charts/chart12.xml",
"ppt/charts/chart13.xml",
"ppt/embeddings/Workbook12.xlsx",
"ppt/embeddings/Workbook13.xlsx",
}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P6UpdateError(f"P6 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide6.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_table(slide_root, model.table_rows)
updates = {
"ppt/slides/slide6.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
"ppt/charts/chart12.xml": _update_chart_xml(
source.read("ppt/charts/chart12.xml"),
"chart12",
model.charts["chart12"],
),
"ppt/charts/chart13.xml": _update_chart_xml(
source.read("ppt/charts/chart13.xml"),
"chart13",
model.charts["chart13"],
),
"ppt/embeddings/Workbook12.xlsx": _update_workbook(
source.read("ppt/embeddings/Workbook12.xlsx"),
model.charts["chart12"],
),
"ppt/embeddings/Workbook13.xlsx": _update_workbook(
source.read("ppt/embeddings/Workbook13.xlsx"),
model.charts["chart13"],
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p6-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 6 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument("--district", required=True, help="行政区名称,例如 浦东新区。")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p6_response(args.api_base, args.year, args.month, args.district)
model = build_p6_update_model(response)
update_p6_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""Update slide 7 of the administrative district service monthly PPTX.
本脚本用于单独更新 PPT 第 7 页:当月友商中标大单。
取数逻辑:
- `/award/competitor-large-deals?year={year}&month={month}&ct=是&scope=行政区`
`&scope_value={district}`
使用 `电信大单` 和 `联通大单`。
更新内容:
- 左表:电信当月中标大单 Top8。
- 右表:联通当月中标大单 Top8。
- 当某一侧无当月大单时,在该侧表格第一行数据写入“无”。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
PAGE_NUMBER = 7
SLIDE_XML = "ppt/slides/slide7.xml"
DATA_SOURCES = ["/award/competitor-large-deals"]
TELECOM_SECTION = "电信大单"
UNICOM_SECTION = "联通大单"
TABLE_HEADER = ["招标人", "行业", "项目名称", "中标金额(万元)"]
TOP_N = 8
TELECOM_TITLE_TEXT = "电信当月中标大单"
UNICOM_TITLE_TEXT = "联通当月中标大单"
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
class P7UpdateError(ValueError):
"""Raised when P7 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class DealRow:
buyer: str
industry: str
project_name: str
amount_wan: str
amount_value: Decimal
@dataclass(frozen=True)
class P7UpdateModel:
period_label: str
telecom_rows: list[DealRow]
unicom_rows: list[DealRow]
tables: dict[str, list[list[str]]]
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P7UpdateError(f"P7 更新失败:{field_path} 为空,无法更新第七页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P7UpdateError(
f"P7 更新失败:{field_path} 不是可计算数字,无法更新第七页。"
)
raw = raw.replace(",", "")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P7UpdateError(f"P7 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P7UpdateError(f"P7 更新失败:{field_path} 为空,无法更新第七页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip().removesuffix("")
if not year or not month:
raise P7UpdateError(
"P7 更新失败:/award/competitor-large-deals 缺少统计年份或统计月份。"
)
return f"{year}{month}"
def _deal_rows(response: dict[str, Any], section: str) -> list[DealRow]:
raw_rows = response.get(section)
if not isinstance(raw_rows, list):
raise P7UpdateError(
f"P7 更新失败:/award/competitor-large-deals.{section} 缺少列表数据。"
)
rows: list[DealRow] = []
for index, row in enumerate(raw_rows, start=1):
if not isinstance(row, dict):
raise P7UpdateError(
"P7 更新失败:"
f"/award/competitor-large-deals.{section}[{index}] 不是对象。"
)
amount_text = _required_text(
row,
"中标金额(万元)",
f"/award/competitor-large-deals.{section}[{index}].中标金额(万元)",
)
rows.append(
DealRow(
buyer=_required_text(
row,
"招标人",
f"/award/competitor-large-deals.{section}[{index}].招标人",
),
industry=_required_text(
row,
"行业",
f"/award/competitor-large-deals.{section}[{index}].行业",
),
project_name=_required_text(
row,
"项目名称",
f"/award/competitor-large-deals.{section}[{index}].项目名称",
),
amount_wan=amount_text,
amount_value=_parse_decimal(
amount_text,
f"/award/competitor-large-deals.{section}[{index}].中标金额(万元)",
),
)
)
return sorted(rows, key=lambda row: (-row.amount_value, row.buyer, row.project_name))[:TOP_N]
def _table_rows(rows: list[DealRow]) -> list[list[str]]:
table_rows = [TABLE_HEADER]
if rows:
table_rows.extend(
[row.buyer, row.industry, row.project_name, row.amount_wan]
for row in rows
)
else:
table_rows.append(["", "", "", ""])
while len(table_rows) < TOP_N + 1:
table_rows.append(["", "", "", ""])
return table_rows
def build_p7_update_model(response: dict[str, Any]) -> P7UpdateModel:
period_label = _period_label(response)
telecom_rows = _deal_rows(response, TELECOM_SECTION)
unicom_rows = _deal_rows(response, UNICOM_SECTION)
return P7UpdateModel(
period_label=period_label,
telecom_rows=telecom_rows,
unicom_rows=unicom_rows,
tables={
TELECOM_SECTION: _table_rows(telecom_rows),
UNICOM_SECTION: _table_rows(unicom_rows),
},
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P7UpdateError(f"P7 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P7UpdateError(f"P7 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P7UpdateError(f"P7 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P7UpdateError(f"P7 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P7UpdateError(f"P7 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p7_response(api_base: str, year: int, month: str, district: str) -> dict[str, Any]:
district = district.strip()
if not district:
raise P7UpdateError("P7 更新失败:行政区参数 --district 不能为空。")
return fetch_json(
api_base,
"/award/competitor-large-deals",
{
"year": year,
"month": month,
"ct": "",
"scope": "行政区",
"scope_value": district,
},
)
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P7UpdateError("P7 更新失败:目标元素缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _shape_id(shape: ET.Element) -> str | None:
cnv = shape.find("./p:nvSpPr/p:cNvPr", OOXML_NS)
return cnv.attrib.get("id") if cnv is not None else None
def _update_section_titles(root: ET.Element) -> None:
shapes = {_shape_id(shape): shape for shape in root.findall(".//p:sp", OOXML_NS)}
title_by_shape_id = {
"6": TELECOM_TITLE_TEXT,
"7": UNICOM_TITLE_TEXT,
}
missing_ids = sorted(shape_id for shape_id in title_by_shape_id if shape_id not in shapes)
if missing_ids:
raise P7UpdateError(
f"P7 更新失败:第 7 页缺少大单标题形状:{', '.join(missing_ids)}"
)
for shape_id, text in title_by_shape_id.items():
_set_text_nodes(shapes[shape_id], text)
def _table_frames_sorted_by_x(root: ET.Element) -> list[ET.Element]:
frames: list[tuple[int, ET.Element]] = []
for graphic_frame in root.findall(".//p:graphicFrame", OOXML_NS):
table = graphic_frame.find(".//a:tbl", OOXML_NS)
if table is None:
continue
off = graphic_frame.find("./p:xfrm/a:off", OOXML_NS)
x = int(off.attrib.get("x", "0")) if off is not None else 0
frames.append((x, table))
if len(frames) != 2:
raise P7UpdateError("P7 更新失败:第 7 页应包含 2 张大单表。")
return [table for _, table in sorted(frames, key=lambda item: item[0])]
def _update_table(table: ET.Element, rows: list[list[str]], label: str) -> None:
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) != len(rows):
raise P7UpdateError(f"P7 更新失败:第 7 页{label}表格应为 {len(rows)} 行。")
for row_index, row_values in enumerate(rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) != len(row_values):
raise P7UpdateError(
f"P7 更新失败:第 7 页{label}表格第 {row_index + 1} 行应为 4 列。"
)
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def update_p7_pptx(pptx_path: Path, output_path: Path, model: P7UpdateModel) -> None:
if not pptx_path.exists():
raise P7UpdateError(f"P7 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {SLIDE_XML}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P7UpdateError(
f"P7 更新失败:PPTX 缺少必要部件:{', '.join(missing)}"
)
slide_root = ET.fromstring(source.read(SLIDE_XML))
_update_section_titles(slide_root)
telecom_table, unicom_table = _table_frames_sorted_by_x(slide_root)
_update_table(telecom_table, model.tables[TELECOM_SECTION], "电信大单")
_update_table(unicom_table, model.tables[UNICOM_SECTION], "联通大单")
updates = {
SLIDE_XML: ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p7-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Update page 7 of the administrative district service monthly PPTX."
)
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
parser.add_argument(
"--district",
required=True,
help="行政区名称,例如 浦东新区。",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p7_response(args.api_base, args.year, args.month, args.district)
model = build_p7_update_model(response)
update_p7_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,11 @@
"""Standalone page update scripts for administrative district service monthly reports."""
PAGE_MODULES = (
"P1_update",
"P2_update",
"P3_update",
"P4_update",
"P5_update",
"P6_update",
"P7_update",
)
+201
View File
@@ -0,0 +1,201 @@
# 上海市服务月报 PPT 自动更新指南
本目录用于按页更新 `ppt_templates/上海市服务月报.pptx`。目前 9 个页面均已完成独立脚本设计,每一页由一个单文件 Python 脚本负责读取 FastAPI 数据、计算页面口径,并写回 PPTX 中对应的文本、表格、原生图表和内嵌 workbook。
## 核心原则
- 每一页脚本都是独立文件,位于 `ppt_update/shanghai_city/page_updates/P{页码}_update.py`
- 所有数据只来自本机 FastAPI,不读取旧 `scripts/`,不使用 JSON fallback。
- 默认 FastAPI 地址为 `http://127.0.0.1:8000`
- 任一必要接口、字段、占位符、表格、图表或 workbook 缺失时,脚本直接停止并抛出清晰错误。
- 脚本只更新自己负责的页面,其余页面和模板样式尽量保持不变。
- 脚本不额外处理字体、字号、文本框位置或模板版式;需要适配空间时优先压缩生成文案。
- 无法完全自动分析的内容保留双花括号事实占位符,格式为 `{{数据事实;请基于数据进行概括总结分析,凸显数据特征}}` 或同义短提示。
- 可比较字段为 `/` 时,表格单元格显示 `/`;分析描述保留数据事实,并说明可比较数据暂不可得或只写事实占位符。
- 支持输入 PPTX 与输出 PPTX 为同一路径,脚本会用临时文件安全替换。
## 目录结构
```text
market_api/
market_api/ FastAPI 服务
ppt_templates/上海市服务月报.pptx 当前 PPT 模板
ppt_update/
README.md PPT 自动更新模块总入口
shanghai_city/
README.md 本指南
page_updates/
P1_update.py
...
P9_update.py
administrative_district/ 行政区服务月报模块,后续建设
output/ 建议输出目录
```
## 前置条件
1. 启动 FastAPI 服务,并确保本机 `8000` 端口可访问。
2. 用接口探活确认服务正常:
```bash
curl -I http://127.0.0.1:8000/docs
```
3. 建议在项目根目录执行脚本:
```bash
cd /Users/kun/PycharmProjects/market_api
```
## 单页更新用法
每个页面脚本参数一致:
```bash
python3 ppt_update/shanghai_city/page_updates/P1_update.py \
--pptx ppt_templates/上海市服务月报.pptx \
--output output/上海市服务月报-P1更新.pptx \
--year 2026 \
--month 4月 \
--api-base http://127.0.0.1:8000
```
参数说明:
| 参数 | 说明 |
| --- | --- |
| `--pptx` | 输入 PPTX,可以是模板,也可以是上一页更新后的 PPTX |
| `--output` | 输出 PPTX |
| `--year` | 报告年份,例如 `2026` |
| `--month` | 报告月份,例如 `4月` |
| `--api-base` | FastAPI 地址,通常为 `http://127.0.0.1:8000` |
## 整份 PPT 串行更新
如果要得到一份 9 页都更新后的 PPT,可以先更新 P1 输出到一个总文件,再用同一个文件作为后续页面的输入和输出:
```bash
python3 ppt_update/shanghai_city/page_updates/P1_update.py --pptx ppt_templates/上海市服务月报.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P2_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P3_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P4_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P5_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P6_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P7_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P8_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
python3 ppt_update/shanghai_city/page_updates/P9_update.py --pptx output/上海市服务月报-自动更新.pptx --output output/上海市服务月报-自动更新.pptx --year 2026 --month 4月 --api-base http://127.0.0.1:8000
```
## 逐页更新逻辑
| 页面 | 主题 | FastAPI 数据源 | 主要更新内容 |
| --- | --- | --- | --- |
| P1 | 招标总览驾驶舱 | `/tender/overall`, `/tender/industry`, `/tender/region`, `/tender/major-buyers`, `/tender/class`, `/tender/time`, `/tender/customer-139` | 指标卡、增长行业/规模行业、重点区域图表与 workbook、政府/企业 Top5 客户、经营主线事实占位符、季度节奏表 |
| P2 | 累计行业招标分析 | `/tender/industry` | 按累计金额排序的行业表格、行业图表、行业分析文案 |
| P3 | 累计区域招标分析 | `/tender/region` | 按累计金额排序的区域表格、区域图表、区域分析文案 |
| P4 | 单月招标节奏、行业、区域 | `/tender/overall`, `/tender/industry`, `/tender/region` | 单月金额/个数、环比文案、月度行业与区域 Top3 |
| P5 | 政府侧与企业侧招采 Top5 客户、139 客户重点项目 | `/tender/major-buyers`, `/tender/customer-139`, `/tender/customer-139/key-projects` | 政府侧/企业侧两张 Top5 客户表、139 月度摘要、139 重点项目 Top5 表、项目共性事实占位符,标题同步改为 Top5 |
| P6 | 运营商市场中标总览 | `/award/overall`, `/award/industry`, `/award/region` | 累计/单月中标指标、同比/环比、运营商/行业/区域图表 |
| P7 | 累计行业中标分析 | `/award/industry` | 按累计中标金额排序的行业表格、`chart10/chart11`、Top3 行业文案 |
| P8 | 累计区域中标分析 | `/award/region` | 按累计中标金额排序的属地表格、`chart12/chart13`、Top3 区域文案 |
| P9 | 当月友商中标大单 | `/award/competitor-large-deals` | 电信大单与联通大单两张 Top8 明细表 |
## 关键口径
### 招标侧 P1-P5
- `scope=上海`,统计全上海公开市场招标数据。
- P1 使用累计总览、行业、属地、主要客户、项目类型、季度节奏和 139 客户数据;经营主线类内容保留事实占位符,供后置概括。
- P2/P3 使用累计数据,并按金额降序排序;同比字段为 `/` 时,表格保留 `/`,分析描述写事实与待分析提示。
- P4 使用月度数据,月度环比字段为 `/` 时,表格显示 `/`,描述中改为事实描述或“可比较环比数据暂不可得”。
- P5 使用 `/tender/major-buyers`,政府侧与企业侧各取有效 Top5;同时使用 139 客户月度数据和重点项目数据。
- P1/P5 会过滤无意义客户名称,避免 `未知单位``某单位``某公司` 等信息进入 Top 客户表述。
### 中标侧 P6-P9
- CT 口径固定为 `ct=是`
- P6-P8 使用运营商市场中标数据,统计方式固定为 `scope=上海`
- P6 同时读取当前月、去年同期和上月运营商总览,用于累计同比与单月环比;基准为 0 或可比较字段不足时,指标显示 `/` 或在事实占位符中提示可比不足。
- P7 使用 `/award/industry.累计数据`,按三家运营商合计中标金额排序。
- P8 使用 `/award/region.累计数据`,按三家运营商合计中标金额排序。
- P9 只使用当月数据,不做累计、同比或环比;接口返回 `电信大单``联通大单`,阈值为金额大于 300 万元,每张表取 Top8。
## 后置占位符规则
部分内容需要后续智能体基于事实进行概括总结,不在脚本里直接生成最终判断。脚本会保留双花括号,但会先填入数据事实:
```text
{{行业Top3:党政12.16/商客5.47/医卫1.89亿,占比82.7%;请基于数据进行概括总结分析,凸显数据特征}}
```
模板文本框空间较紧的页面会使用短提示或纯事实占位符,例如:
```text
{{移动累计:85个/6.89亿,29.2%}}
{{2026年4月:单月104个/9.79亿,环项+19.54%、额+115.29%;请概括,凸显特征}}
```
这种双花括号不是未更新错误,而是给后续人工或智能体二次修订保留的明确标记。检查残留时应关注 `{{P1...}}``{{P9...}}` 这类模板占位符是否仍存在。
## 模板更新对象
脚本直接写 PPTX 内部 OOXML,主要更新以下对象:
- 文本占位符:替换为脚本生成的摘要或分析文案。
- 原生表格:逐格写入最新字段值,保留原表格样式。
- 原生图表 XML:更新分类、系列名称、数值缓存和图表标题。
- 内嵌 workbook:同步更新图表对应的 Excel 数据。
P9 没有图表,只更新两张表和右侧标题。
## 错误策略
所有页面均采用严格错误策略:
- FastAPI 连接失败、HTTP 非 200、返回值不是 JSON:直接报错。
- 必填数据节点缺失:直接报错。
- 必填字段为空或不是数字:直接报错。
- 排序所需数据不足:直接报错。
- 模板缺少占位符、表格、图表或 workbook:直接报错。
- 不自动补默认值,不静默跳过,不降级到旧数据。
- 同比、环比、份额变化等可比较字段允许为 `/` 的页面,会在表格中原样显示 `/`,在描述中转为事实说明;其他必算字段仍按缺失处理。
这种策略是为了让智能体在数据或模板异常时停下来,而不是生成看似完整但口径错误的月报。
## 测试与验证
运行当前上海市页面测试:
```bash
python3 -m unittest discover -s tests
```
单页测试示例:
```bash
python3 -m unittest tests/test_shanghai_p6_update.py
python3 -m unittest tests/test_shanghai_p7_p8_p9_update.py
```
脚本编译检查示例:
```bash
python3 -m py_compile ppt_update/shanghai_city/page_updates/P{1,2,3,4,5,6,7,8,9}_update.py
```
建议每次修改模板或脚本后至少执行:
1. 对应单页测试或脚本编译检查。
2. P1-P9 全量回归测试,若测试目录存在。
3. 用 FastAPI 真实生成输出 PPTX。
4. 拆包检查无 `{{P1...}}``{{P9...}}` 模板占位符和 `XXXX` 残留;事实占位符可按设计保留。
5. 使用 LibreOffice 或 PowerPoint 渲染核验重点页,确认文本未溢出、表格未扩展、图表/workbook 与表格内容同步。
## 维护建议
- 新增或调整模板占位符时,先改对应测试,再改模板与脚本。
- 页面口径变更时,优先在对应 `P{页码}_update.py` 文件顶部注释和本 README 中同步说明。
- 不要把跨页公共逻辑抽到旧 `scripts/` 中;当前设计要求每页脚本自包含,便于智能体按页理解和执行。
- 如果 FastAPI 接口字段发生变化,先更新页面测试中的样例响应,再修改脚本解析逻辑。
- 如果模板页内图表编号、workbook 编号或表格数量变化,必须同步更新对应页面测试,防止脚本写错对象。
+1
View File
@@ -0,0 +1 @@
"""Shanghai city service monthly report update scripts."""
@@ -0,0 +1,386 @@
from __future__ import annotations
import argparse
import re
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from zipfile import ZipFile
SLIDE_PART_RE = re.compile(r"ppt/slides/slide\d+\.xml$")
PLACEHOLDER_RE = re.compile(r"\{\{([^{}]+)\}\}")
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
class PlaceholderFillError(ValueError):
"""Raised when the deck contains a placeholder without a mapped analysis."""
ANALYSIS_REPLACEMENTS: dict[str, str] = {
"战客属地累计招标金额75亿元、招标次数1532次、金额同比+119.54%;请基于数据进行概括总结分析,凸显数据特征": (
"战客75亿元居区域首位,金额同比翻倍增长,市级重点客户与跨区项目拉动最强。"
),
"浦东属地累计招标金额49.81亿元、招标次数977次、金额同比+195.91%;请基于数据进行概括总结分析,凸显数据特征": (
"浦东49.81亿元、同比+195.91%,核心功能区投资和数字化项目持续释放。"
),
"党政行业累计招标金额63.33亿元、招标次数1149次、金额同比+67.76%;请基于数据进行概括总结分析,凸显数据特征": (
"党政63.33亿元居行业首位,政务治理、数字政府和公共服务仍是需求基本盘。"
),
"交通行业累计招标金额35.99亿元、招标次数371次、金额同比+390.1%;请基于数据进行概括总结分析,凸显数据特征": (
"交通35.99亿元、同比+390.1%,枢纽建设、道路运维和智慧交通需求快速升温。"
),
"上海市道路运输事业发展中心累计招标预算105378.19万元、招标次数7次;请基于数据进行概括总结分析,凸显数据特征": (
"道路运输事业发展中心预算10.54亿元,交通基础设施及运维升级带动显著。"
),
"上海市大数据中心累计招标预算98185.766万元、招标次数98次;请基于数据进行概括总结分析,凸显数据特征": (
"大数据中心预算9.82亿元、98次,政务云、数据底座和一网通办支撑需求活跃。"
),
"规划要点,此处最后由智能体修订": (
"“十五五”服务业突出数智化、标准化、融合化、国际化,生产性服务业和数据要素服务将持续放大。"
),
"政策导向,此处最后由智能体修订": (
"公开政策聚焦服务业扩能提质、城市数字化转型和“人工智能+”,政企云、数据底座、智慧交通/医疗将持续释放项目需求。"
),
"累计金额Top3行业为党政63.33亿元、交通35.99亿元、农业文旅34.51亿元,合计金额133.83亿元、占比53.3%;请基于数据进行概括总结分析,凸显数据特征": (
"党政、交通、农业文旅合计占比53.3%,公共治理、交通基建与文旅农旅成为上半年主线。"
),
"党政行业累计招标金额63.33亿元、招标次数1149次、个数同比-17.58%、金额同比+67.76%;请基于数据进行概括总结分析,凸显数据特征": (
"党政金额居首,次数回落但金额增67.76%,单项目规模提升,数字政府和治理类项目支撑明显。"
),
"交通行业累计招标金额35.99亿元、招标次数371次、个数同比+24.08%、金额同比+390.1%;请基于数据进行概括总结分析,凸显数据特征": (
"交通金额增近4倍且次数同步增长,枢纽交通、道路运维与智慧出行项目集中释放。"
),
"农业文旅行业累计招标金额34.51亿元、招标次数291次、个数同比+18.29%、金额同比+395.02%;请基于数据进行概括总结分析,凸显数据特征": (
"农业文旅34.51亿元、同比+395.02%,文旅融合、乡村场景和公共服务更新热度升温。"
),
"工业能源行业累计招标金额20.55亿元、招标次数627次、个数同比+43.15%、金额同比+689.87%;请基于数据进行概括总结分析,凸显数据特征": (
"工业能源次数、金额双升,金额增幅最高,能源改造、设备更新和工业服务大单拉动显著。"
),
"融合创新行业累计招标金额20.50亿元、招标次数312次、个数同比+149.6%、金额同比+666.63%;请基于数据进行概括总结分析,凸显数据特征": (
"融合创新次数同比+149.6%、金额+666.63%,新技术融合应用正从试点走向规模化采购。"
),
"商客行业累计招标金额20.34亿元、招标次数241次、个数同比-13.93%、金额同比+285.32%;请基于数据进行概括总结分析,凸显数据特征": (
"商客次数回落但金额大增,客户需求向少量大额、综合解决方案型项目集中。"
),
"教育行业累计招标金额17.92亿元、招标次数434次、个数同比-30%、金额同比+75.96%;请基于数据进行概括总结分析,凸显数据特征": (
"教育项目数下降30%但金额增长75.96%,校级信息化和基础设施更新仍有大单支撑。"
),
"医卫行业累计招标金额17.32亿元、招标次数350次、个数同比-37.94%、金额同比+79.59%;请基于数据进行概括总结分析,凸显数据特征": (
"医卫次数降幅较大、金额仍增79.59%,医院扩建、智慧医院和设备更新抬升单体金额。"
),
"金融行业累计招标金额16.04亿元、招标次数902次、个数同比+30.72%、金额同比+54.69%;请基于数据进行概括总结分析,凸显数据特征": (
"金融次数、金额同步增长,网络安全、基础设施和核心系统升级需求保持韧性。"
),
"互联网行业累计招标金额4.65亿元、招标次数143次、个数同比-1.38%、金额同比+45.19%;请基于数据进行概括总结分析,凸显数据特征": (
"互联网规模较小但金额仍增,采购更偏平台能力、云资源和运营服务等结构性需求。"
),
"累计金额Top3属地为战客75.00亿元、浦东49.81亿元、闵行19.09亿元,合计金额143.90亿元、占比56.4%;请基于数据进行概括总结分析,凸显数据特征": (
"战客、浦东、闵行合计占比56.4%,属地贡献头部集中,市级客户和核心功能区拉动明显。"
),
"战客属地累计招标金额75.00亿元、招标次数1532次、个数同比+16.95%、金额同比+119.54%;请基于数据进行概括总结分析,凸显数据特征": (
"战客75.00亿元居首,次数和金额双增,市级重点客户及跨区项目仍是最大增量来源。"
),
"浦东属地累计招标金额49.81亿元、招标次数977次、个数同比+22.28%、金额同比+195.91%;请基于数据进行概括总结分析,凸显数据特征": (
"浦东49.81亿元、金额同比近翻两倍,功能区投资和数字化项目持续外溢。"
),
"闵行属地累计招标金额19.09亿元、招标次数302次、个数同比+1.68%、金额同比+242.13%;请基于数据进行概括总结分析,凸显数据特征": (
"闵行19.09亿元、同比+242.13%,次数基本持平,增长主要来自项目单体规模放大。"
),
"其他属地累计招标金额15.27亿元、招标次数103次、个数同比-43.72%、金额同比+1034.41%;请基于数据进行概括总结分析,凸显数据特征": (
"其他属地次数下降但金额大幅跃升,少数大额项目对当期规模贡献突出。"
),
"崇明属地累计招标金额15.17亿元、招标次数157次、个数同比+153.23%、金额同比+623.68%;请基于数据进行概括总结分析,凸显数据特征": (
"崇明次数、金额均高增,生态文旅、交通和公共服务补短板形成阶段性放量。"
),
"南区属地累计招标金额14.35亿元、招标次数417次、个数同比-22.78%、金额同比+65.68%;请基于数据进行概括总结分析,凸显数据特征": (
"南区金额增长但次数下降,需求结构向中大型项目集中,需关注重点客户深挖。"
),
"宝山属地累计招标金额10.92亿元、招标次数173次、个数同比+24.46%、金额同比+447.61%;请基于数据进行概括总结分析,凸显数据特征": (
"宝山10.92亿元、金额同比+447.61%,工业转型、城市更新和公共设施项目带动明显。"
),
"北区属地累计招标金额10.84亿元、招标次数260次、个数同比-25.71%、金额同比+35.99%;请基于数据进行概括总结分析,凸显数据特征": (
"北区次数回落但金额小幅增长,存量区域以项目质量提升对冲数量下滑。"
),
"松江属地累计招标金额10.67亿元、招标次数259次、个数同比+30.81%、金额同比+356.75%;请基于数据进行概括总结分析,凸显数据特征": (
"松江次数、金额同步增长,制造服务、专业服务和新城建设需求延续。"
),
"奉贤属地累计招标金额10.28亿元、招标次数118次、个数同比-7.81%、金额同比+153.92%;请基于数据进行概括总结分析,凸显数据特征": (
"奉贤次数小幅下降但金额大增,医院扩建等大额民生项目带动明显。"
),
"西区属地累计招标金额9.44亿元、招标次数385次、个数同比-12.7%、金额同比+57.59%;请基于数据进行概括总结分析,凸显数据特征": (
"西区次数下降、金额上升,客户需求由零散采购转向集约化综合项目。"
),
"青浦属地累计招标金额8.32亿元、招标次数114次、个数同比-21.38%、金额同比+237.18%;请基于数据进行概括总结分析,凸显数据特征": (
"青浦金额同比+237.18%,长三角示范区和会展物流相关需求具备支撑。"
),
"金山属地累计招标金额3.41亿元、招标次数52次、个数同比-59.06%、金额同比+323.9%;请基于数据进行概括总结分析,凸显数据特征": (
"金山项目数量明显回落但金额增长,少量能源和产业项目贡献拉动。"
),
"嘉定属地累计招标金额2.60亿元、招标次数94次、个数同比-7.84%、金额同比+31.03%;请基于数据进行概括总结分析,凸显数据特征": (
"嘉定规模较小但保持正增长,汽车产业链和城市服务类需求仍需持续跟踪。"
),
"互拓属地累计招标金额0.03亿元、招标次数10次;请基于数据进行概括总结分析,凸显数据特征": (
"互拓金额基数较低,当前以机会培育和跨域协同跟进为主。"
),
"2026年5月:1138次/73.66亿、个数/金额环比-6.26%/+8.08%;请基于数据进行概括总结分析,凸显数据特征": (
"5月招标1138次、73.66亿元,次数小幅回落但金额环比+8.08%,由大额项目拉动。"
),
"行业Top3:交通22.44/党政10.66/工业能源9.34亿,合计42.44亿/58.2%;请基于数据进行概括总结分析,凸显数据特征": (
"交通、党政、工业能源合计58.2%,行业集中度高,基建交通与工业能源成为增量主轴。"
),
"交通:61次/22.44亿、个数/金额环比-29.07%/+241.57%;请基于数据进行概括总结分析,凸显数据特征": (
"交通金额环比+241.57%但次数下降,少数大额交通项目集中落地。"
),
"党政:258次/10.66亿、个数/金额环比-5.15%/-4.92%;请基于数据进行概括总结分析,凸显数据特征": (
"党政次数和金额均小幅回落,但258次仍居高位,政务类刚性采购保持稳定。"
),
"工业能源:122次/9.34亿、个数/金额环比-15.28%/+153.5%;请基于数据进行概括总结分析,凸显数据特征": (
"工业能源金额环比+153.5%,设备更新、能源改造和工业服务项目放量明显。"
),
"属地Top3:战客20.83/浦东13.58/崇明8.93亿,合计43.34亿/58.8%;请基于数据进行概括总结分析,凸显数据特征": (
"战客、浦东、崇明合计58.8%,单月区域集中度与大额项目依赖度同步提高。"
),
"战客:298次/20.83亿、个数/金额环比-19.02%/-6.26%;请基于数据进行概括总结分析,凸显数据特征": (
"战客金额环比微降但仍达20.83亿元,头部客户基本盘稳固。"
),
"浦东:272次/13.58亿、个数/金额环比+8.37%/+29.44%;请基于数据进行概括总结分析,凸显数据特征": (
"浦东次数和金额双升,核心产业区项目节奏较快,是单月增量的重要来源。"
),
"崇明:37次/8.93亿、个数/金额环比-30.19%/+375.77%;请基于数据进行概括总结分析,凸显数据特征": (
"崇明金额环比+375.77%,低次数高金额特征突出,阶段性大项目贡献明显。"
),
"2026年5月:139客户348次/20.66亿;请基于数据进行概括总结分析,凸显数据特征": (
"5月139客户348次、20.66亿元,医疗、交通、政务数字底座等公共服务场景集中释放。"
),
"139重点项目Top5见右表,最高38288.00万元;请基于项目名称概括重点项目共性内容": (
"集中在医院扩建、东方枢纽配套道路、基础云平台及金融IT设备,体现民生基建和数字底座两条主线"
),
"行业Top3:商客27.38/党政13.81/医卫3.12亿,占比89.2%": (
"商客、党政、医卫贡献89.2%,运营商中标集中在政企和公共服务场景。"
),
"商客:27.38亿,联通80.1%": (
"商客27.38亿元,联通金额占80.1%,大额项目优势突出。"
),
"党政:13.81亿,电信51.1%": (
"党政13.81亿元,电信金额占51.1%,政务场景竞争相对均衡。"
),
"医卫:3.12亿,电信66.2%": (
"医卫3.12亿元,电信金额占66.2%,医疗信息化优势明显。"
),
"区域Top3:战客中心37.45/浦东2.75/闵行1.38亿,占比84.3%": (
"战客中心、浦东、闵行合计84.3%,运营商机会高度集中。"
),
"战客中心:37.45亿,联通67.0%": (
"战客中心37.45亿元,联通占67.0%,头部客户大单拉动。"
),
"浦东:2.75亿,移动84.7%": (
"浦东2.75亿元,移动占84.7%,核心区域单点优势明显。"
),
"闵行:1.38亿,电信53.6%": (
"闵行1.38亿元,电信占53.6%,份额领先但仍有竞争空间。"
),
"2026年1-5月:累计458个/49.66亿,同项+16.24%、额-10.01%;请概括,凸显特征": (
"累计项目数增16.24%但金额降10.01%,中小项目增多,大额项目节奏偏后。"
),
"移动累计:112个/7.44亿,15.0%": (
"移动112个/7.44亿元,占15.0%,项目覆盖有基础但金额份额偏低。"
),
"电信累计:217个/14.43亿,29.1%": (
"电信217个/14.43亿元,占29.1%,项目数优势显著,金额居中。"
),
"联通累计:129个/27.79亿,56.0%": (
"联通129个/27.79亿元,占56.0%,依靠大额项目取得金额领先。"
),
"2026年5月:单月121个/26.05亿,环项+16.35%、额+166.17%;请概括,凸显特征": (
"5月金额环比+166.17%、项目数+16.35%,单月由大额项目强拉动。"
),
"移动单月:27个/0.55亿,2.1%": (
"移动27个/0.55亿元,占2.1%,单月金额贡献偏弱。"
),
"电信单月:57个/1.69亿,6.5%": (
"电信57个/1.69亿元,占6.5%,项目活跃但大额不足。"
),
"联通单月:37个/23.82亿,91.4%": (
"联通37个/23.82亿元,占91.4%,单月几乎由联通大单主导。"
),
"行业Top3:商客27.38/党政13.81/医卫3.12亿,占比89.2%;请基于数据进行概括总结分析,凸显数据特征": (
"商客、党政、医卫合计占89.2%,行业中标强集中,竞争焦点落在政企和民生服务场景。"
),
"商客行业:27.38亿,个数电信41.9%,金额联通80.1%;请基于数据进行概括总结分析,凸显数据特征": (
"商客27.38亿元,电信项目数占41.9%但联通金额占80.1%,联通拿下少数大额项目。"
),
"党政行业:13.81亿,个数电信55.0%,金额电信51.1%;请基于数据进行概括总结分析,凸显数据特征": (
"党政13.81亿元,电信在项目数和金额均过半,政务客户粘性和交付能力形成优势。"
),
"医卫行业:3.12亿,个数电信64.0%,金额电信66.2%;请基于数据进行概括总结分析,凸显数据特征": (
"医卫3.12亿元,电信项目数和金额均领先,医院信息化和网络安全项目适配度较高。"
),
"属地Top3:战客中心37.45/浦东2.75/闵行1.38亿,占比84.3%;请基于数据进行概括总结分析,凸显数据特征": (
"战客中心、浦东、闵行合计84.3%,区域中标高度头部化,资源应优先投向战客及核心城区。"
),
"战客中心:37.45亿,个数电信56.7%,金额联通67.0%;请基于数据进行概括总结分析,凸显数据特征": (
"战客中心37.45亿元,电信项目数领先但联通金额占67.0%,大单获取决定区域格局。"
),
"浦东:2.75亿,个数移动41.3%,金额移动84.7%;请基于数据进行概括总结分析,凸显数据特征": (
"浦东2.75亿元,移动项目数和金额均领先,数字经济核心区具备持续跟进价值。"
),
"闵行:1.38亿,个数电信60.0%,金额电信53.6%;请基于数据进行概括总结分析,凸显数据特征": (
"闵行1.38亿元,电信项目数60%、金额53.6%,在制造和城市服务场景保持领先。"
),
}
SOURCE_NOTES = [
(
"上海市服务业高质量发展解读",
"https://www.shanghai.gov.cn/wzjd/20260601/6e8402b412bb4a2f8969fe0a64e08fd2.html",
),
(
"上海市服务业高质量发展措施",
"https://fgw.sh.gov.cn/fgw_cyfz/20250317/bee76b58d9b74985b89dda953b7e5590.html",
),
(
"上海人工智能与城市数字化转型相关政策检索",
"https://www.shanghai.gov.cn/",
),
]
def _paragraph_text(paragraph: ET.Element) -> str:
return "".join(node.text or "" for node in paragraph.findall(".//a:t", OOXML_NS))
def _replace_paragraph(paragraph: ET.Element) -> int:
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
return 0
original = _paragraph_text(paragraph)
if "{{" not in original:
return 0
missing: list[str] = []
replacement_count = 0
def replace_match(match: re.Match[str]) -> str:
nonlocal replacement_count
placeholder = match.group(1)
if placeholder not in ANALYSIS_REPLACEMENTS:
missing.append(placeholder)
return match.group(0)
replacement_count += 1
return ANALYSIS_REPLACEMENTS[placeholder]
updated = PLACEHOLDER_RE.sub(replace_match, original)
if missing:
raise PlaceholderFillError(
"未配置占位符替换文案:" + "".join(sorted(set(missing)))
)
if replacement_count == 0:
return 0
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
return replacement_count
def _replace_slide_placeholders(xml_bytes: bytes) -> tuple[bytes, int]:
root = ET.fromstring(xml_bytes)
replacement_count = 0
for paragraph in root.findall(".//a:p", OOXML_NS):
replacement_count += _replace_paragraph(paragraph)
return ET.tostring(root, encoding="utf-8", xml_declaration=True), replacement_count
def find_remaining_placeholders(pptx_path: Path) -> list[str]:
remaining: list[str] = []
with ZipFile(pptx_path) as package:
for name in package.namelist():
if not SLIDE_PART_RE.fullmatch(name):
continue
root = ET.fromstring(package.read(name))
for paragraph in root.findall(".//a:p", OOXML_NS):
remaining.extend(PLACEHOLDER_RE.findall(_paragraph_text(paragraph)))
return remaining
def fill_analysis_placeholders(pptx_path: Path, output_path: Path) -> int:
if not pptx_path.exists():
raise PlaceholderFillError(f"输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
actual_output = output_path
if pptx_path.resolve() == output_path.resolve():
handle = tempfile.NamedTemporaryFile(delete=False, suffix=".pptx")
handle.close()
actual_output = Path(handle.name)
replacement_count = 0
try:
with ZipFile(pptx_path, "r") as source, ZipFile(actual_output, "w") as target:
for info in source.infolist():
payload = source.read(info.filename)
if SLIDE_PART_RE.fullmatch(info.filename):
payload, count = _replace_slide_placeholders(payload)
replacement_count += count
target.writestr(info, payload)
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
remaining = find_remaining_placeholders(output_path)
if remaining:
raise PlaceholderFillError(
f"仍有 {len(remaining)} 个占位符未替换:" + "".join(remaining[:5])
)
return replacement_count
def write_source_notes(output_path: Path) -> None:
lines = [
"# 上海市服务月报占位符填充来源说明",
"",
"本文件记录非接口占位符补写时参考的公开政策来源。招投标指标类文案来自 PPTX 已生成的表格、图表和占位符内指标,不再调用 FastAPI。",
"",
]
for title, url in SOURCE_NOTES:
lines.append(f"- {title}: {url}")
lines.append("")
output_path.write_text("\n".join(lines), encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="填充上海市服务月报中的分析性占位符。")
parser.add_argument("--pptx", required=True, type=Path, help="输入 PPTX 文件路径。")
parser.add_argument("--output", required=True, type=Path, help="输出 PPTX 文件路径。")
parser.add_argument(
"--sources-output",
type=Path,
help="可选:输出公开来源说明 Markdown。",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
replacement_count = fill_analysis_placeholders(args.pptx, args.output)
if args.sources_output:
args.sources_output.parent.mkdir(parents=True, exist_ok=True)
write_source_notes(args.sources_output)
print(f"已替换 {replacement_count} 个分析占位符:{args.output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,785 @@
#!/usr/bin/env python3
"""P1 update script draft.
本脚本后续用于单独更新 PPT 第 1 页:上海信息化公开市场招标情况总览。
取数逻辑草案:
- `/tender/overall?year={year}&month={month}&scope=上海`
用于累计招标金额、累计招标次数、同比增幅等总览 KPI。
- `/tender/industry?year={year}&month={month}&scope=上海`
用于增长最强行业、规模最大行业、行业机会卡片等。
- `/tender/region?year={year}&month={month}&scope=上海`
用于重点区域金额 Top 图表和区域经营主线。
- `/tender/major-buyers?year={year}&month={month}&scope=上海`
用于政府侧、企业侧 Top 客户列表。
模板注意事项:
- 当前第 1 页使用短占位符,目的是避免模板阶段破坏版面。
- 一些占位符在 PPT XML 中会被拆成多个 text run,不能只做单个 `a:t` 节点替换。
- 招采节奏表不需要占位符更新。
- 政府侧/企业侧客户区只更新 Top5 客户名称,不再更新规模、个数、占比。
- 经营主线只更新重点区域、核心行业、核心客群;由智能体基于数据生成短判断,双轮驱动不更新。
- “政策洞察”“客户关键人”“存量项目经营”等内容当前不在 P1 自动更新范围内。
第一页占位符和取数说明:
1. 市场总体趋势
- `{{累计金额}}` <= `/tender/overall.累计数据.招标预算金额(亿元)`
- `{{金额同比}}` <= `/tender/overall.累计数据.招标金额同比增幅`
- `{{累计次数}}` <= `/tender/overall.累计数据.招标次数`
- `{{次数同比}}` <= `/tender/overall.累计数据.招标次数同比增幅`
2. 增长最强行业列表 Top5
- `{{行业1增幅}}` 到 `{{行业5增幅}}`
从 `/tender/industry.累计数据` 中按 `金额同比增幅` 排序取 Top5。
- 输出格式:`行业名 + 增幅`,例如 `融合创新 +670%`。
3. 规模最大行业列表 Top5
- `{{行业1金额}}` 到 `{{行业5金额}}`
从 `/tender/industry.累计数据` 中按 `金额(亿元)` 排序取 Top5。
- 输出格式:`行业名 + 金额`,例如 `党政 52.67亿元`。
4. 重点区域图
- 第 1 页图表 `ppt/charts/chart1.xml`
使用 `/tender/region.累计数据`,按 `金额(亿元)` 取 Top10。
5. 政府侧/企业侧客户
- `{{政府侧客户 1}}` 到 `{{政府侧客户 5}}`
取 `/tender/major-buyers.政府侧招标单位` 前 5 名的 `招标单位`。
- `{{企业侧客户 1}}` 到 `{{企业侧客户 5}}`
取 `/tender/major-buyers.企业侧招标单位` 前 5 名的 `招标单位`。
6. 经营主线
- `{{区域1及分析结果}}`、`{{区域2及分析结果}}`
由智能体基于 `/tender/region.累计数据` 生成短判断。
- `{{行业1及分析结果}}`、`{{行业2及分析结果}}`
由智能体基于 `/tender/industry.累计数据` 生成短判断。
- `{{客群1及分析结果}}`、`{{客群2及分析结果}}`
由智能体基于 `/tender/major-buyers` 生成短判断。
"""
from __future__ import annotations
import argparse
import json
import re
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 1
SLIDE_XML = "ppt/slides/slide1.xml"
DATA_SOURCES = [
"/tender/overall",
"/tender/industry",
"/tender/region",
"/tender/major-buyers",
"/tender/class",
"/tender/time",
"/tender/customer-139",
]
UPDATE_TARGETS = [
"累计招标金额、招标次数及同比指标",
"增长最强行业、规模最大行业等行业卡片",
"重点区域金额 Top 图表",
"政府侧与企业侧 Top 客户列表",
"经营主线中的重点区域、核心行业、核心客群",
]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
class P1UpdateError(ValueError):
"""Raised when P1 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class RegionChartData:
categories: list[str]
values: list[float]
@dataclass(frozen=True)
class P1UpdateModel:
replacements: dict[str, str]
region_chart: RegionChartData
quarter_table_rows: list[list[str]]
REQUIRED_ENDPOINTS = (
"/tender/overall",
"/tender/industry",
"/tender/region",
"/tender/major-buyers",
"/tender/class",
"/tender/time",
"/tender/customer-139",
)
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P1UpdateError(f"P1 更新失败:{field_path} 为空,无法更新第一页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P1UpdateError(f"P1 更新失败:{field_path} 不是可计算数字,无法更新第一页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P1UpdateError(f"P1 更新失败:{field_path}={value!r} 不是数字。") from exc
def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return None
return _parse_decimal(raw, field_path)
def _format_growth(value: Any, field_path: str) -> str:
number = _parse_decimal(value, field_path)
sign = "+" if number > 0 else ""
return f"{sign}{_format_decimal(number)}%"
def _format_growth_or_fact(value: Any, field_path: str, fact_text: str) -> str:
number = _parse_optional_decimal(value, field_path)
if number is None:
return fact_text
sign = "+" if number > 0 else ""
return f"{sign}{_format_decimal(number)}%"
def _format_amount(value: Any, field_path: str) -> str:
return _format_decimal(_parse_decimal(value, field_path))
def _format_amount_yi_with_unit(value: Any, field_path: str) -> str:
amount_yi = _parse_decimal(value, field_path).quantize(Decimal("0.01"))
return f"{amount_yi:.2f}亿元"
def _endpoint_response(responses: dict[str, Any], endpoint: str) -> dict[str, Any]:
for key in (endpoint, endpoint.lstrip("/")):
value = responses.get(key)
if value is None:
continue
if not isinstance(value, dict):
raise P1UpdateError(f"P1 更新失败:{endpoint} 响应必须是 JSON 对象。")
return value
raise P1UpdateError(f"P1 更新失败:缺少必需接口 {endpoint} 的响应。")
def _required_dict(response: dict[str, Any], endpoint: str, key: str) -> dict[str, Any]:
value = response.get(key)
if not isinstance(value, dict):
raise P1UpdateError(f"P1 更新失败:{endpoint} 响应缺少 {key},无法更新第一页。")
return value
def _required_rows(response: dict[str, Any], endpoint: str, key: str) -> list[dict[str, Any]]:
value = response.get(key)
if not isinstance(value, list) or not value:
raise P1UpdateError(f"P1 更新失败:{endpoint} 响应缺少 {key},无法更新第一页。")
rows = [row for row in value if isinstance(row, dict)]
if len(rows) != len(value):
raise P1UpdateError(f"P1 更新失败:{endpoint}.{key} 中存在非对象行。")
return rows
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P1UpdateError(f"P1 更新失败:{field_path} 为空,无法更新第一页。")
return text
def _top_rows_by_amount(
rows: list[dict[str, Any]],
amount_key: str,
field_prefix: str,
count: int,
) -> list[dict[str, Any]]:
if len(rows) < count:
raise P1UpdateError(f"P1 更新失败:{field_prefix} 少于 {count} 行,无法更新第一页。")
return sorted(
rows,
key=lambda row: _parse_decimal(row.get(amount_key), f"{field_prefix}.{amount_key}"),
reverse=True,
)[:count]
def _top_industry_growth_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
candidates: list[tuple[int, Decimal, Decimal, dict[str, Any]]] = []
for row in rows:
amount = _parse_decimal(row.get("金额(亿元)"), "/tender/industry.累计数据.金额(亿元)")
growth = _parse_optional_decimal(
row.get("金额同比增幅"),
"/tender/industry.累计数据.金额同比增幅",
)
candidates.append(
(
1 if growth is not None else 0,
growth if growth is not None else Decimal("-999999999"),
amount,
row,
)
)
if len(candidates) < 5:
raise P1UpdateError("P1 更新失败:/tender/industry.累计数据 少于 5 行。")
return [
row
for _has_growth, _growth, _amount, row in sorted(
candidates,
key=lambda item: (item[0], item[1], item[2]),
reverse=True,
)[:5]
]
GENERIC_CUSTOMER_MARKERS = (
"",
"未知",
"未披露",
"匿名",
"不详",
"保密",
"无名",
)
def _is_meaningful_customer_name(value: Any) -> bool:
name = "" if value is None else str(value).strip()
if not name:
return False
return not any(marker in name for marker in GENERIC_CUSTOMER_MARKERS)
def _customer_rows(response: dict[str, Any], section: str) -> list[dict[str, Any]]:
rows = _required_rows(response, "/tender/major-buyers", section)
meaningful_rows = [
row for row in rows if _is_meaningful_customer_name(row.get("招标单位"))
]
if len(meaningful_rows) < 5:
raise P1UpdateError(
f"P1 更新失败:/tender/major-buyers.{section} 过滤无意义客户后少于 5 行。"
)
return meaningful_rows
def _analysis_growth_part(value: Any, field_path: str) -> str | None:
number = _parse_optional_decimal(value, field_path)
if number is None:
return None
sign = "+" if number > 0 else ""
return f"金额同比{sign}{_format_decimal(number)}%"
def _wrap_followup_prompt(data_text: str) -> str:
return f"{{{{{data_text};请基于数据进行概括总结分析,凸显数据特征}}}}"
def _line_for_region(row: dict[str, Any]) -> str:
name = _required_text(row, "属地", "/tender/region.累计数据.属地")
amount = _format_amount(row.get("金额(亿元)"), f"/tender/region.累计数据.{name}.金额(亿元)")
count = str(
int(_parse_decimal(row.get("个数"), f"/tender/region.累计数据.{name}.个数"))
)
parts = [f"{name}属地累计招标金额{amount}亿元", f"招标次数{count}"]
growth = _analysis_growth_part(
row.get("金额同比增幅"),
f"/tender/region.累计数据.{name}.金额同比增幅",
)
if growth is not None:
parts.append(growth)
return _wrap_followup_prompt("".join(parts))
def _line_for_industry(row: dict[str, Any]) -> str:
name = _required_text(row, "行业", "/tender/industry.累计数据.行业")
amount = _format_amount(row.get("金额(亿元)"), f"/tender/industry.累计数据.{name}.金额(亿元)")
count = str(
int(_parse_decimal(row.get("个数"), f"/tender/industry.累计数据.{name}.个数"))
)
parts = [f"{name}行业累计招标金额{amount}亿元", f"招标次数{count}"]
growth = _analysis_growth_part(
row.get("金额同比增幅"),
f"/tender/industry.累计数据.{name}.金额同比增幅",
)
if growth is not None:
parts.append(growth)
return _wrap_followup_prompt("".join(parts))
def _line_for_customer(row: dict[str, Any]) -> str:
name = _required_text(row, "招标单位", "/tender/major-buyers.招标单位")
amount = _format_amount(
row.get("招标预算金额(万元)"),
f"/tender/major-buyers.{name}.招标预算金额(万元)",
)
count = row.get("招标次数")
count_text = "" if count is None else str(count).strip()
if not count_text:
raise P1UpdateError(f"P1 更新失败:/tender/major-buyers.{name}.招标次数 为空。")
return _wrap_followup_prompt(f"{name}累计招标预算{amount}万元、招标次数{count_text}")
def _quarter_parts(label: str) -> tuple[str, str]:
match = re.fullmatch(r"\s*(\d{4})年([1-4])季度\s*", label)
if not match:
raise P1UpdateError(f"P1 更新失败:/tender/time.季度数据.季度={label!r} 格式不正确。")
return f"{match.group(1)}", f"Q{match.group(2)}"
def _quarter_table_rows(response: dict[str, Any]) -> list[list[str]]:
rows = _required_rows(response, "/tender/time", "季度数据")
if len(rows) != 5:
raise P1UpdateError("P1 更新失败:/tender/time.季度数据 必须包含 5 个季度。")
year_headers = ["时间类型"]
quarter_headers = [""]
government_amounts = ["政府"]
enterprise_amounts = ["企业"]
previous_year = ""
for index, row in enumerate(rows, start=1):
label = _required_text(row, "季度", f"/tender/time.季度数据[{index}].季度")
year_label, quarter_label = _quarter_parts(label)
year_headers.append(year_label if year_label != previous_year else "")
quarter_headers.append(quarter_label)
previous_year = year_label
government_amounts.append(
f"{_format_amount(row.get('政府侧招采金额(亿元)'), f'/tender/time.季度数据[{index}].政府侧招采金额(亿元)')}亿"
)
enterprise_amounts.append(
f"{_format_amount(row.get('企业侧招采金额(亿元)'), f'/tender/time.季度数据[{index}].企业侧招采金额(亿元)')}亿"
)
return [year_headers, quarter_headers, government_amounts, enterprise_amounts]
def build_p1_update_model(responses: dict[str, Any]) -> P1UpdateModel:
"""Build all P1 replacements and chart data from FastAPI responses."""
overall = _endpoint_response(responses, "/tender/overall")
industry = _endpoint_response(responses, "/tender/industry")
region = _endpoint_response(responses, "/tender/region")
major_buyers = _endpoint_response(responses, "/tender/major-buyers")
tender_class = _endpoint_response(responses, "/tender/class")
tender_time = _endpoint_response(responses, "/tender/time")
customer_139 = _endpoint_response(responses, "/tender/customer-139")
cumulative_overall = _required_dict(overall, "/tender/overall", "累计数据")
industry_rows = _required_rows(industry, "/tender/industry", "累计数据")
region_rows = _required_rows(region, "/tender/region", "累计数据")
government_rows = _customer_rows(major_buyers, "政府侧招标单位")
enterprise_rows = _customer_rows(major_buyers, "企业侧招标单位")
government_side = _required_dict(tender_class, "/tender/class", "政府侧累计数据")
enterprise_side = _required_dict(tender_class, "/tender/class", "企业侧累计数据")
customer_139_monthly = _required_dict(customer_139, "/tender/customer-139", "月度数据")
growth_rows = _top_industry_growth_rows(industry_rows)
amount_rows = _top_rows_by_amount(industry_rows, "金额(亿元)", "/tender/industry.累计数据", 5)
region_top_rows = _top_rows_by_amount(region_rows, "金额(亿元)", "/tender/region.累计数据", 10)
customer_top_rows = _top_rows_by_amount(
[*government_rows, *enterprise_rows],
"招标预算金额(万元)",
"/tender/major-buyers.合并客户",
2,
)
cumulative_amount = _format_amount(
cumulative_overall.get("招标预算金额(亿元)"),
"/tender/overall.累计数据.招标预算金额(亿元)",
)
cumulative_count = str(cumulative_overall.get("招标次数", "")).strip()
if not cumulative_count:
raise P1UpdateError("P1 更新失败:/tender/overall.累计数据.招标次数 为空。")
replacements = {
"{{累计金额}}": _format_amount(
cumulative_overall.get("招标预算金额(亿元)"),
"/tender/overall.累计数据.招标预算金额(亿元)",
),
"{{金额同比}}": _format_growth_or_fact(
cumulative_overall.get("招标金额同比增幅"),
"/tender/overall.累计数据.招标金额同比增幅",
f"金额{cumulative_amount}亿元",
),
"{{累计次数}}": cumulative_count,
"{{次数同比}}": _format_growth_or_fact(
cumulative_overall.get("招标次数同比增幅"),
"/tender/overall.累计数据.招标次数同比增幅",
f"次数{cumulative_count}",
),
"{{政府侧招标规模万元}} 万": _format_amount_yi_with_unit(
government_side.get("招标预算金额(亿元)"),
"/tender/class.政府侧累计数据.招标预算金额(亿元)",
),
"{{政府侧招标金额占比}}": _required_text(
government_side,
"招标金额占比",
"/tender/class.政府侧累计数据.招标金额占比",
),
"{{政府侧招标个数}}": str(
int(
_parse_decimal(
government_side.get("招标次数"),
"/tender/class.政府侧累计数据.招标次数",
)
)
),
"{{政府侧招标次数占比}}": _required_text(
government_side,
"招标次数占比",
"/tender/class.政府侧累计数据.招标次数占比",
),
"{{企业侧招标规模万元}} 万": _format_amount_yi_with_unit(
enterprise_side.get("招标预算金额(亿元)"),
"/tender/class.企业侧累计数据.招标预算金额(亿元)",
),
"{{企业侧招标金额占比}}": _required_text(
enterprise_side,
"招标金额占比",
"/tender/class.企业侧累计数据.招标金额占比",
),
"{{企业侧招标个数}}": str(
int(
_parse_decimal(
enterprise_side.get("招标次数"),
"/tender/class.企业侧累计数据.招标次数",
)
)
),
"{{企业侧招标次数占比}}": _required_text(
enterprise_side,
"招标次数占比",
"/tender/class.企业侧累计数据.招标次数占比",
),
"{{规模万元}} 万": _format_amount_yi_with_unit(
customer_139_monthly.get("招标预算金额(亿元)"),
"/tender/customer-139.月度数据.招标预算金额(亿元)",
),
"{{招标个数}}": str(
int(
_parse_decimal(
customer_139_monthly.get("招标次数"),
"/tender/customer-139.月度数据.招标次数",
)
)
),
}
for index, row in enumerate(growth_rows, start=1):
name = _required_text(row, "行业", f"/tender/industry.累计数据[{index}].行业")
amount = _format_amount(row.get("金额(亿元)"), f"/tender/industry.累计数据.{name}.金额(亿元)")
count = str(int(_parse_decimal(row.get("个数"), f"/tender/industry.累计数据.{name}.个数")))
growth = _format_growth_or_fact(
row.get("金额同比增幅"),
f"/tender/industry.累计数据.{name}.金额同比增幅",
f"金额{amount}亿元/次数{count}",
)
replacements[f"{{{{行业{index}增幅}}}}"] = f"{name} {growth}"
for index, row in enumerate(amount_rows, start=1):
name = _required_text(row, "行业", f"/tender/industry.累计数据[{index}].行业")
amount = _format_amount(row.get("金额(亿元)"), f"/tender/industry.累计数据.{name}.金额(亿元)")
replacements[f"{{{{行业{index}金额}}}}"] = f"{name} {amount}亿元"
for index, row in enumerate(government_rows[:5], start=1):
replacements[f"{{{{政府侧客户 {index}}}}}"] = _required_text(
row,
"招标单位",
f"/tender/major-buyers.政府侧招标单位[{index}].招标单位",
)
for index, row in enumerate(enterprise_rows[:5], start=1):
replacements[f"{{{{企业侧客户 {index}}}}}"] = _required_text(
row,
"招标单位",
f"/tender/major-buyers.企业侧招标单位[{index}].招标单位",
)
for index, row in enumerate(region_top_rows[:2], start=1):
replacements[f"{{{{区域{index}及分析结果}}}}"] = _line_for_region(row)
for index, row in enumerate(amount_rows[:2], start=1):
replacements[f"{{{{行业{index}及分析结果}}}}"] = _line_for_industry(row)
for index, row in enumerate(customer_top_rows, start=1):
replacements[f"{{{{客群{index}及分析结果}}}}"] = _line_for_customer(row)
return P1UpdateModel(
replacements=replacements,
region_chart=RegionChartData(
categories=[
_required_text(row, "属地", "/tender/region.累计数据.属地")
for row in region_top_rows
],
values=[
float(_parse_decimal(row.get("金额(亿元)"), "/tender/region.累计数据.金额(亿元)"))
for row in region_top_rows
],
),
quarter_table_rows=_quarter_table_rows(tender_time),
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
base = api_base.rstrip("/")
url = f"{base}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P1UpdateError(f"P1 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P1UpdateError(f"P1 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P1UpdateError(f"P1 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P1UpdateError(f"P1 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P1UpdateError(f"P1 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p1_responses(api_base: str, year: int, month: str) -> dict[str, Any]:
params = {"year": year, "month": month, "scope": "上海"}
return {
endpoint: fetch_json(api_base, endpoint, params)
for endpoint in REQUIRED_ENDPOINTS
}
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
def _replace_slide_text(slide_xml: bytes, replacements: dict[str, str]) -> bytes:
root = ET.fromstring(slide_xml)
_replace_slide_text_root(root, replacements)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _replace_slide_text_root(root: ET.Element, replacements: dict[str, str]) -> None:
replaced: set[str] = set()
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
updated = updated.replace(placeholder, value)
replaced.add(placeholder)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(set(replacements) - replaced)
if missing:
raise P1UpdateError(f"P1 更新失败:模板第 1 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
tx_body = parent.find("./a:txBody", OOXML_NS)
if tx_body is None:
tx_body = ET.SubElement(parent, _qn("a", "txBody"))
ET.SubElement(tx_body, _qn("a", "bodyPr"))
ET.SubElement(tx_body, _qn("a", "lstStyle"))
paragraph = tx_body.find("./a:p", OOXML_NS)
if paragraph is None:
paragraph = ET.SubElement(tx_body, _qn("a", "p"))
run = ET.SubElement(paragraph, _qn("a", "r"))
text_node = ET.SubElement(run, _qn("a", "t"))
text_nodes = [text_node]
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_quarter_table(root: ET.Element, table_rows: list[list[str]]) -> None:
tables = root.findall(".//a:tbl", OOXML_NS)
if len(tables) != 1:
raise P1UpdateError("P1 更新失败:第 1 页应包含 1 张招采节奏表。")
xml_rows = tables[0].findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P1UpdateError("P1 更新失败:第 1 页招采节奏表行数不足。")
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P1UpdateError("P1 更新失败:第 1 页招采节奏表列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _update_chart1_xml(chart_xml: bytes, chart_data: RegionChartData) -> bytes:
root = ET.fromstring(chart_xml)
series = root.find(".//c:ser", OOXML_NS)
if series is None:
raise P1UpdateError("P1 更新失败:chart1.xml 中没有找到图表系列。")
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P1UpdateError("P1 更新失败:chart1.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, chart_data.categories)
_set_cache_points(
value_cache,
[_format_decimal(Decimal(str(value))) for value in chart_data.values],
)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook1(workbook_bytes: bytes, chart_data: RegionChartData) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
sheet["A1"] = "属地"
sheet["B1"] = "金额(亿元)"
for row_index, (category, value) in enumerate(
zip(chart_data.categories, chart_data.values, strict=True),
start=2,
):
sheet.cell(row=row_index, column=1).value = category
sheet.cell(row=row_index, column=2).value = value
for row_index in range(len(chart_data.categories) + 2, sheet.max_row + 1):
sheet.cell(row=row_index, column=1).value = None
sheet.cell(row=row_index, column=2).value = None
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p1_pptx(pptx_path: Path, output_path: Path, model: P1UpdateModel) -> None:
if not pptx_path.exists():
raise P1UpdateError(f"P1 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_package_parts = {
"ppt/slides/slide1.xml",
"ppt/charts/chart1.xml",
"ppt/embeddings/Workbook1.xlsx",
}
with ZipFile(pptx_path, "r") as source:
names = set(source.namelist())
missing = sorted(required_package_parts - names)
if missing:
raise P1UpdateError(f"P1 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide1.xml"))
_replace_slide_text_root(slide_root, model.replacements)
_update_quarter_table(slide_root, model.quarter_table_rows)
updates = {
"ppt/slides/slide1.xml": ET.tostring(
slide_root,
encoding="utf-8",
xml_declaration=True,
),
"ppt/charts/chart1.xml": _update_chart1_xml(
source.read("ppt/charts/chart1.xml"),
model.region_chart,
),
"ppt/embeddings/Workbook1.xlsx": _update_workbook1(
source.read("ppt/embeddings/Workbook1.xlsx"),
model.region_chart,
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p1-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 1 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
responses = fetch_p1_responses(args.api_base, args.year, args.month)
model = build_p1_update_model(responses)
update_p1_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
"""P2 update script draft.
本脚本后续用于单独更新 PPT 第 2 页:累计行业招标分析。
取数逻辑草案:
- `/tender/industry?year={year}&month={month}&scope=上海`
使用返回值中的 `累计数据`。
更新内容草案:
- 原生图表:累计行业招标金额与次数分布。
- 表格:各行业累计个数、金额、个数同比增幅、金额同比增幅。
- 文案:根据金额 Top、个数增幅、金额增幅、量价背离等规则生成行业分析。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 2
SLIDE_XML = "ppt/slides/slide2.xml"
DATA_SOURCES = ["/tender/industry"]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
P2_ANALYSIS_PLACEHOLDERS = [
"{{P2行业总览:根据金额Top3、合计占比和整体增长特征生成一句总览}}",
"{{P2行业分析1:按累计金额排序第1行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析2:按累计金额排序第2行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析3:按累计金额排序第3行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析4:按累计金额排序第4行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析5:按累计金额排序第5行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析6:按累计金额排序第6行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析7:按累计金额排序第7行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析8:按累计金额排序第8行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析9:按累计金额排序第9行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
"{{P2行业分析10:按累计金额排序第10行业,介绍行业情况,总结只用一句话说明个数同比、金额同比和特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P2UpdateError(ValueError):
"""Raised when P2 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class IndustryRow:
industry: str
count: int
amount_yi: Decimal
count_growth: Decimal | None
amount_growth: Decimal | None
@dataclass(frozen=True)
class P2UpdateModel:
period_label: str
rows: list[IndustryRow]
table_rows: list[list[str]]
chart_title: str
chart_categories: list[str]
chart_count_values: list[int]
chart_amount_values: list[float]
replacements: dict[str, str]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P2UpdateError(f"P2 更新失败:{field_path} 为空,无法更新第二页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P2UpdateError(f"P2 更新失败:{field_path} 不是可计算数字,无法更新第二页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P2UpdateError(f"P2 更新失败:{field_path}={value!r} 不是数字。") from exc
def _format_growth(value: Decimal) -> str:
sign = "+" if value > 0 else ""
return f"{sign}{_format_decimal(value)}%"
def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return None
return _parse_decimal(raw, field_path)
def _format_optional_growth(value: Decimal | None) -> str:
if value is None:
return "/"
return _format_growth(value)
def _format_amount(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P2UpdateError(f"P2 更新失败:{field_path} 为空,无法更新第二页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P2UpdateError("P2 更新失败:/tender/industry 缺少统计年份或统计月份。")
return f"{year}年1-{month_number}"
def _industry_rows(response: dict[str, Any]) -> list[IndustryRow]:
raw_rows = response.get("累计数据")
if not isinstance(raw_rows, list) or not raw_rows:
raise P2UpdateError("P2 更新失败:/tender/industry 响应缺少 累计数据。")
rows: list[IndustryRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P2UpdateError(f"P2 更新失败:/tender/industry.累计数据[{index}] 不是对象。")
industry = _required_text(raw_row, "行业", f"/tender/industry.累计数据[{index}].行业")
count = int(_parse_decimal(raw_row.get("个数"), f"/tender/industry.累计数据.{industry}.个数"))
rows.append(
IndustryRow(
industry=industry,
count=count,
amount_yi=_parse_decimal(
raw_row.get("金额(亿元)"),
f"/tender/industry.累计数据.{industry}.金额(亿元)",
),
count_growth=_parse_optional_decimal(
raw_row.get("个数同比增幅"),
f"/tender/industry.累计数据.{industry}.个数同比增幅",
),
amount_growth=_parse_optional_decimal(
raw_row.get("金额同比增幅"),
f"/tender/industry.累计数据.{industry}.金额同比增幅",
),
)
)
if len(rows) < 10:
raise P2UpdateError("P2 更新失败:/tender/industry.累计数据 少于 10 行。")
return sorted(rows, key=lambda row: row.amount_yi, reverse=True)[:10]
def _growth_fact_parts(row: IndustryRow) -> list[str]:
parts: list[str] = []
if row.count_growth is not None:
parts.append(f"个数同比{_format_growth(row.count_growth)}")
if row.amount_growth is not None:
parts.append(f"金额同比{_format_growth(row.amount_growth)}")
return parts
def _wrap_followup_prompt(data_text: str) -> str:
return f"{{{{{data_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _summary_text(rows: list[IndustryRow]) -> str:
amount_sorted = sorted(rows, key=lambda row: row.amount_yi, reverse=True)
top3 = amount_sorted[:3]
total_amount = sum((row.amount_yi for row in rows), Decimal("0"))
top3_amount = sum((row.amount_yi for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "".join(f"{row.industry}{_format_amount(row.amount_yi)}亿元" for row in top3)
return _wrap_followup_prompt(
f"累计金额Top3行业为{top_text},合计金额{_format_amount(top3_amount)}亿元、"
f"占比{share.quantize(Decimal('0.1'))}%"
)
def _analysis_text(index: int, row: IndustryRow) -> str:
parts = [
f"{row.industry}行业累计招标金额{_format_amount(row.amount_yi)}亿元",
f"招标次数{row.count}",
*_growth_fact_parts(row),
]
return _wrap_followup_prompt("".join(parts))
def build_p2_update_model(response: dict[str, Any]) -> P2UpdateModel:
period_label = _period_label(response)
rows = _industry_rows(response)
table_rows = [
[period_label, "个数", "金额(亿元)", "个数增幅", "金额增幅"],
*[
[
row.industry,
str(row.count),
_format_amount(row.amount_yi),
_format_optional_growth(row.count_growth),
_format_optional_growth(row.amount_growth),
]
for row in rows
],
]
replacements = {P2_ANALYSIS_PLACEHOLDERS[0]: _summary_text(rows)}
for index, row in enumerate(rows, start=1):
replacements[P2_ANALYSIS_PLACEHOLDERS[index]] = _analysis_text(index, row)
return P2UpdateModel(
period_label=period_label,
rows=rows,
table_rows=table_rows,
chart_title=f"累计行业招标金额与次数分布({period_label}",
chart_categories=[row.industry for row in rows],
chart_count_values=[row.count for row in rows],
chart_amount_values=[float(row.amount_yi) for row in rows],
replacements=replacements,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P2UpdateError(f"P2 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P2UpdateError(f"P2 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P2UpdateError(f"P2 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P2UpdateError(f"P2 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P2UpdateError(f"P2 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p2_response(api_base: str, year: int, month: str) -> dict[str, Any]:
return fetch_json(
api_base,
"/tender/industry",
{"year": year, "month": month, "scope": "上海"},
)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced: set[str] = set()
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
updated = updated.replace(placeholder, value)
replaced.add(placeholder)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(set(replacements) - replaced)
if missing:
raise P2UpdateError(f"P2 更新失败:模板第 2 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P2UpdateError("P2 更新失败:表格单元格缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P2UpdateError("P2 更新失败:第 2 页未找到表格。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P2UpdateError("P2 更新失败:第 2 页表格行数不足。")
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P2UpdateError("P2 更新失败:第 2 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P2UpdateError("P2 更新失败:chart2.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _update_chart2_xml(chart_xml: bytes, model: P2UpdateModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.chart_title)
series_nodes = root.findall(".//c:ser", OOXML_NS)
if len(series_nodes) < 2:
raise P2UpdateError("P2 更新失败:chart2.xml 图表系列少于 2 个。")
series_values = [
[str(value) for value in model.chart_count_values],
[_format_decimal(Decimal(str(value))) for value in model.chart_amount_values],
]
series_names = ["个数", "金额(亿元)"]
for index, series in enumerate(series_nodes[:2]):
name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = series_names[index]
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P2UpdateError("P2 更新失败:chart2.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.chart_categories)
_set_cache_points(value_cache, series_values[index])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook2(workbook_bytes: bytes, model: P2UpdateModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
sheet["A1"] = model.period_label
sheet["B1"] = "个数"
sheet["C1"] = "金额(亿元)"
sheet["D1"] = "金额(万元)"
for row_index, row in enumerate(model.rows, start=2):
sheet.cell(row=row_index, column=1).value = row.industry
sheet.cell(row=row_index, column=2).value = row.count
sheet.cell(row=row_index, column=3).value = float(row.amount_yi)
sheet.cell(row=row_index, column=4).value = float(row.amount_yi * Decimal("10000"))
for row_index in range(len(model.rows) + 2, sheet.max_row + 1):
for column_index in range(1, 5):
sheet.cell(row=row_index, column=column_index).value = None
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p2_pptx(pptx_path: Path, output_path: Path, model: P2UpdateModel) -> None:
if not pptx_path.exists():
raise P2UpdateError(f"P2 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {
"ppt/slides/slide2.xml",
"ppt/charts/chart2.xml",
"ppt/embeddings/Workbook2.xlsx",
}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P2UpdateError(f"P2 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide2.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_table(slide_root, model.table_rows)
updates = {
"ppt/slides/slide2.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
"ppt/charts/chart2.xml": _update_chart2_xml(source.read("ppt/charts/chart2.xml"), model),
"ppt/embeddings/Workbook2.xlsx": _update_workbook2(
source.read("ppt/embeddings/Workbook2.xlsx"),
model,
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p2-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 2 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p2_response(args.api_base, args.year, args.month)
model = build_p2_update_model(response)
update_p2_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""P3 update script draft.
本脚本后续用于单独更新 PPT 第 3 页:累计区域招标分析。
取数逻辑草案:
- `/tender/region?year={year}&month={month}&scope=上海`
使用返回值中的 `累计数据`。
更新内容草案:
- 原生图表:累计区域招标金额与次数分布。
- 表格:各区域累计个数、金额、个数同比增幅、金额同比增幅。
- 文案:根据金额 Top、增速、量价背离等规则生成区域分析。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 3
SLIDE_XML = "ppt/slides/slide3.xml"
DATA_SOURCES = ["/tender/region"]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
P3_ANALYSIS_PLACEHOLDERS = [
"{{P3区域总览:根据金额Top3、合计占比和整体区域集中度生成一句总览}}",
"{{P3区域分析1:按累计金额排序第1属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析2:按累计金额排序第2属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析3:按累计金额排序第3属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析4:按累计金额排序第4属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析5:按累计金额排序第5属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析6:按累计金额排序第6属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析7:按累计金额排序第7属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析8:按累计金额排序第8属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析9:按累计金额排序第9属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析10:按累计金额排序第10属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析11:按累计金额排序第11属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析12:按累计金额排序第12属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析13:按累计金额排序第13属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析14:按累计金额排序第14属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
"{{P3区域分析15:按累计金额排序第15属地,介绍属地情况,总结只用一句话说明个数同比、金额同比和区域特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P3UpdateError(ValueError):
"""Raised when P3 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class RegionRow:
region: str
count: int
amount_yi: Decimal
count_growth: Decimal | None
amount_growth: Decimal | None
@dataclass(frozen=True)
class P3UpdateModel:
period_label: str
rows: list[RegionRow]
table_rows: list[list[str]]
chart_title: str
chart_categories: list[str]
chart_count_values: list[int]
chart_amount_values: list[float]
replacements: dict[str, str]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P3UpdateError(f"P3 更新失败:{field_path} 为空,无法更新第三页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P3UpdateError(f"P3 更新失败:{field_path} 不是可计算数字,无法更新第三页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P3UpdateError(f"P3 更新失败:{field_path}={value!r} 不是数字。") from exc
def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return None
return _parse_decimal(raw, field_path)
def _format_growth(value: Decimal) -> str:
sign = "+" if value > 0 else ""
return f"{sign}{_format_decimal(value)}%"
def _format_optional_growth(value: Decimal | None) -> str:
if value is None:
return "/"
return _format_growth(value)
def _format_amount(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P3UpdateError(f"P3 更新失败:{field_path} 为空,无法更新第三页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P3UpdateError("P3 更新失败:/tender/region 缺少统计年份或统计月份。")
return f"{year}年1-{month_number}"
def _region_rows(response: dict[str, Any]) -> list[RegionRow]:
raw_rows = response.get("累计数据")
if not isinstance(raw_rows, list) or not raw_rows:
raise P3UpdateError("P3 更新失败:/tender/region 响应缺少 累计数据。")
rows: list[RegionRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P3UpdateError(f"P3 更新失败:/tender/region.累计数据[{index}] 不是对象。")
region = _required_text(raw_row, "属地", f"/tender/region.累计数据[{index}].属地")
rows.append(
RegionRow(
region=region,
count=int(_parse_decimal(raw_row.get("个数"), f"/tender/region.累计数据.{region}.个数")),
amount_yi=_parse_decimal(
raw_row.get("金额(亿元)"),
f"/tender/region.累计数据.{region}.金额(亿元)",
),
count_growth=_parse_optional_decimal(
raw_row.get("个数同比增幅"),
f"/tender/region.累计数据.{region}.个数同比增幅",
),
amount_growth=_parse_optional_decimal(
raw_row.get("金额同比增幅"),
f"/tender/region.累计数据.{region}.金额同比增幅",
),
)
)
if len(rows) < 15:
raise P3UpdateError("P3 更新失败:/tender/region.累计数据 少于 15 行。")
return sorted(rows, key=lambda row: row.amount_yi, reverse=True)[:15]
def _growth_fact_parts(row: RegionRow) -> list[str]:
parts: list[str] = []
if row.count_growth is not None:
parts.append(f"个数同比{_format_growth(row.count_growth)}")
if row.amount_growth is not None:
parts.append(f"金额同比{_format_growth(row.amount_growth)}")
return parts
def _wrap_followup_prompt(data_text: str) -> str:
return f"{{{{{data_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _summary_text(rows: list[RegionRow]) -> str:
top3 = rows[:3]
total_amount = sum((row.amount_yi for row in rows), Decimal("0"))
top3_amount = sum((row.amount_yi for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "".join(f"{row.region}{_format_amount(row.amount_yi)}亿元" for row in top3)
return _wrap_followup_prompt(
f"累计金额Top3属地为{top_text},合计金额{_format_amount(top3_amount)}亿元、"
f"占比{share.quantize(Decimal('0.1'))}%"
)
def _analysis_text(index: int, row: RegionRow) -> str:
parts = [
f"{row.region}属地累计招标金额{_format_amount(row.amount_yi)}亿元",
f"招标次数{row.count}",
*_growth_fact_parts(row),
]
return _wrap_followup_prompt("".join(parts))
def build_p3_update_model(response: dict[str, Any]) -> P3UpdateModel:
period_label = _period_label(response)
rows = _region_rows(response)
table_rows = [
[period_label, "个数", "金额(亿元)", "个数增幅", "金额增幅"],
*[
[
row.region,
str(row.count),
_format_amount(row.amount_yi),
_format_optional_growth(row.count_growth),
_format_optional_growth(row.amount_growth),
]
for row in rows
],
]
replacements = {P3_ANALYSIS_PLACEHOLDERS[0]: _summary_text(rows)}
for index, row in enumerate(rows, start=1):
replacements[P3_ANALYSIS_PLACEHOLDERS[index]] = _analysis_text(index, row)
return P3UpdateModel(
period_label=period_label,
rows=rows,
table_rows=table_rows,
chart_title=f"累计区域招标金额与次数分布({period_label}",
chart_categories=[row.region for row in rows],
chart_count_values=[row.count for row in rows],
chart_amount_values=[float(row.amount_yi) for row in rows],
replacements=replacements,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P3UpdateError(f"P3 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P3UpdateError(f"P3 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P3UpdateError(f"P3 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P3UpdateError(f"P3 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P3UpdateError(f"P3 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p3_response(api_base: str, year: int, month: str) -> dict[str, Any]:
return fetch_json(
api_base,
"/tender/region",
{"year": year, "month": month, "scope": "上海"},
)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced: set[str] = set()
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
updated = updated.replace(placeholder, value)
replaced.add(placeholder)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(set(replacements) - replaced)
if missing:
raise P3UpdateError(f"P3 更新失败:模板第 3 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P3UpdateError("P3 更新失败:表格单元格缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P3UpdateError("P3 更新失败:第 3 页未找到表格。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P3UpdateError("P3 更新失败:第 3 页表格行数不足。")
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P3UpdateError("P3 更新失败:第 3 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P3UpdateError("P3 更新失败:chart3.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _update_chart3_xml(chart_xml: bytes, model: P3UpdateModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.chart_title)
series_nodes = root.findall(".//c:ser", OOXML_NS)
if len(series_nodes) < 2:
raise P3UpdateError("P3 更新失败:chart3.xml 图表系列少于 2 个。")
series_values = [
[str(value) for value in model.chart_count_values],
[_format_decimal(Decimal(str(value))) for value in model.chart_amount_values],
]
series_names = ["个数", "金额(亿元)"]
for index, series in enumerate(series_nodes[:2]):
name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = series_names[index]
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P3UpdateError("P3 更新失败:chart3.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.chart_categories)
_set_cache_points(value_cache, series_values[index])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook3(workbook_bytes: bytes, model: P3UpdateModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
sheet["A1"] = model.period_label
sheet["B1"] = "个数"
sheet["C1"] = "金额(亿元)"
sheet["D1"] = "金额(万元)"
for row_index, row in enumerate(model.rows, start=2):
sheet.cell(row=row_index, column=1).value = row.region
sheet.cell(row=row_index, column=2).value = row.count
sheet.cell(row=row_index, column=3).value = float(row.amount_yi)
sheet.cell(row=row_index, column=4).value = float(row.amount_yi * Decimal("10000"))
for row_index in range(len(model.rows) + 2, sheet.max_row + 1):
for column_index in range(1, 5):
sheet.cell(row=row_index, column=column_index).value = None
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p3_pptx(pptx_path: Path, output_path: Path, model: P3UpdateModel) -> None:
if not pptx_path.exists():
raise P3UpdateError(f"P3 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {
"ppt/slides/slide3.xml",
"ppt/charts/chart3.xml",
"ppt/embeddings/Workbook3.xlsx",
}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P3UpdateError(f"P3 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide3.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_table(slide_root, model.table_rows)
updates = {
"ppt/slides/slide3.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
"ppt/charts/chart3.xml": _update_chart3_xml(source.read("ppt/charts/chart3.xml"), model),
"ppt/embeddings/Workbook3.xlsx": _update_workbook3(
source.read("ppt/embeddings/Workbook3.xlsx"),
model,
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p3-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 3 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p3_response(args.api_base, args.year, args.month)
model = build_p3_update_model(response)
update_p3_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""Update slide 4 of the service monthly PPTX.
本脚本用于单独更新 PPT 第 4 页:单月招标节奏、行业、区域。
取数逻辑:
- `/tender/overall?year={year}&month={month}&scope=上海`
使用 `月度数据` 更新单月整体指标。
- `/tender/industry?year={year}&month={month}&scope=上海`
使用 `月度数据` 更新单月行业 Top 表格和文案。
- `/tender/region?year={year}&month={month}&scope=上海`
使用 `月度数据` 更新单月区域 Top 表格和文案。
更新内容:
- 表格:单月行业金额 Top3、单月区域金额 Top3、单月整体指标。
- 文案:根据次数、金额、环比增幅和量价关系生成简洁分析。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from copy import deepcopy
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
PAGE_NUMBER = 4
SLIDE_XML = "ppt/slides/slide4.xml"
DATA_SOURCES = ["/tender/overall", "/tender/industry", "/tender/region"]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P4_PERIOD_PLACEHOLDER = "{{P4统计月份:如2026年4月}}"
P4_ANALYSIS_PLACEHOLDERS = [
"{{P4整体月度分析:说明单月次数、金额、次数环比、金额环比和量价特征}}",
"{{P4行业总览:按月度金额Top3说明金额、合计占比和行业集中度}}",
"{{P4行业分析1:按月度金额排序第1行业,介绍行业情况,说明并总结个数环比、金额环比和特征}}",
"{{P4行业分析2:按月度金额排序第2行业,介绍行业情况,说明并总结个数环比、金额环比和特征}}",
"{{P4行业分析3:按月度金额排序第3行业,介绍行业情况,说明并总结个数环比、金额环比和特征}}",
"{{P4区域总览:按月度金额Top3说明金额、合计占比和区域集中度}}",
"{{P4区域分析1:按月度金额排序第1属地,介绍属地情况,说明并总结个数环比、金额环比和特征}}",
"{{P4区域分析2:按月度金额排序第2属地,介绍属地情况,说明并总结个数环比、金额环比和特征}}",
"{{P4区域分析3:按月度金额排序第3属地,介绍属地情况,说明并总结个数环比、金额环比和特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P4UpdateError(ValueError):
"""Raised when P4 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class MonthlyRow:
name: str
count: int
amount_yi: Decimal
count_growth: Decimal | None
amount_growth: Decimal | None
@dataclass(frozen=True)
class MonthlyOverall:
count: int
amount_yi: Decimal
count_growth: Decimal | None
amount_growth: Decimal | None
@dataclass(frozen=True)
class P4UpdateModel:
period_label: str
overall: MonthlyOverall
industry_rows: list[MonthlyRow]
region_rows: list[MonthlyRow]
overall_table_rows: list[list[str]]
industry_table_rows: list[list[str]]
region_table_rows: list[list[str]]
replacements: dict[str, str]
def _format_amount(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _format_growth(value: Decimal) -> str:
sign = "+" if value > 0 else ""
return f"{sign}{format(value.quantize(Decimal('0.01')).normalize(), 'f')}%"
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P4UpdateError(f"P4 更新失败:{field_path} 为空,无法更新第四页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P4UpdateError(f"P4 更新失败:{field_path} 不是可计算数字,无法更新第四页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P4UpdateError(f"P4 更新失败:{field_path}={value!r} 不是数字。") from exc
def _parse_optional_decimal(value: Any, field_path: str) -> Decimal | None:
raw = "" if value is None else str(value).strip()
if not raw or raw == "/":
return None
return _parse_decimal(raw, field_path)
def _format_optional_growth(value: Decimal | None) -> str:
if value is None:
return "/"
return _format_growth(value)
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P4UpdateError(f"P4 更新失败:{field_path} 为空,无法更新第四页。")
return text
def _period_label(response: dict[str, Any], endpoint: str) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P4UpdateError(f"P4 更新失败:{endpoint} 缺少统计年份或统计月份。")
return f"{year}{month_number}"
def _validate_same_period(
overall_response: dict[str, Any],
industry_response: dict[str, Any],
region_response: dict[str, Any],
) -> str:
periods = {
"/tender/overall": _period_label(overall_response, "/tender/overall"),
"/tender/industry": _period_label(industry_response, "/tender/industry"),
"/tender/region": _period_label(region_response, "/tender/region"),
}
unique_periods = set(periods.values())
if len(unique_periods) != 1:
detail = "".join(f"{endpoint}={period}" for endpoint, period in periods.items())
raise P4UpdateError(f"P4 更新失败:三个接口统计周期不一致:{detail}")
return periods["/tender/overall"]
def _overall(response: dict[str, Any]) -> MonthlyOverall:
raw_monthly = response.get("月度数据")
if not isinstance(raw_monthly, dict):
raise P4UpdateError("P4 更新失败:/tender/overall 响应缺少 月度数据。")
return MonthlyOverall(
count=int(_parse_decimal(raw_monthly.get("招标次数"), "/tender/overall.月度数据.招标次数")),
amount_yi=_parse_decimal(
raw_monthly.get("招标预算金额(亿元)"),
"/tender/overall.月度数据.招标预算金额(亿元)",
),
count_growth=_parse_optional_decimal(
raw_monthly.get("招标次数环比增幅"),
"/tender/overall.月度数据.招标次数环比增幅",
),
amount_growth=_parse_optional_decimal(
raw_monthly.get("招标金额环比增幅"),
"/tender/overall.月度数据.招标金额环比增幅",
),
)
def _monthly_top_rows(response: dict[str, Any], endpoint: str, name_key: str) -> tuple[list[MonthlyRow], Decimal]:
raw_rows = response.get("月度数据")
if not isinstance(raw_rows, list) or not raw_rows:
raise P4UpdateError(f"P4 更新失败:{endpoint} 响应缺少 月度数据。")
sortable_rows: list[tuple[str, int, Decimal, dict[str, Any]]] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P4UpdateError(f"P4 更新失败:{endpoint}.月度数据[{index}] 不是对象。")
name = _required_text(raw_row, name_key, f"{endpoint}.月度数据[{index}].{name_key}")
count = int(_parse_decimal(raw_row.get("个数"), f"{endpoint}.月度数据.{name}.个数"))
amount_yi = _parse_decimal(raw_row.get("金额(亿元)"), f"{endpoint}.月度数据.{name}.金额(亿元)")
sortable_rows.append((name, count, amount_yi, raw_row))
if len(sortable_rows) < 3:
raise P4UpdateError(f"P4 更新失败:{endpoint}.月度数据 少于 3 行。")
total_amount = sum((row[2] for row in sortable_rows), Decimal("0"))
top_rows: list[MonthlyRow] = []
for name, count, amount_yi, raw_row in sorted(sortable_rows, key=lambda row: row[2], reverse=True)[:3]:
top_rows.append(
MonthlyRow(
name=name,
count=count,
amount_yi=amount_yi,
count_growth=_parse_optional_decimal(
raw_row.get("个数环比增幅"),
f"{endpoint}.月度数据.{name}.个数环比增幅",
),
amount_growth=_parse_optional_decimal(
raw_row.get("金额环比增幅"),
f"{endpoint}.月度数据.{name}.金额环比增幅",
),
)
)
return top_rows, total_amount
def _compact_growth_fact(
count_growth: Decimal | None,
amount_growth: Decimal | None,
) -> str:
if count_growth is not None and amount_growth is not None:
return f"个数/金额环比{_format_growth(count_growth)}/{_format_growth(amount_growth)}"
if count_growth is not None:
return f"个数环比{_format_growth(count_growth)}"
if amount_growth is not None:
return f"金额环比{_format_growth(amount_growth)}"
return "可比较环比数据暂不可得"
def _wrap_followup_prompt(data_text: str) -> str:
return f"{{{{{data_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _overall_text(period_label: str, overall: MonthlyOverall) -> str:
parts = [
f"{period_label}{overall.count}次/{_format_amount(overall.amount_yi)}亿",
_compact_growth_fact(overall.count_growth, overall.amount_growth),
]
return _wrap_followup_prompt("".join(parts))
def _summary_text(rows: list[MonthlyRow], total_amount: Decimal, dimension_text: str) -> str:
top3_amount = sum((row.amount_yi for row in rows), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "/".join(f"{row.name}{_format_amount(row.amount_yi)}" for row in rows)
return _wrap_followup_prompt(
f"{dimension_text}Top3{top_text}亿,合计{_format_amount(top3_amount)}亿/"
f"{share.quantize(Decimal('0.1'))}%"
)
def _analysis_text(row: MonthlyRow, dimension_text: str) -> str:
parts = [
f"{row.name}{row.count}次/{_format_amount(row.amount_yi)}亿",
_compact_growth_fact(row.count_growth, row.amount_growth),
]
return _wrap_followup_prompt("".join(parts))
def build_p4_update_model(
overall_response: dict[str, Any],
industry_response: dict[str, Any],
region_response: dict[str, Any],
) -> P4UpdateModel:
period_label = _validate_same_period(overall_response, industry_response, region_response)
overall = _overall(overall_response)
industry_rows, industry_total_amount = _monthly_top_rows(industry_response, "/tender/industry", "行业")
region_rows, region_total_amount = _monthly_top_rows(region_response, "/tender/region", "属地")
overall_table_rows = [
["招标次数", "招标预算金额(亿元)", "招标次数环比(%", "招标金额环比(%"],
[
str(overall.count),
_format_amount(overall.amount_yi),
_format_optional_growth(overall.count_growth),
_format_optional_growth(overall.amount_growth),
],
]
industry_table_rows = [
["行业", "个数", "金额(亿元)", "个数环比", "金额环比"],
*[
[
row.name,
str(row.count),
_format_amount(row.amount_yi),
_format_optional_growth(row.count_growth),
_format_optional_growth(row.amount_growth),
]
for row in industry_rows
],
]
region_table_rows = [
["区域", "个数", "金额(亿元)", "个数环比", "金额环比"],
*[
[
row.name,
str(row.count),
_format_amount(row.amount_yi),
_format_optional_growth(row.count_growth),
_format_optional_growth(row.amount_growth),
]
for row in region_rows
],
]
replacements = {
P4_PERIOD_PLACEHOLDER: period_label,
P4_ANALYSIS_PLACEHOLDERS[0]: _overall_text(period_label, overall),
P4_ANALYSIS_PLACEHOLDERS[1]: _summary_text(industry_rows, industry_total_amount, "行业"),
P4_ANALYSIS_PLACEHOLDERS[5]: _summary_text(region_rows, region_total_amount, "属地"),
}
for index, row in enumerate(industry_rows, start=1):
replacements[P4_ANALYSIS_PLACEHOLDERS[index + 1]] = _analysis_text(row, "行业")
for index, row in enumerate(region_rows, start=1):
replacements[P4_ANALYSIS_PLACEHOLDERS[index + 5]] = _analysis_text(row, "属地")
return P4UpdateModel(
period_label=period_label,
overall=overall,
industry_rows=industry_rows,
region_rows=region_rows,
overall_table_rows=overall_table_rows,
industry_table_rows=industry_table_rows,
region_table_rows=region_table_rows,
replacements=replacements,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P4UpdateError(f"P4 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P4UpdateError(f"P4 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P4UpdateError(f"P4 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P4UpdateError(f"P4 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P4UpdateError(f"P4 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p4_responses(api_base: str, year: int, month: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
params = {"year": year, "month": month, "scope": "上海"}
return (
fetch_json(api_base, "/tender/overall", params),
fetch_json(api_base, "/tender/industry", params),
fetch_json(api_base, "/tender/region", params),
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
required_counts = {placeholder: 1 for placeholder in replacements}
required_counts[P4_PERIOD_PLACEHOLDER] = 3
missing = sorted(
placeholder
for placeholder, required_count in required_counts.items()
if replaced_counts[placeholder] < required_count
)
if missing:
raise P4UpdateError(f"P4 更新失败:模板第 4 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(
parent: ET.Element,
value: str,
template_run_properties: ET.Element | None = None,
) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
tx_body = parent.find("./a:txBody", OOXML_NS)
if tx_body is None:
tx_body = ET.SubElement(parent, _qn("a", "txBody"))
ET.SubElement(tx_body, _qn("a", "bodyPr"))
ET.SubElement(tx_body, _qn("a", "lstStyle"))
paragraph = tx_body.find("./a:p", OOXML_NS)
if paragraph is None:
paragraph = ET.SubElement(tx_body, _qn("a", "p"))
run = ET.SubElement(paragraph, _qn("a", "r"))
if template_run_properties is not None:
run.append(deepcopy(template_run_properties))
text_node = ET.SubElement(run, _qn("a", "t"))
text_nodes = [text_node]
else:
first_run = parent.find(".//a:r", OOXML_NS)
if (
first_run is not None
and first_run.find("./a:rPr", OOXML_NS) is None
and template_run_properties is not None
):
first_run.insert(0, deepcopy(template_run_properties))
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_table(table: ET.Element, table_rows: list[list[str]]) -> None:
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P4UpdateError("P4 更新失败:第 4 页表格行数不足。")
column_run_properties: list[ET.Element | None] = []
if xml_rows:
for cell in xml_rows[0].findall("./a:tc", OOXML_NS):
column_run_properties.append(cell.find(".//a:rPr", OOXML_NS))
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P4UpdateError("P4 更新失败:第 4 页表格列数不足。")
for column_index, (cell, value) in enumerate(zip(cells, row_values, strict=True)):
template_run_properties = (
column_run_properties[column_index]
if column_index < len(column_run_properties)
else None
)
_set_text_nodes(cell, value, template_run_properties)
def _update_slide_tables(root: ET.Element, model: P4UpdateModel) -> None:
tables = root.findall(".//a:tbl", OOXML_NS)
if len(tables) < 3:
raise P4UpdateError("P4 更新失败:第 4 页未找到 3 张表。")
_update_table(tables[0], model.industry_table_rows)
_update_table(tables[1], model.region_table_rows)
_update_table(tables[2], model.overall_table_rows)
def update_p4_pptx(pptx_path: Path, output_path: Path, model: P4UpdateModel) -> None:
if not pptx_path.exists():
raise P4UpdateError(f"P4 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {"ppt/slides/slide4.xml"}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P4UpdateError(f"P4 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide4.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_tables(slide_root, model)
updates = {
"ppt/slides/slide4.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p4-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 4 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
overall_response, industry_response, region_response = fetch_p4_responses(
args.api_base,
args.year,
args.month,
)
model = build_p4_update_model(overall_response, industry_response, region_response)
update_p4_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,656 @@
#!/usr/bin/env python3
"""Update slide 5 of the service monthly PPTX.
本脚本用于单独更新 PPT 第 5 页:政府侧、企业侧 Top5 客户与 139 客户重点项目。
取数逻辑:
- `/tender/major-buyers?year={year}&month={month}&scope=上海`
使用 `政府侧招标单位` 和 `企业侧招标单位` 两组数据。
- `/tender/customer-139?year={year}&month={month}&scope=上海`
使用 `月度数据` 更新 139 客户当月招标总览提示。
- `/tender/customer-139/key-projects?year={year}&month={month}&scope=上海`
使用 `重点项目` 更新月度 139 客户招标重点项目表。
更新内容:
- 标题中的统计周期,并将模板标题 Top10 改为 Top5。
- 两张客户表:政府侧有效 Top5 客户、企业侧有效 Top5 客户。
- 139 客户月度总览与项目共性后置分析占位符。
- 一张重点项目表:月度 139 客户招标重点项目 Top5。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
PAGE_NUMBER = 5
SLIDE_XML = "ppt/slides/slide5.xml"
SLIDE_RELS_XML = "ppt/slides/_rels/slide5.xml.rels"
NOTES_SLIDE_XML = "ppt/notesSlides/notesSlide5.xml"
NOTES_SLIDE_RELS_XML = "ppt/notesSlides/_rels/notesSlide5.xml.rels"
CONTENT_TYPES_XML = "[Content_Types].xml"
DATA_SOURCES = [
"/tender/major-buyers",
"/tender/customer-139",
"/tender/customer-139/key-projects",
]
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
NOTES_SLIDE_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide"
)
NOTES_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
)
SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
NOTES_SLIDE_CONTENT_TYPE = (
"application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"
)
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P5_PERIOD_PLACEHOLDER = "{{P5统计周期:如2026年1-4月}}"
P5_MONTHLY_PERIOD_PLACEHOLDER = "{{P5统计周期:如2026年4月}}"
P5_139_SUMMARY_PLACEHOLDER = "{{P5 139客户月度招标总览:简要描述139客户招标次数、招标预算金额}}"
P5_PROJECT_SUMMARY_PLACEHOLDER = "{{根据项目名称简要概括重点项目共性内容}}"
TOP10_TITLE_TEXT = "Top10客户"
TOP5_TITLE_TEXT = "Top5客户"
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
MEANINGLESS_CUSTOMER_KEYWORDS = ("", "未知", "未披露", "匿名", "不详", "保密", "无名")
TOP_CUSTOMER_COUNT = 5
TOP_PROJECT_COUNT = 5
TOP_PROJECT_NOTE_COUNT = 10
class P5UpdateError(ValueError):
"""Raised when P5 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class BuyerRow:
name: str
count: int
amount_wan: Decimal
@dataclass(frozen=True)
class KeyProjectRow:
buyer: str
project_name: str
amount_wan: Decimal
@dataclass(frozen=True)
class P5UpdateModel:
cumulative_period_label: str
monthly_period_label: str
government_rows: list[BuyerRow]
enterprise_rows: list[BuyerRow]
key_project_rows: list[KeyProjectRow]
government_table_rows: list[list[str]]
enterprise_table_rows: list[list[str]]
key_project_table_rows: list[list[str]]
notes_project_rows: list[KeyProjectRow]
notes_text: str
replacements: dict[str, str]
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P5UpdateError(f"P5 更新失败:{field_path} 为空,无法更新第五页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P5UpdateError(f"P5 更新失败:{field_path} 不是可计算数字,无法更新第五页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P5UpdateError(f"P5 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P5UpdateError(f"P5 更新失败:{field_path} 为空,无法更新第五页。")
return text
def _format_amount_wan(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"
def _format_amount_yi(value: Any, field_path: str) -> str:
return f"{_parse_decimal(value, field_path).quantize(Decimal('0.01'))}"
def _period_labels(response: dict[str, Any], endpoint: str) -> tuple[str, str]:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip()
month_number = month.removesuffix("")
if not year or not month_number:
raise P5UpdateError(f"P5 更新失败:{endpoint} 缺少统计年份或统计月份。")
return f"{year}年1-{month_number}", f"{year}{month_number}"
def _validate_same_period(
major_buyers_response: dict[str, Any],
customer_139_response: dict[str, Any],
key_projects_response: dict[str, Any],
) -> tuple[str, str]:
periods = {
"/tender/major-buyers": _period_labels(major_buyers_response, "/tender/major-buyers"),
"/tender/customer-139": _period_labels(customer_139_response, "/tender/customer-139"),
"/tender/customer-139/key-projects": _period_labels(
key_projects_response,
"/tender/customer-139/key-projects",
),
}
unique_periods = set(periods.values())
if len(unique_periods) != 1:
detail = "".join(
f"{endpoint}={cumulative}/{monthly}"
for endpoint, (cumulative, monthly) in periods.items()
)
raise P5UpdateError(f"P5 更新失败:三个接口统计周期不一致:{detail}")
return periods["/tender/major-buyers"]
def _is_meaningful_customer_name(value: Any) -> bool:
name = "" if value is None else str(value).strip()
if not name:
return False
return not any(keyword in name for keyword in MEANINGLESS_CUSTOMER_KEYWORDS)
def _buyer_rows(response: dict[str, Any], section: str) -> list[BuyerRow]:
raw_rows = response.get(section)
if not isinstance(raw_rows, list) or not raw_rows:
raise P5UpdateError(f"P5 更新失败:/tender/major-buyers.{section} 缺少数据。")
rows: list[BuyerRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P5UpdateError(f"P5 更新失败:/tender/major-buyers.{section}[{index}] 不是对象。")
name = _required_text(
raw_row,
"招标单位",
f"/tender/major-buyers.{section}[{index}].招标单位",
)
if not _is_meaningful_customer_name(name):
continue
rows.append(
BuyerRow(
name=name,
count=int(_parse_decimal(raw_row.get("招标次数"), f"/tender/major-buyers.{section}.{name}.招标次数")),
amount_wan=_parse_decimal(
raw_row.get("招标预算金额(万元)"),
f"/tender/major-buyers.{section}.{name}.招标预算金额(万元)",
),
)
)
if len(rows) < TOP_CUSTOMER_COUNT:
raise P5UpdateError(
f"P5 更新失败:/tender/major-buyers.{section} 过滤无意义客户后少于 {TOP_CUSTOMER_COUNT} 行。"
)
return sorted(rows, key=lambda row: (-row.amount_wan, -row.count, row.name))[
:TOP_CUSTOMER_COUNT
]
def _table_rows(rows: list[BuyerRow]) -> list[list[str]]:
return [
["招标单位", "招标次数", "招标预算金额(万元)"],
*[[row.name, str(row.count), _format_amount_wan(row.amount_wan)] for row in rows],
]
def _key_project_rows(response: dict[str, Any]) -> list[KeyProjectRow]:
raw_rows = response.get("重点项目")
if not isinstance(raw_rows, list) or not raw_rows:
raise P5UpdateError("P5 更新失败:/tender/customer-139/key-projects.重点项目 缺少数据。")
rows: list[KeyProjectRow] = []
for index, raw_row in enumerate(raw_rows, start=1):
if not isinstance(raw_row, dict):
raise P5UpdateError(
f"P5 更新失败:/tender/customer-139/key-projects.重点项目[{index}] 不是对象。"
)
project_name = _required_text(
raw_row,
"项目名称",
f"/tender/customer-139/key-projects.重点项目[{index}].项目名称",
)
rows.append(
KeyProjectRow(
buyer=_required_text(
raw_row,
"招标单位",
f"/tender/customer-139/key-projects.重点项目[{index}].招标单位",
),
project_name=project_name,
amount_wan=_parse_decimal(
raw_row.get("招标预算金额(万元)"),
f"/tender/customer-139/key-projects.重点项目.{project_name}.招标预算金额(万元)",
),
)
)
return sorted(rows, key=lambda row: (-row.amount_wan, row.project_name, row.buyer))
def _key_project_table_rows(rows: list[KeyProjectRow]) -> list[list[str]]:
return [
["月度139客户招标重点项目", "", ""],
["招标单位", "项目名称", "招标预算金额(万元)"],
*[[row.buyer, row.project_name, _format_amount_wan(row.amount_wan)] for row in rows],
]
def _wrap_followup_prompt(data_text: str, suffix: str = FOLLOWUP_PROMPT_SUFFIX) -> str:
return f"{{{{{data_text}{suffix}}}}}"
def _customer_139_summary_text(response: dict[str, Any], monthly_period_label: str) -> str:
monthly = response.get("月度数据")
if not isinstance(monthly, dict):
raise P5UpdateError("P5 更新失败:/tender/customer-139 缺少 月度数据。")
count = int(_parse_decimal(monthly.get("招标次数"), "/tender/customer-139.月度数据.招标次数"))
amount_yi = _format_amount_yi(
monthly.get("招标预算金额(亿元)"),
"/tender/customer-139.月度数据.招标预算金额(亿元)",
)
return _wrap_followup_prompt(f"{monthly_period_label}139客户{count}次/{amount_yi}亿")
def _project_summary_text(rows: list[KeyProjectRow]) -> str:
highest_amount = max((row.amount_wan for row in rows), default=Decimal("0"))
return _wrap_followup_prompt(
f"139重点项目Top{len(rows)}见右表,最高{_format_amount_wan(highest_amount)}万元",
"请基于项目名称概括重点项目共性内容",
)
def _key_project_notes_text(rows: list[KeyProjectRow], monthly_period_label: str) -> str:
lines = [f"139客户招标及重点项目 Top10{monthly_period_label}"]
for index, row in enumerate(rows[:TOP_PROJECT_NOTE_COUNT], start=1):
lines.append(
f"{index}. {row.project_name}{row.buyer}{_format_amount_wan(row.amount_wan)}万元"
)
return "\n".join(lines)
def build_p5_update_model(
major_buyers_response: dict[str, Any],
customer_139_response: dict[str, Any],
key_projects_response: dict[str, Any],
) -> P5UpdateModel:
cumulative_period_label, monthly_period_label = _validate_same_period(
major_buyers_response,
customer_139_response,
key_projects_response,
)
government_rows = _buyer_rows(major_buyers_response, "政府侧招标单位")
enterprise_rows = _buyer_rows(major_buyers_response, "企业侧招标单位")
all_key_project_rows = _key_project_rows(key_projects_response)
key_project_rows = all_key_project_rows[:TOP_PROJECT_COUNT]
notes_project_rows = all_key_project_rows[:TOP_PROJECT_NOTE_COUNT]
return P5UpdateModel(
cumulative_period_label=cumulative_period_label,
monthly_period_label=monthly_period_label,
government_rows=government_rows,
enterprise_rows=enterprise_rows,
key_project_rows=key_project_rows,
government_table_rows=_table_rows(government_rows),
enterprise_table_rows=_table_rows(enterprise_rows),
key_project_table_rows=_key_project_table_rows(key_project_rows),
notes_project_rows=notes_project_rows,
notes_text=_key_project_notes_text(notes_project_rows, monthly_period_label),
replacements={
P5_PERIOD_PLACEHOLDER: cumulative_period_label,
P5_MONTHLY_PERIOD_PLACEHOLDER: monthly_period_label,
P5_139_SUMMARY_PLACEHOLDER: _customer_139_summary_text(
customer_139_response,
monthly_period_label,
),
P5_PROJECT_SUMMARY_PLACEHOLDER: _project_summary_text(key_project_rows),
TOP10_TITLE_TEXT: TOP5_TITLE_TEXT,
},
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P5UpdateError(f"P5 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P5UpdateError(f"P5 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P5UpdateError(f"P5 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P5UpdateError(f"P5 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P5UpdateError(f"P5 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p5_responses(
api_base: str,
year: int,
month: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
params = {"year": year, "month": month, "scope": "上海"}
return (
fetch_json(api_base, "/tender/major-buyers", params),
fetch_json(api_base, "/tender/customer-139", params),
fetch_json(api_base, "/tender/customer-139/key-projects", params),
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
required_counts = {
P5_PERIOD_PLACEHOLDER: 2,
P5_MONTHLY_PERIOD_PLACEHOLDER: 1,
P5_139_SUMMARY_PLACEHOLDER: 1,
P5_PROJECT_SUMMARY_PLACEHOLDER: 1,
TOP10_TITLE_TEXT: 2,
}
missing = sorted(
placeholder
for placeholder, required_count in required_counts.items()
if replaced_counts.get(placeholder, 0) < required_count
)
if missing:
raise P5UpdateError(f"P5 更新失败:模板第 5 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
tx_body = parent.find("./a:txBody", OOXML_NS)
if tx_body is None:
tx_body = ET.SubElement(parent, _qn("a", "txBody"))
ET.SubElement(tx_body, _qn("a", "bodyPr"))
ET.SubElement(tx_body, _qn("a", "lstStyle"))
paragraph = tx_body.find("./a:p", OOXML_NS)
if paragraph is None:
paragraph = ET.SubElement(tx_body, _qn("a", "p"))
run = ET.SubElement(paragraph, _qn("a", "r"))
text_node = ET.SubElement(run, _qn("a", "t"))
text_nodes = [text_node]
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_table(table: ET.Element, table_rows: list[list[str]]) -> None:
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P5UpdateError("P5 更新失败:第 5 页表格行数不足。")
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P5UpdateError("P5 更新失败:第 5 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _update_slide_tables(root: ET.Element, model: P5UpdateModel) -> None:
tables = root.findall(".//a:tbl", OOXML_NS)
if len(tables) < 3:
raise P5UpdateError("P5 更新失败:第 5 页未找到 3 张表。")
_update_table(tables[0], model.government_table_rows)
_update_table(tables[1], model.enterprise_table_rows)
_update_table(tables[2], model.key_project_table_rows)
def _rel_qn(tag: str) -> str:
return f"{{{REL_NS}}}{tag}"
def _ct_qn(tag: str) -> str:
return f"{{{CONTENT_TYPES_NS}}}{tag}"
def _next_relationship_id(root: ET.Element) -> str:
max_id = 0
for relationship in root.findall("./rel:Relationship", {"rel": REL_NS}):
raw_id = relationship.attrib.get("Id", "")
if raw_id.startswith("rId") and raw_id[3:].isdigit():
max_id = max(max_id, int(raw_id[3:]))
return f"rId{max_id + 1}"
def _ensure_slide_notes_relationship(xml_bytes: bytes) -> bytes:
ET.register_namespace("", REL_NS)
root = ET.fromstring(xml_bytes)
for relationship in root.findall("./rel:Relationship", {"rel": REL_NS}):
if relationship.attrib.get("Type") == NOTES_SLIDE_REL_TYPE:
relationship.set("Target", "../notesSlides/notesSlide5.xml")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
relationship = ET.SubElement(root, _rel_qn("Relationship"))
relationship.set("Id", _next_relationship_id(root))
relationship.set("Type", NOTES_SLIDE_REL_TYPE)
relationship.set("Target", "../notesSlides/notesSlide5.xml")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _ensure_notes_slide_content_type(xml_bytes: bytes) -> bytes:
ET.register_namespace("", CONTENT_TYPES_NS)
root = ET.fromstring(xml_bytes)
for override in root.findall("./ct:Override", {"ct": CONTENT_TYPES_NS}):
if override.attrib.get("PartName") == "/ppt/notesSlides/notesSlide5.xml":
override.set("ContentType", NOTES_SLIDE_CONTENT_TYPE)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
override = ET.SubElement(root, _ct_qn("Override"))
override.set("PartName", "/ppt/notesSlides/notesSlide5.xml")
override.set("ContentType", NOTES_SLIDE_CONTENT_TYPE)
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _notes_slide_relationships_xml() -> bytes:
ET.register_namespace("", REL_NS)
root = ET.Element(_rel_qn("Relationships"))
slide_rel = ET.SubElement(root, _rel_qn("Relationship"))
slide_rel.set("Id", "rId1")
slide_rel.set("Type", SLIDE_REL_TYPE)
slide_rel.set("Target", "../slides/slide5.xml")
master_rel = ET.SubElement(root, _rel_qn("Relationship"))
master_rel.set("Id", "rId2")
master_rel.set("Type", NOTES_MASTER_REL_TYPE)
master_rel.set("Target", "../notesMasters/notesMaster1.xml")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _add_notes_paragraph(parent: ET.Element, text: str, bold: bool = False) -> None:
paragraph = ET.SubElement(parent, _qn("a", "p"))
paragraph_properties = ET.SubElement(paragraph, _qn("a", "pPr"))
paragraph_properties.set("lvl", "0")
run = ET.SubElement(paragraph, _qn("a", "r"))
run_properties = ET.SubElement(run, _qn("a", "rPr"))
run_properties.set("lang", "zh-CN")
run_properties.set("altLang", "en-US")
run_properties.set("sz", "1200")
if bold:
run_properties.set("b", "1")
latin = ET.SubElement(run_properties, _qn("a", "latin"))
latin.set("typeface", "仿宋_GB2312")
east_asian = ET.SubElement(run_properties, _qn("a", "ea"))
east_asian.set("typeface", "仿宋_GB2312")
text_node = ET.SubElement(run, _qn("a", "t"))
text_node.text = text
end_run_properties = ET.SubElement(paragraph, _qn("a", "endParaRPr"))
end_run_properties.set("lang", "zh-CN")
end_run_properties.set("altLang", "en-US")
def _notes_slide_xml(notes_text: str) -> bytes:
root = ET.Element(_qn("p", "notes"))
c_sld = ET.SubElement(root, _qn("p", "cSld"))
sp_tree = ET.SubElement(c_sld, _qn("p", "spTree"))
nv_grp = ET.SubElement(sp_tree, _qn("p", "nvGrpSpPr"))
ET.SubElement(nv_grp, _qn("p", "cNvPr"), {"id": "1", "name": ""})
ET.SubElement(nv_grp, _qn("p", "cNvGrpSpPr"))
ET.SubElement(nv_grp, _qn("p", "nvPr"))
grp_sp_pr = ET.SubElement(sp_tree, _qn("p", "grpSpPr"))
xfrm = ET.SubElement(grp_sp_pr, _qn("a", "xfrm"))
ET.SubElement(xfrm, _qn("a", "off"), {"x": "0", "y": "0"})
ET.SubElement(xfrm, _qn("a", "ext"), {"cx": "0", "cy": "0"})
ET.SubElement(xfrm, _qn("a", "chOff"), {"x": "0", "y": "0"})
ET.SubElement(xfrm, _qn("a", "chExt"), {"cx": "0", "cy": "0"})
shape = ET.SubElement(sp_tree, _qn("p", "sp"))
nv_sp_pr = ET.SubElement(shape, _qn("p", "nvSpPr"))
ET.SubElement(nv_sp_pr, _qn("p", "cNvPr"), {"id": "2", "name": "备注占位符 1"})
ET.SubElement(nv_sp_pr, _qn("p", "cNvSpPr"))
nv_pr = ET.SubElement(nv_sp_pr, _qn("p", "nvPr"))
ET.SubElement(nv_pr, _qn("p", "ph"), {"type": "body", "idx": "1"})
sp_pr = ET.SubElement(shape, _qn("p", "spPr"))
shape_xfrm = ET.SubElement(sp_pr, _qn("a", "xfrm"))
ET.SubElement(shape_xfrm, _qn("a", "off"), {"x": "685800", "y": "4400550"})
ET.SubElement(shape_xfrm, _qn("a", "ext"), {"cx": "5486400", "cy": "3600450"})
ET.SubElement(sp_pr, _qn("a", "prstGeom"), {"prst": "rect"}).append(
ET.Element(_qn("a", "avLst"))
)
tx_body = ET.SubElement(shape, _qn("p", "txBody"))
ET.SubElement(
tx_body,
_qn("a", "bodyPr"),
{"lIns": "91440", "tIns": "45720", "rIns": "91440", "bIns": "45720"},
)
ET.SubElement(tx_body, _qn("a", "lstStyle"))
for index, line in enumerate(notes_text.splitlines()):
_add_notes_paragraph(tx_body, line, bold=index == 0)
clr_map_ovr = ET.SubElement(root, _qn("p", "clrMapOvr"))
ET.SubElement(clr_map_ovr, _qn("a", "masterClrMapping"))
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def update_p5_pptx(pptx_path: Path, output_path: Path, model: P5UpdateModel) -> None:
if not pptx_path.exists():
raise P5UpdateError(f"P5 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {CONTENT_TYPES_XML, SLIDE_XML, SLIDE_RELS_XML}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P5UpdateError(f"P5 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read(SLIDE_XML))
_replace_slide_text(slide_root, model.replacements)
_update_slide_tables(slide_root, model)
updates = {
CONTENT_TYPES_XML: _ensure_notes_slide_content_type(source.read(CONTENT_TYPES_XML)),
SLIDE_XML: ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
SLIDE_RELS_XML: _ensure_slide_notes_relationship(source.read(SLIDE_RELS_XML)),
NOTES_SLIDE_XML: _notes_slide_xml(model.notes_text),
NOTES_SLIDE_RELS_XML: _notes_slide_relationships_xml(),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p5-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
existing_names = set(source.namelist())
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
for filename, payload in updates.items():
if filename not in existing_names:
target.writestr(filename, payload)
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 5 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
major_buyers_response, customer_139_response, key_projects_response = fetch_p5_responses(
args.api_base,
args.year,
args.month,
)
model = build_p5_update_model(
major_buyers_response,
customer_139_response,
key_projects_response,
)
update_p5_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,753 @@
#!/usr/bin/env python3
"""Update slide 6 of the service monthly PPTX.
本脚本用于单独更新 PPT 第 6 页:运营商市场中标总览。
取数逻辑:
- `/award/overall?year={year}&month={month}&ct=是&scope=上海`
使用 `累计数据` 和 `月度数据` 更新运营商 KPI、正文和 4 张运营商图表。
- `/award/industry?year={year}&month={month}&ct=是&scope=上海`
使用 `累计数据.金额数据` 更新行业规模图表与文案。
- `/award/region?year={year}&month={month}&ct=是&scope=上海`
使用 `累计数据.金额数据` 更新区域机会图表与文案。
- `/award/overall?year={year - 1}&month={month}&ct=是&scope=上海`
作为累计同比基准。
- `/award/overall?year={previous_year}&month={previous_month}&ct=是&scope=上海`
作为单月环比基准。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 6
SLIDE_XML = "ppt/slides/slide6.xml"
DATA_SOURCES = ["/award/overall", "/award/industry", "/award/region"]
OPERATORS = ("移动", "电信", "联通")
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P6_TEXT_PLACEHOLDERS = [
"{{P6累计统计周期:如2026年1-4月}}",
"{{P6月度统计周期:如2026年4月}}",
"{{P6累计中标金额亿}}",
"{{P6累计金额同比}}",
"{{P6累计中标项目数}}",
"{{P6累计项目数同比}}",
"{{P6单月中标金额亿}}",
"{{P6单月金额环比}}",
"{{P6单月中标项目数}}",
"{{P6单月项目数环比}}",
"{{P6累计总览:说明累计金额、项目数同比和整体特征}}",
"{{P6累计移动分析:简要说明总结移动特征}}",
"{{P6累计电信分析:简要说明总结电信特征}}",
"{{P6累计联通分析:简要说明总结联通特征}}",
"{{P6月度总览:说明单月金额、项目数环比和整体特征}}",
"{{P6月度移动分析:简要说明总结移动特征}}",
"{{P6月度电信分析:简要说明总结电信特征}}",
"{{P6月度联通分析:简要说明总结联通特征}}",
"{{P6行业总览:按累计中标金额Top3说明行业规模和集中度}}",
"{{P6行业分析1:按累计中标金额排序第1行业说明运营商竞争格局}}",
"{{P6行业分析2:按累计中标金额排序第2行业说明运营商竞争格局}}",
"{{P6行业分析3:按累计中标金额排序第3行业说明运营商竞争格局}}",
"{{P6区域总览:按累计中标金额Top3说明区域机会和集中度}}",
"{{P6区域分析1:按累计中标金额排序第1属地说明运营商竞争格局}}",
"{{P6区域分析2:按累计中标金额排序第2属地说明运营商竞争格局}}",
"{{P6区域分析3:按累计中标金额排序第3属地说明运营商竞争格局}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请概括,凸显特征"
COMPACT_FOLLOWUP_PROMPT_SUFFIX = ""
class P6UpdateError(ValueError):
"""Raised when P6 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class OperatorMetric:
operator: str
count: int
amount_wan: Decimal
count_share: str
amount_share: str
count_change_pp: str
amount_change_pp: str
@dataclass(frozen=True)
class SegmentAmountRow:
name: str
total_amount_wan: Decimal
operator_amounts: dict[str, Decimal]
@dataclass(frozen=True)
class ChartModel:
title: str
series_name: str
categories: list[str]
values: list[int | float]
@dataclass(frozen=True)
class P6UpdateModel:
cumulative_period_label: str
monthly_period_label: str
cumulative_total_count: int
cumulative_total_amount_wan: Decimal
monthly_total_count: int
monthly_total_amount_wan: Decimal
replacements: dict[str, str]
charts: dict[str, ChartModel]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P6UpdateError(f"P6 更新失败:{field_path} 为空,无法更新第六页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P6UpdateError(f"P6 更新失败:{field_path} 不是可计算数字,无法更新第六页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P6UpdateError(f"P6 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P6UpdateError(f"P6 更新失败:{field_path} 为空,无法更新第六页。")
return text
def _month_number(response: dict[str, Any], endpoint: str) -> int:
raw_month = str(response.get("统计月份", "")).strip().removesuffix("")
if not raw_month:
raise P6UpdateError(f"P6 更新失败:{endpoint} 缺少统计月份。")
try:
return int(raw_month)
except ValueError as exc:
raise P6UpdateError(f"P6 更新失败:{endpoint}.统计月份={raw_month!r} 不是月份。") from exc
def _period_labels(response: dict[str, Any]) -> tuple[str, str]:
year = response.get("统计年份")
month_number = _month_number(response, "/award/overall")
if not year:
raise P6UpdateError("P6 更新失败:/award/overall 缺少统计年份。")
return f"{year}年1-{month_number}", f"{year}{month_number}"
def _growth_percent(current: Decimal, baseline: Decimal, field_path: str) -> Decimal | None:
if baseline == 0:
return None
return (current - baseline) / baseline * Decimal("100")
def _format_growth(value: Decimal | None) -> str:
if value is None:
return "/"
sign = "+" if value > 0 else ""
return f"{sign}{value.quantize(Decimal('0.01'))}%"
def _format_pp_change(value: str) -> str:
if value.strip() == "/":
return "/"
decimal_value = _parse_decimal(value, "运营商份额变化")
sign = "+" if decimal_value > 0 else ""
return f"{sign}{_format_decimal(decimal_value)}pp"
def _format_yi_from_wan(value: Decimal) -> str:
return f"{_format_yi_number_from_wan(value)}亿"
def _format_yi_number_from_wan(value: Decimal) -> str:
return str((value / Decimal("10000")).quantize(Decimal("0.01")))
def _wrap_followup_prompt(fact_text: str, *, compact: bool = False) -> str:
suffix = COMPACT_FOLLOWUP_PROMPT_SUFFIX if compact else FOLLOWUP_PROMPT_SUFFIX
if not suffix:
return f"{{{{{fact_text}}}}}"
return f"{{{{{fact_text}{suffix}}}}}"
def _ensure_operator_rows(rows: Any, section_path: str) -> list[dict[str, Any]]:
if not isinstance(rows, list) or not rows:
raise P6UpdateError(f"P6 更新失败:{section_path} 缺少数据。")
by_operator: dict[str, dict[str, Any]] = {}
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise P6UpdateError(f"P6 更新失败:{section_path}[{index}] 不是对象。")
operator = _required_text(row, "运营商", f"{section_path}[{index}].运营商")
by_operator[operator] = row
missing = [operator for operator in OPERATORS if operator not in by_operator]
if missing:
raise P6UpdateError(f"P6 更新失败:{section_path} 缺少运营商:{', '.join(missing)}")
return [by_operator[operator] for operator in OPERATORS]
def _operator_metrics(
response: dict[str, Any],
section_key: str,
count_key: str,
amount_key: str,
count_change_key: str,
amount_change_key: str,
) -> list[OperatorMetric]:
raw_rows = _ensure_operator_rows(response.get(section_key), f"/award/overall.{section_key}")
metrics: list[OperatorMetric] = []
for row in raw_rows:
operator = _required_text(row, "运营商", "/award/overall.运营商")
metrics.append(
OperatorMetric(
operator=operator,
count=int(_parse_decimal(row.get(count_key), f"/award/overall.{section_key}.{operator}.{count_key}")),
amount_wan=_parse_decimal(row.get(amount_key), f"/award/overall.{section_key}.{operator}.{amount_key}"),
count_share=_required_text(row, "个数份额", f"/award/overall.{section_key}.{operator}.个数份额"),
amount_share=_required_text(row, "金额份额", f"/award/overall.{section_key}.{operator}.金额份额"),
count_change_pp=_format_pp_change(
_required_text(row, count_change_key, f"/award/overall.{section_key}.{operator}.{count_change_key}")
),
amount_change_pp=_format_pp_change(
_required_text(row, amount_change_key, f"/award/overall.{section_key}.{operator}.{amount_change_key}")
),
)
)
return metrics
def _operator_totals(
response: dict[str, Any],
section_key: str,
count_key: str,
amount_key: str,
source_label: str,
) -> tuple[int, Decimal]:
raw_rows = _ensure_operator_rows(response.get(section_key), f"{source_label}.{section_key}")
total_count = 0
total_amount = Decimal("0")
for row in raw_rows:
operator = _required_text(row, "运营商", f"{source_label}.{section_key}.运营商")
total_count += int(_parse_decimal(row.get(count_key), f"{source_label}.{section_key}.{operator}.{count_key}"))
total_amount += _parse_decimal(row.get(amount_key), f"{source_label}.{section_key}.{operator}.{amount_key}")
return total_count, total_amount
def _operator_phrase(metrics: list[OperatorMetric], operator: str, period_type: str, comparison_label: str) -> str:
row = next(metric for metric in metrics if metric.operator == operator)
return _wrap_followup_prompt(
f"{operator}{period_type}{row.count}个/{_format_yi_from_wan(row.amount_wan)}{row.amount_share}",
compact=True,
)
def _relationship_phrase(count_growth: Decimal | None, amount_growth: Decimal | None) -> str:
if count_growth is None or amount_growth is None:
return "可比较字段不足,需后续结合数据补充判断"
if count_growth >= 0 and amount_growth >= 0:
if amount_growth > count_growth * Decimal("2"):
return "金额增速高于项目数增速,大单驱动特征明显"
return "项目数与金额同步增长,市场保持稳健扩张"
if count_growth < 0 <= amount_growth:
return "项目数承压但金额增长,项目单体规模抬升"
if count_growth >= 0 > amount_growth:
return "项目数增长但金额回落,单体规模有所收缩"
return "项目数与金额同步回落,市场阶段性承压"
def _overall_summary(
period_label: str,
total_count: int,
total_amount_wan: Decimal,
count_growth: Decimal | None,
amount_growth: Decimal | None,
comparison_label: str,
) -> str:
period_type = "单月" if comparison_label == "环比" else "累计"
comparison_short = comparison_label[:1]
count_comparison = (
f"{_format_growth(count_growth)}"
if count_growth is not None
else "项可比不足"
)
amount_comparison = (
f"{_format_growth(amount_growth)}"
if amount_growth is not None
else "额可比不足"
)
return _wrap_followup_prompt(
f"{period_label}{period_type}{total_count}个/{_format_yi_from_wan(total_amount_wan)}"
f"{comparison_short}{count_comparison}{amount_comparison}"
)
def _segment_rows(response: dict[str, Any], dimension_key: str, endpoint: str) -> list[SegmentAmountRow]:
raw_amount_rows = response.get("累计数据", {}).get("金额数据") if isinstance(response.get("累计数据"), dict) else None
if not isinstance(raw_amount_rows, list) or not raw_amount_rows:
raise P6UpdateError(f"P6 更新失败:{endpoint}.累计数据.金额数据 缺少数据。")
rows: list[SegmentAmountRow] = []
for index, raw_row in enumerate(raw_amount_rows, start=1):
if not isinstance(raw_row, dict):
raise P6UpdateError(f"P6 更新失败:{endpoint}.累计数据.金额数据[{index}] 不是对象。")
name = _required_text(raw_row, dimension_key, f"{endpoint}.累计数据.金额数据[{index}].{dimension_key}")
operator_amounts = {
operator: _parse_decimal(
raw_row.get(f"{operator}金额(万元)"),
f"{endpoint}.累计数据.金额数据.{name}.{operator}金额(万元)",
)
for operator in OPERATORS
}
rows.append(
SegmentAmountRow(
name=name,
total_amount_wan=sum(operator_amounts.values(), Decimal("0")),
operator_amounts=operator_amounts,
)
)
return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)
def _dominant_operator(row: SegmentAmountRow) -> tuple[str, Decimal]:
operator, amount = max(row.operator_amounts.items(), key=lambda item: item[1])
share = Decimal("0") if row.total_amount_wan == 0 else amount / row.total_amount_wan * Decimal("100")
return operator, share
def _segment_summary(rows: list[SegmentAmountRow], label: str) -> str:
top3 = rows[:3]
total_amount = sum((row.total_amount_wan for row in rows), Decimal("0"))
top3_amount = sum((row.total_amount_wan for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "/".join(f"{row.name}{_format_yi_number_from_wan(row.total_amount_wan)}" for row in top3)
return _wrap_followup_prompt(
f"{label}Top3{top_text}亿,占比{share.quantize(Decimal('0.1'))}%",
compact=True,
)
def _segment_analysis(index: int, row: SegmentAmountRow) -> str:
operator, share = _dominant_operator(row)
return _wrap_followup_prompt(
f"{row.name}{_format_yi_from_wan(row.total_amount_wan)}{operator}{share.quantize(Decimal('0.1'))}%",
compact=True,
)
def _chart_values_from_amount_rows(rows: list[SegmentAmountRow]) -> list[float]:
return [float(row.total_amount_wan) for row in rows]
def build_p6_update_model(
current_overall_response: dict[str, Any],
industry_response: dict[str, Any],
region_response: dict[str, Any],
prior_year_overall_response: dict[str, Any],
previous_month_overall_response: dict[str, Any],
) -> P6UpdateModel:
cumulative_period_label, monthly_period_label = _period_labels(current_overall_response)
cumulative_metrics = _operator_metrics(
current_overall_response,
"累计数据",
"累计中标个数",
"累计中标金额(万元)",
"个数份额同比变化(百分点)",
"金额份额同比变化(百分点)",
)
monthly_metrics = _operator_metrics(
current_overall_response,
"月度数据",
"单月中标个数",
"单月中标金额(万元)",
"个数份额环比变化(百分点)",
"金额份额环比变化(百分点)",
)
cumulative_total_count = sum(metric.count for metric in cumulative_metrics)
cumulative_total_amount = sum((metric.amount_wan for metric in cumulative_metrics), Decimal("0"))
monthly_total_count = sum(metric.count for metric in monthly_metrics)
monthly_total_amount = sum((metric.amount_wan for metric in monthly_metrics), Decimal("0"))
prior_count, prior_amount = _operator_totals(
prior_year_overall_response,
"累计数据",
"累计中标个数",
"累计中标金额(万元)",
"/award/overall.去年同期",
)
previous_count, previous_amount = _operator_totals(
previous_month_overall_response,
"月度数据",
"单月中标个数",
"单月中标金额(万元)",
"/award/overall.上月",
)
cumulative_count_growth = _growth_percent(
Decimal(cumulative_total_count),
Decimal(prior_count),
"/award/overall.去年同期.累计中标个数",
)
cumulative_amount_growth = _growth_percent(
cumulative_total_amount,
prior_amount,
"/award/overall.去年同期.累计中标金额(万元)",
)
monthly_count_growth = _growth_percent(
Decimal(monthly_total_count),
Decimal(previous_count),
"/award/overall.上月.单月中标个数",
)
monthly_amount_growth = _growth_percent(
monthly_total_amount,
previous_amount,
"/award/overall.上月.单月中标金额(万元)",
)
industry_rows = _segment_rows(industry_response, "行业", "/award/industry")
region_rows = _segment_rows(region_response, "属地", "/award/region")
replacements = {
P6_TEXT_PLACEHOLDERS[0]: cumulative_period_label,
P6_TEXT_PLACEHOLDERS[1]: monthly_period_label,
P6_TEXT_PLACEHOLDERS[2]: _format_yi_from_wan(cumulative_total_amount),
P6_TEXT_PLACEHOLDERS[3]: _format_growth(cumulative_amount_growth),
P6_TEXT_PLACEHOLDERS[4]: f"{cumulative_total_count}",
P6_TEXT_PLACEHOLDERS[5]: _format_growth(cumulative_count_growth),
P6_TEXT_PLACEHOLDERS[6]: _format_yi_from_wan(monthly_total_amount),
P6_TEXT_PLACEHOLDERS[7]: _format_growth(monthly_amount_growth),
P6_TEXT_PLACEHOLDERS[8]: f"{monthly_total_count}",
P6_TEXT_PLACEHOLDERS[9]: _format_growth(monthly_count_growth),
P6_TEXT_PLACEHOLDERS[10]: _overall_summary(
cumulative_period_label,
cumulative_total_count,
cumulative_total_amount,
cumulative_count_growth,
cumulative_amount_growth,
"同比",
),
P6_TEXT_PLACEHOLDERS[14]: _overall_summary(
monthly_period_label,
monthly_total_count,
monthly_total_amount,
monthly_count_growth,
monthly_amount_growth,
"环比",
),
P6_TEXT_PLACEHOLDERS[18]: _segment_summary(industry_rows, "行业"),
P6_TEXT_PLACEHOLDERS[22]: _segment_summary(region_rows, "区域"),
}
for index, operator in enumerate(OPERATORS):
replacements[P6_TEXT_PLACEHOLDERS[11 + index]] = _operator_phrase(
cumulative_metrics,
operator,
"累计",
"同比",
)
replacements[P6_TEXT_PLACEHOLDERS[15 + index]] = _operator_phrase(
monthly_metrics,
operator,
"单月",
"环比",
)
for index, row in enumerate(industry_rows[:3], start=1):
replacements[P6_TEXT_PLACEHOLDERS[18 + index]] = _segment_analysis(index, row)
for index, row in enumerate(region_rows[:3], start=1):
replacements[P6_TEXT_PLACEHOLDERS[22 + index]] = _segment_analysis(index, row)
charts = {
"chart4": ChartModel(
title=f"各运营商累计中标个数对比({cumulative_period_label}",
series_name="累计中标个数",
categories=[metric.operator for metric in cumulative_metrics],
values=[metric.count for metric in cumulative_metrics],
),
"chart5": ChartModel(
title=f"各运营商累计中标金额对比(万元,{cumulative_period_label}",
series_name="累计中标金额(万元)",
categories=[metric.operator for metric in cumulative_metrics],
values=[float(metric.amount_wan) for metric in cumulative_metrics],
),
"chart6": ChartModel(
title=f"各运营商单月中标个数对比({monthly_period_label}",
series_name="单月中标个数",
categories=[metric.operator for metric in monthly_metrics],
values=[metric.count for metric in monthly_metrics],
),
"chart7": ChartModel(
title=f"各运营商单月中标金额对比(万元,{monthly_period_label}",
series_name="单月中标金额(万元)",
categories=[metric.operator for metric in monthly_metrics],
values=[float(metric.amount_wan) for metric in monthly_metrics],
),
"chart8": ChartModel(
title=f"运营商各行业累计中标金额分布({cumulative_period_label}",
series_name="累计中标金额(万元)",
categories=[row.name for row in industry_rows],
values=_chart_values_from_amount_rows(industry_rows),
),
"chart9": ChartModel(
title=f"累计区域中标金额分布({cumulative_period_label}",
series_name="累计中标金额(万元)",
categories=[row.name for row in region_rows],
values=_chart_values_from_amount_rows(region_rows),
),
}
return P6UpdateModel(
cumulative_period_label=cumulative_period_label,
monthly_period_label=monthly_period_label,
cumulative_total_count=cumulative_total_count,
cumulative_total_amount_wan=cumulative_total_amount,
monthly_total_count=monthly_total_count,
monthly_total_amount_wan=monthly_total_amount,
replacements=replacements,
charts=charts,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P6UpdateError(f"P6 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P6UpdateError(f"P6 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P6UpdateError(f"P6 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P6UpdateError(f"P6 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P6UpdateError(f"P6 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def _previous_month(year: int, month: int) -> tuple[int, int]:
if month == 1:
return year - 1, 12
return year, month - 1
def fetch_p6_responses(
api_base: str,
year: int,
month: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
month_number = int(str(month).strip().removesuffix(""))
previous_year, previous_month = _previous_month(year, month_number)
common_params = {"ct": "", "scope": "上海"}
current_params = {"year": year, "month": month, **common_params}
return (
fetch_json(api_base, "/award/overall", current_params),
fetch_json(api_base, "/award/industry", current_params),
fetch_json(api_base, "/award/region", current_params),
fetch_json(api_base, "/award/overall", {"year": year - 1, "month": month, **common_params}),
fetch_json(
api_base,
"/award/overall",
{"year": previous_year, "month": f"{previous_month}", **common_params},
),
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
if missing:
raise P6UpdateError(f"P6 更新失败:模板第 6 页缺少占位符:{', '.join(missing)}")
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P6UpdateError(f"P6 更新失败:{chart_name}.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _chart_value_text(value: int | float) -> str:
return _format_decimal(Decimal(str(value)))
def _update_chart_xml(chart_xml: bytes, chart_name: str, model: ChartModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.title, chart_name)
series_nodes = root.findall(".//c:ser", OOXML_NS)
if not series_nodes:
raise P6UpdateError(f"P6 更新失败:{chart_name}.xml 缺少图表系列。")
series = series_nodes[0]
name_nodes = series.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = model.series_name
category_cache = series.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P6UpdateError(f"P6 更新失败:{chart_name}.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.categories)
_set_cache_points(value_cache, [_chart_value_text(value) for value in model.values])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook(workbook_bytes: bytes, category_header: str, chart_model: ChartModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
clear_columns = max(sheet.max_column, 2)
for row_index in range(1, clear_rows + 1):
for column_index in range(1, clear_columns + 1):
sheet.cell(row=row_index, column=column_index).value = None
sheet["A1"] = category_header
sheet["B1"] = chart_model.series_name
for row_index, (category, value) in enumerate(zip(chart_model.categories, chart_model.values, strict=True), start=2):
sheet.cell(row=row_index, column=1).value = category
sheet.cell(row=row_index, column=2).value = value
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p6_pptx(pptx_path: Path, output_path: Path, model: P6UpdateModel) -> None:
if not pptx_path.exists():
raise P6UpdateError(f"P6 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
chart_parts = {f"ppt/charts/chart{index}.xml" for index in range(4, 10)}
workbook_parts = {f"ppt/embeddings/Workbook{index}.xlsx" for index in range(4, 10)}
required_parts = {"ppt/slides/slide6.xml", *chart_parts, *workbook_parts}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P6UpdateError(f"P6 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide6.xml"))
_replace_slide_text(slide_root, model.replacements)
updates: dict[str, bytes] = {
"ppt/slides/slide6.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
for index in range(4, 10):
chart_key = f"chart{index}"
chart_part = f"ppt/charts/{chart_key}.xml"
updates[chart_part] = _update_chart_xml(source.read(chart_part), chart_key, model.charts[chart_key])
category_header = "运营商" if index in {4, 5, 6, 7} else ("行业" if index == 8 else "属地")
workbook_part = f"ppt/embeddings/Workbook{index}.xlsx"
updates[workbook_part] = _update_workbook(
source.read(workbook_part),
category_header,
model.charts[chart_key],
)
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p6-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 6 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
responses = fetch_p6_responses(args.api_base, args.year, args.month)
model = build_p6_update_model(*responses)
update_p6_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""Update slide 7 of the service monthly PPTX.
本脚本用于单独更新 PPT 第 7 页:累计行业中标分析。
取数逻辑:
- `/award/industry?year={year}&month={month}&ct=是&scope=上海`
使用 `累计数据.个数数据` 和 `累计数据.金额数据`。
更新内容:
- 正文:按累计中标金额 Top3 生成行业总览和重点行业分析。
- 表格:各行业三家运营商的个数份额、金额份额。
- 原生图表:chart10 行业累计中标个数,chart11 行业累计中标金额。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 7
SLIDE_XML = "ppt/slides/slide7.xml"
DATA_SOURCES = ["/award/industry"]
OPERATORS = ("电信", "移动", "联通")
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P7_TEXT_PLACEHOLDERS = [
"{{P7行业总览:按累计中标金额Top3说明行业集中度和竞争分化}}",
"{{P7行业分析1:按累计中标金额排序第1行业,介绍行业情况,说明并总结三家运营商个数份额和金额份额特征}}",
"{{P7行业分析2:按累计中标金额排序第2行业,介绍行业情况,说明并总结三家运营商个数份额和金额份额特征}}",
"{{P7行业分析3:按累计中标金额排序第3行业,介绍行业情况,说明并总结三家运营商个数份额和金额份额特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P7UpdateError(ValueError):
"""Raised when P7 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class IndustryRow:
industry: str
total_amount_wan: Decimal
counts: dict[str, int]
amounts_wan: dict[str, Decimal]
count_shares: dict[str, str]
amount_shares: dict[str, str]
@dataclass(frozen=True)
class MultiSeriesChartModel:
title: str
categories: list[str]
series: dict[str, list[int | float]]
@dataclass(frozen=True)
class P7UpdateModel:
period_label: str
rows: list[IndustryRow]
table_rows: list[list[str]]
replacements: dict[str, str]
charts: dict[str, MultiSeriesChartModel]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P7UpdateError(f"P7 更新失败:{field_path} 为空,无法更新第七页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P7UpdateError(f"P7 更新失败:{field_path} 不是可计算数字,无法更新第七页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P7UpdateError(f"P7 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P7UpdateError(f"P7 更新失败:{field_path} 为空,无法更新第七页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip().removesuffix("")
if not year or not month:
raise P7UpdateError("P7 更新失败:/award/industry 缺少统计年份或统计月份。")
return f"{year}年1-{month}"
def _raw_rows(response: dict[str, Any], data_key: str) -> list[dict[str, Any]]:
cumulative = response.get("累计数据")
if not isinstance(cumulative, dict):
raise P7UpdateError("P7 更新失败:/award/industry 缺少 累计数据。")
rows = cumulative.get(data_key)
if not isinstance(rows, list) or not rows:
raise P7UpdateError(f"P7 更新失败:/award/industry.累计数据.{data_key} 缺少数据。")
return rows
def _rows_by_industry(rows: list[dict[str, Any]], data_key: str) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise P7UpdateError(f"P7 更新失败:/award/industry.累计数据.{data_key}[{index}] 不是对象。")
industry = _required_text(row, "行业", f"/award/industry.累计数据.{data_key}[{index}].行业")
result[industry] = row
return result
def _industry_rows(response: dict[str, Any]) -> list[IndustryRow]:
count_by_industry = _rows_by_industry(_raw_rows(response, "个数数据"), "个数数据")
amount_by_industry = _rows_by_industry(_raw_rows(response, "金额数据"), "金额数据")
missing_amount = sorted(set(count_by_industry) - set(amount_by_industry))
missing_count = sorted(set(amount_by_industry) - set(count_by_industry))
if missing_amount or missing_count:
raise P7UpdateError(
"P7 更新失败:/award/industry.累计数据 个数数据与金额数据行业不一致。"
)
rows: list[IndustryRow] = []
for industry in count_by_industry:
count_row = count_by_industry[industry]
amount_row = amount_by_industry[industry]
counts = {
operator: int(
_parse_decimal(
count_row.get(f"{operator}个数"),
f"/award/industry.累计数据.个数数据.{industry}.{operator}个数",
)
)
for operator in OPERATORS
}
amounts = {
operator: _parse_decimal(
amount_row.get(f"{operator}金额(万元)"),
f"/award/industry.累计数据.金额数据.{industry}.{operator}金额(万元)",
)
for operator in OPERATORS
}
count_shares = {
operator: _required_text(
count_row,
f"{operator}个数份额",
f"/award/industry.累计数据.个数数据.{industry}.{operator}个数份额",
)
for operator in OPERATORS
}
amount_shares = {
operator: _required_text(
amount_row,
f"{operator}金额份额",
f"/award/industry.累计数据.金额数据.{industry}.{operator}金额份额",
)
for operator in OPERATORS
}
rows.append(
IndustryRow(
industry=industry,
total_amount_wan=sum(amounts.values(), Decimal("0")),
counts=counts,
amounts_wan=amounts,
count_shares=count_shares,
amount_shares=amount_shares,
)
)
if len(rows) < 10:
raise P7UpdateError("P7 更新失败:/award/industry.累计数据 少于 10 个行业。")
return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)[:10]
def _format_yi_from_wan(value: Decimal) -> str:
return f"{_format_yi_number_from_wan(value)}亿"
def _format_yi_number_from_wan(value: Decimal) -> str:
return str((value / Decimal("10000")).quantize(Decimal("0.01")))
def _wrap_followup_prompt(fact_text: str) -> str:
return f"{{{{{fact_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _dominant_operator(shares: dict[str, str]) -> tuple[str, str]:
scored = {
operator: _parse_decimal(share, f"P7.{operator}份额")
for operator, share in shares.items()
}
operator, _ = max(scored.items(), key=lambda item: item[1])
return operator, shares[operator]
def _summary_text(rows: list[IndustryRow]) -> str:
top3 = rows[:3]
total_amount = sum((row.total_amount_wan for row in rows), Decimal("0"))
top3_amount = sum((row.total_amount_wan for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "/".join(f"{row.industry}{_format_yi_number_from_wan(row.total_amount_wan)}" for row in top3)
return _wrap_followup_prompt(
f"行业Top3{top_text}亿,占比{share.quantize(Decimal('0.1'))}%"
)
def _analysis_text(index: int, row: IndustryRow) -> str:
count_operator, count_share = _dominant_operator(row.count_shares)
amount_operator, amount_share = _dominant_operator(row.amount_shares)
return _wrap_followup_prompt(
f"{row.industry}行业:{_format_yi_from_wan(row.total_amount_wan)}"
f"个数{count_operator}{count_share},金额{amount_operator}{amount_share}"
)
def _table_rows(rows: list[IndustryRow]) -> list[list[str]]:
return [
["行业", "电信个数份额", "移动个数份额", "联通个数份额", "电信金额份额", "移动金额份额", "联通金额份额"],
*[
[
row.industry,
row.count_shares["电信"],
row.count_shares["移动"],
row.count_shares["联通"],
row.amount_shares["电信"],
row.amount_shares["移动"],
row.amount_shares["联通"],
]
for row in rows
],
]
def build_p7_update_model(response: dict[str, Any]) -> P7UpdateModel:
period_label = _period_label(response)
rows = _industry_rows(response)
replacements = {
P7_TEXT_PLACEHOLDERS[0]: _summary_text(rows),
}
for index, row in enumerate(rows[:3], start=1):
replacements[P7_TEXT_PLACEHOLDERS[index]] = _analysis_text(index, row)
charts = {
"chart10": MultiSeriesChartModel(
title=f"累计行业中标个数对比({period_label}",
categories=[row.industry for row in rows],
series={
f"{operator}个数": [row.counts[operator] for row in rows]
for operator in OPERATORS
},
),
"chart11": MultiSeriesChartModel(
title=f"累计行业中标金额对比(万元,{period_label}",
categories=[row.industry for row in rows],
series={
f"{operator}金额(万元)": [float(row.amounts_wan[operator]) for row in rows]
for operator in OPERATORS
},
),
}
return P7UpdateModel(
period_label=period_label,
rows=rows,
table_rows=_table_rows(rows),
replacements=replacements,
charts=charts,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P7UpdateError(f"P7 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P7UpdateError(f"P7 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P7UpdateError(f"P7 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P7UpdateError(f"P7 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P7UpdateError(f"P7 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p7_response(api_base: str, year: int, month: str) -> dict[str, Any]:
return fetch_json(
api_base,
"/award/industry",
{"year": year, "month": month, "ct": "", "scope": "上海"},
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
if missing:
raise P7UpdateError(f"P7 更新失败:模板第 7 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P7UpdateError("P7 更新失败:表格单元格缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P7UpdateError("P7 更新失败:第 7 页未找到表格。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P7UpdateError("P7 更新失败:第 7 页表格行数不足。")
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P7UpdateError("P7 更新失败:第 7 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P7UpdateError(f"P7 更新失败:{chart_name}.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _chart_value_text(value: int | float) -> str:
return _format_decimal(Decimal(str(value)))
def _update_chart_xml(chart_xml: bytes, chart_name: str, model: MultiSeriesChartModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.title, chart_name)
series_nodes = root.findall(".//c:ser", OOXML_NS)
series_items = list(model.series.items())
if len(series_nodes) < len(series_items):
raise P7UpdateError(f"P7 更新失败:{chart_name}.xml 图表系列不足。")
for series_node, (series_name, values) in zip(series_nodes, series_items, strict=False):
name_nodes = series_node.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = series_name
category_cache = series_node.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series_node.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P7UpdateError(f"P7 更新失败:{chart_name}.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.categories)
_set_cache_points(value_cache, [_chart_value_text(value) for value in values])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook(workbook_bytes: bytes, chart_model: MultiSeriesChartModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
clear_columns = max(sheet.max_column, len(chart_model.series) + 1)
for row_index in range(1, clear_rows + 1):
for column_index in range(1, clear_columns + 1):
sheet.cell(row=row_index, column=column_index).value = None
sheet["A1"] = "行业"
for column_index, series_name in enumerate(chart_model.series, start=2):
sheet.cell(row=1, column=column_index).value = series_name
for row_index, category in enumerate(chart_model.categories, start=2):
sheet.cell(row=row_index, column=1).value = category
for column_index, values in enumerate(chart_model.series.values(), start=2):
sheet.cell(row=row_index, column=column_index).value = values[row_index - 2]
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p7_pptx(pptx_path: Path, output_path: Path, model: P7UpdateModel) -> None:
if not pptx_path.exists():
raise P7UpdateError(f"P7 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {
"ppt/slides/slide7.xml",
"ppt/charts/chart10.xml",
"ppt/charts/chart11.xml",
"ppt/embeddings/Workbook10.xlsx",
"ppt/embeddings/Workbook11.xlsx",
}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P7UpdateError(f"P7 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide7.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_table(slide_root, model.table_rows)
updates = {
"ppt/slides/slide7.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
"ppt/charts/chart10.xml": _update_chart_xml(
source.read("ppt/charts/chart10.xml"),
"chart10",
model.charts["chart10"],
),
"ppt/charts/chart11.xml": _update_chart_xml(
source.read("ppt/charts/chart11.xml"),
"chart11",
model.charts["chart11"],
),
"ppt/embeddings/Workbook10.xlsx": _update_workbook(
source.read("ppt/embeddings/Workbook10.xlsx"),
model.charts["chart10"],
),
"ppt/embeddings/Workbook11.xlsx": _update_workbook(
source.read("ppt/embeddings/Workbook11.xlsx"),
model.charts["chart11"],
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p7-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 7 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p7_response(args.api_base, args.year, args.month)
model = build_p7_update_model(response)
update_p7_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,536 @@
#!/usr/bin/env python3
"""Update slide 8 of the service monthly PPTX.
本脚本用于单独更新 PPT 第 8 页:累计区域中标分析。
取数逻辑:
- `/award/region?year={year}&month={month}&ct=是&scope=上海`
使用 `累计数据.个数数据` 和 `累计数据.金额数据`。
更新内容:
- 正文:按累计中标金额 Top3 生成区域总览和重点属地分析。
- 表格:各属地三家运营商的个数份额、金额份额。
- 原生图表:chart12 区域累计中标个数,chart13 区域累计中标金额。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
from openpyxl import load_workbook
PAGE_NUMBER = 8
SLIDE_XML = "ppt/slides/slide8.xml"
DATA_SOURCES = ["/award/region"]
OPERATORS = ("电信", "移动", "联通")
EXPECTED_REGION_COUNT = 14
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
def _qn(prefix: str, tag: str) -> str:
return f"{{{OOXML_NS[prefix]}}}{tag}"
P8_TEXT_PLACEHOLDERS = [
"{{P8区域总览:按累计中标金额Top3说明区域集中度和运营商竞争分化}}",
"{{P8区域分析1:按累计中标金额排序第1属地,介绍属地情况,说明并总结三家运营商个数份额和金额份额特征}}",
"{{P8区域分析2:按累计中标金额排序第2属地,介绍属地情况,说明并总结三家运营商个数份额和金额份额特征}}",
"{{P8区域分析3:按累计中标金额排序第3属地,介绍属地情况,说明并总结三家运营商个数份额和金额份额特征}}",
]
FOLLOWUP_PROMPT_SUFFIX = "请基于数据进行概括总结分析,凸显数据特征"
class P8UpdateError(ValueError):
"""Raised when P8 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class RegionRow:
region: str
total_amount_wan: Decimal
counts: dict[str, int]
amounts_wan: dict[str, Decimal]
count_shares: dict[str, str]
amount_shares: dict[str, str]
@dataclass(frozen=True)
class MultiSeriesChartModel:
title: str
categories: list[str]
series: dict[str, list[int | float]]
@dataclass(frozen=True)
class P8UpdateModel:
period_label: str
rows: list[RegionRow]
table_rows: list[list[str]]
replacements: dict[str, str]
charts: dict[str, MultiSeriesChartModel]
def _format_decimal(value: Decimal) -> str:
return format(value.normalize(), "f")
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P8UpdateError(f"P8 更新失败:{field_path} 为空,无法更新第八页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P8UpdateError(f"P8 更新失败:{field_path} 不是可计算数字,无法更新第八页。")
raw = raw.replace(",", "").removesuffix("%")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P8UpdateError(f"P8 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P8UpdateError(f"P8 更新失败:{field_path} 为空,无法更新第八页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip().removesuffix("")
if not year or not month:
raise P8UpdateError("P8 更新失败:/award/region 缺少统计年份或统计月份。")
return f"{year}年1-{month}"
def _raw_rows(response: dict[str, Any], data_key: str) -> list[dict[str, Any]]:
cumulative = response.get("累计数据")
if not isinstance(cumulative, dict):
raise P8UpdateError("P8 更新失败:/award/region 缺少 累计数据。")
rows = cumulative.get(data_key)
if not isinstance(rows, list) or not rows:
raise P8UpdateError(f"P8 更新失败:/award/region.累计数据.{data_key} 缺少数据。")
return rows
def _rows_by_region(rows: list[dict[str, Any]], data_key: str) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
raise P8UpdateError(f"P8 更新失败:/award/region.累计数据.{data_key}[{index}] 不是对象。")
region = _required_text(row, "属地", f"/award/region.累计数据.{data_key}[{index}].属地")
result[region] = row
return result
def _region_rows(response: dict[str, Any]) -> list[RegionRow]:
count_by_region = _rows_by_region(_raw_rows(response, "个数数据"), "个数数据")
amount_by_region = _rows_by_region(_raw_rows(response, "金额数据"), "金额数据")
missing_amount = sorted(set(count_by_region) - set(amount_by_region))
missing_count = sorted(set(amount_by_region) - set(count_by_region))
if missing_amount or missing_count:
raise P8UpdateError(
"P8 更新失败:/award/region.累计数据 个数数据与金额数据属地不一致。"
)
rows: list[RegionRow] = []
for region in count_by_region:
count_row = count_by_region[region]
amount_row = amount_by_region[region]
counts = {
operator: int(
_parse_decimal(
count_row.get(f"{operator}个数"),
f"/award/region.累计数据.个数数据.{region}.{operator}个数",
)
)
for operator in OPERATORS
}
amounts = {
operator: _parse_decimal(
amount_row.get(f"{operator}金额(万元)"),
f"/award/region.累计数据.金额数据.{region}.{operator}金额(万元)",
)
for operator in OPERATORS
}
count_shares = {
operator: _required_text(
count_row,
f"{operator}个数份额",
f"/award/region.累计数据.个数数据.{region}.{operator}个数份额",
)
for operator in OPERATORS
}
amount_shares = {
operator: _required_text(
amount_row,
f"{operator}金额份额",
f"/award/region.累计数据.金额数据.{region}.{operator}金额份额",
)
for operator in OPERATORS
}
rows.append(
RegionRow(
region=region,
total_amount_wan=sum(amounts.values(), Decimal("0")),
counts=counts,
amounts_wan=amounts,
count_shares=count_shares,
amount_shares=amount_shares,
)
)
if len(rows) < EXPECTED_REGION_COUNT:
raise P8UpdateError(
f"P8 更新失败:/award/region.累计数据 少于 {EXPECTED_REGION_COUNT} 个属地。"
)
return sorted(rows, key=lambda row: row.total_amount_wan, reverse=True)[:EXPECTED_REGION_COUNT]
def _format_yi_from_wan(value: Decimal) -> str:
return f"{_format_yi_number_from_wan(value)}亿"
def _format_yi_number_from_wan(value: Decimal) -> str:
return str((value / Decimal("10000")).quantize(Decimal("0.01")))
def _wrap_followup_prompt(fact_text: str) -> str:
return f"{{{{{fact_text}{FOLLOWUP_PROMPT_SUFFIX}}}}}"
def _dominant_operator(shares: dict[str, str]) -> tuple[str, str]:
scored = {
operator: _parse_decimal(share, f"P8.{operator}份额")
for operator, share in shares.items()
}
operator, _ = max(scored.items(), key=lambda item: item[1])
return operator, shares[operator]
def _summary_text(rows: list[RegionRow]) -> str:
top3 = rows[:3]
total_amount = sum((row.total_amount_wan for row in rows), Decimal("0"))
top3_amount = sum((row.total_amount_wan for row in top3), Decimal("0"))
share = Decimal("0") if total_amount == 0 else top3_amount / total_amount * Decimal("100")
top_text = "/".join(f"{row.region}{_format_yi_number_from_wan(row.total_amount_wan)}" for row in top3)
return _wrap_followup_prompt(
f"属地Top3{top_text}亿,占比{share.quantize(Decimal('0.1'))}%"
)
def _analysis_text(index: int, row: RegionRow) -> str:
count_operator, count_share = _dominant_operator(row.count_shares)
amount_operator, amount_share = _dominant_operator(row.amount_shares)
return _wrap_followup_prompt(
f"{row.region}{_format_yi_from_wan(row.total_amount_wan)}"
f"个数{count_operator}{count_share},金额{amount_operator}{amount_share}"
)
def _table_rows(rows: list[RegionRow]) -> list[list[str]]:
return [
["属地", "电信个数份额", "移动个数份额", "联通个数份额", "电信金额份额", "移动金额份额", "联通金额份额"],
*[
[
row.region,
row.count_shares["电信"],
row.count_shares["移动"],
row.count_shares["联通"],
row.amount_shares["电信"],
row.amount_shares["移动"],
row.amount_shares["联通"],
]
for row in rows
],
]
def build_p8_update_model(response: dict[str, Any]) -> P8UpdateModel:
period_label = _period_label(response)
rows = _region_rows(response)
replacements = {
P8_TEXT_PLACEHOLDERS[0]: _summary_text(rows),
}
for index, row in enumerate(rows[:3], start=1):
replacements[P8_TEXT_PLACEHOLDERS[index]] = _analysis_text(index, row)
charts = {
"chart12": MultiSeriesChartModel(
title=f"累计区域中标个数对比({period_label}",
categories=[row.region for row in rows],
series={
f"{operator}个数": [row.counts[operator] for row in rows]
for operator in OPERATORS
},
),
"chart13": MultiSeriesChartModel(
title=f"累计区域中标金额对比(万元,{period_label}",
categories=[row.region for row in rows],
series={
f"{operator}金额(万元)": [float(row.amounts_wan[operator]) for row in rows]
for operator in OPERATORS
},
),
}
return P8UpdateModel(
period_label=period_label,
rows=rows,
table_rows=_table_rows(rows),
replacements=replacements,
charts=charts,
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P8UpdateError(f"P8 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P8UpdateError(f"P8 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P8UpdateError(f"P8 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P8UpdateError(f"P8 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P8UpdateError(f"P8 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p8_response(api_base: str, year: int, month: str) -> dict[str, Any]:
return fetch_json(
api_base,
"/award/region",
{"year": year, "month": month, "ct": "", "scope": "上海"},
)
def _replace_slide_text(root: ET.Element, replacements: dict[str, str]) -> None:
replaced_counts = {placeholder: 0 for placeholder in replacements}
for paragraph in root.findall(".//a:p", OOXML_NS):
text_nodes = paragraph.findall(".//a:t", OOXML_NS)
if not text_nodes:
continue
original = "".join(node.text or "" for node in text_nodes)
updated = original
for placeholder, value in replacements.items():
if placeholder in updated:
replaced_counts[placeholder] += updated.count(placeholder)
updated = updated.replace(placeholder, value)
if updated != original:
text_nodes[0].text = updated
for node in text_nodes[1:]:
node.text = ""
missing = sorted(placeholder for placeholder, count in replaced_counts.items() if count < 1)
if missing:
raise P8UpdateError(f"P8 更新失败:模板第 8 页缺少占位符:{', '.join(missing)}")
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P8UpdateError("P8 更新失败:表格单元格缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _update_slide_table(root: ET.Element, table_rows: list[list[str]]) -> None:
table = root.find(".//a:tbl", OOXML_NS)
if table is None:
raise P8UpdateError("P8 更新失败:第 8 页未找到表格。")
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(table_rows):
raise P8UpdateError("P8 更新失败:第 8 页表格行数不足。")
for extra_row in xml_rows[len(table_rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(table_rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P8UpdateError("P8 更新失败:第 8 页表格列数不足。")
for cell, value in zip(cells, row_values, strict=True):
_set_text_nodes(cell, value)
def _set_cache_points(cache: ET.Element, values: list[str]) -> None:
for child in list(cache):
if child.tag in {_qn("c", "ptCount"), _qn("c", "pt")}:
cache.remove(child)
pt_count = ET.SubElement(cache, _qn("c", "ptCount"))
pt_count.set("val", str(len(values)))
for index, value in enumerate(values):
point = ET.SubElement(cache, _qn("c", "pt"))
point.set("idx", str(index))
value_node = ET.SubElement(point, _qn("c", "v"))
value_node.text = value
def _set_chart_title(root: ET.Element, title: str, chart_name: str) -> None:
text_nodes = root.findall(".//c:title//a:t", OOXML_NS)
if not text_nodes:
raise P8UpdateError(f"P8 更新失败:{chart_name}.xml 缺少标题文本节点。")
text_nodes[0].text = title
for node in text_nodes[1:]:
node.text = ""
def _chart_value_text(value: int | float) -> str:
return _format_decimal(Decimal(str(value)))
def _update_chart_xml(chart_xml: bytes, chart_name: str, model: MultiSeriesChartModel) -> bytes:
root = ET.fromstring(chart_xml)
_set_chart_title(root, model.title, chart_name)
series_nodes = root.findall(".//c:ser", OOXML_NS)
series_items = list(model.series.items())
if len(series_nodes) < len(series_items):
raise P8UpdateError(f"P8 更新失败:{chart_name}.xml 图表系列不足。")
for series_node, (series_name, values) in zip(series_nodes, series_items, strict=False):
name_nodes = series_node.findall(".//c:tx//c:strCache/c:pt/c:v", OOXML_NS)
if name_nodes:
name_nodes[0].text = series_name
category_cache = series_node.find("./c:cat//c:strCache", OOXML_NS)
value_cache = series_node.find("./c:val//c:numCache", OOXML_NS)
if category_cache is None or value_cache is None:
raise P8UpdateError(f"P8 更新失败:{chart_name}.xml 缺少分类或数值缓存。")
_set_cache_points(category_cache, model.categories)
_set_cache_points(value_cache, [_chart_value_text(value) for value in values])
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _update_workbook(workbook_bytes: bytes, chart_model: MultiSeriesChartModel) -> bytes:
workbook = load_workbook(BytesIO(workbook_bytes))
sheet = workbook.active
clear_rows = max(sheet.max_row, len(chart_model.categories) + 1)
clear_columns = max(sheet.max_column, len(chart_model.series) + 1)
for row_index in range(1, clear_rows + 1):
for column_index in range(1, clear_columns + 1):
sheet.cell(row=row_index, column=column_index).value = None
sheet["A1"] = "属地"
for column_index, series_name in enumerate(chart_model.series, start=2):
sheet.cell(row=1, column=column_index).value = series_name
for row_index, category in enumerate(chart_model.categories, start=2):
sheet.cell(row=row_index, column=1).value = category
for column_index, values in enumerate(chart_model.series.values(), start=2):
sheet.cell(row=row_index, column=column_index).value = values[row_index - 2]
output = BytesIO()
workbook.save(output)
return output.getvalue()
def update_p8_pptx(pptx_path: Path, output_path: Path, model: P8UpdateModel) -> None:
if not pptx_path.exists():
raise P8UpdateError(f"P8 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {
"ppt/slides/slide8.xml",
"ppt/charts/chart12.xml",
"ppt/charts/chart13.xml",
"ppt/embeddings/Workbook12.xlsx",
"ppt/embeddings/Workbook13.xlsx",
}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P8UpdateError(f"P8 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide8.xml"))
_replace_slide_text(slide_root, model.replacements)
_update_slide_table(slide_root, model.table_rows)
updates = {
"ppt/slides/slide8.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
"ppt/charts/chart12.xml": _update_chart_xml(
source.read("ppt/charts/chart12.xml"),
"chart12",
model.charts["chart12"],
),
"ppt/charts/chart13.xml": _update_chart_xml(
source.read("ppt/charts/chart13.xml"),
"chart13",
model.charts["chart13"],
),
"ppt/embeddings/Workbook12.xlsx": _update_workbook(
source.read("ppt/embeddings/Workbook12.xlsx"),
model.charts["chart12"],
),
"ppt/embeddings/Workbook13.xlsx": _update_workbook(
source.read("ppt/embeddings/Workbook13.xlsx"),
model.charts["chart13"],
),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p8-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 8 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p8_response(args.api_base, args.year, args.month)
model = build_p8_update_model(response)
update_p8_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Update slide 9 of the service monthly PPTX.
本脚本用于单独更新 PPT 第 9 页:当月友商中标大单。
取数逻辑:
- `/award/competitor-large-deals?year={year}&month={month}&ct=是&scope=上海`
使用 `电信大单` 和 `联通大单`。
更新内容:
- 左表:电信当月中标大单 Top8。
- 右表:联通当月中标大单 Top8。
"""
from __future__ import annotations
import argparse
import json
import tempfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from zipfile import ZipFile
PAGE_NUMBER = 9
SLIDE_XML = "ppt/slides/slide9.xml"
DATA_SOURCES = ["/award/competitor-large-deals"]
TELECOM_SECTION = "电信大单"
UNICOM_SECTION = "联通大单"
TABLE_HEADER = ["招标人", "行业", "项目名称", "中标金额(万元)"]
TOP_N = 8
UNICOM_TITLE_TEXT = "联通当月中标大单"
OOXML_NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for _prefix, _uri in OOXML_NS.items():
ET.register_namespace(_prefix, _uri)
class P9UpdateError(ValueError):
"""Raised when P9 cannot be updated from complete, trusted data."""
@dataclass(frozen=True)
class DealRow:
buyer: str
industry: str
project_name: str
amount_wan: str
amount_value: Decimal
@dataclass(frozen=True)
class P9UpdateModel:
period_label: str
telecom_rows: list[DealRow]
unicom_rows: list[DealRow]
tables: dict[str, list[list[str]]]
def _parse_decimal(value: Any, field_path: str) -> Decimal:
if value is None:
raise P9UpdateError(f"P9 更新失败:{field_path} 为空,无法更新第九页。")
raw = str(value).strip()
if not raw or raw == "/":
raise P9UpdateError(f"P9 更新失败:{field_path} 不是可计算数字,无法更新第九页。")
raw = raw.replace(",", "")
try:
return Decimal(raw)
except InvalidOperation as exc:
raise P9UpdateError(f"P9 更新失败:{field_path}={value!r} 不是数字。") from exc
def _required_text(row: dict[str, Any], key: str, field_path: str) -> str:
value = row.get(key)
text = "" if value is None else str(value).strip()
if not text:
raise P9UpdateError(f"P9 更新失败:{field_path} 为空,无法更新第九页。")
return text
def _period_label(response: dict[str, Any]) -> str:
year = response.get("统计年份")
month = str(response.get("统计月份", "")).strip().removesuffix("")
if not year or not month:
raise P9UpdateError("P9 更新失败:/award/competitor-large-deals 缺少统计年份或统计月份。")
return f"{year}{month}"
def _deal_rows(response: dict[str, Any], section: str) -> list[DealRow]:
raw_rows = response.get(section)
if not isinstance(raw_rows, list) or not raw_rows:
raise P9UpdateError(f"P9 更新失败:/award/competitor-large-deals.{section} 缺少数据。")
rows: list[DealRow] = []
for index, row in enumerate(raw_rows, start=1):
if not isinstance(row, dict):
raise P9UpdateError(f"P9 更新失败:/award/competitor-large-deals.{section}[{index}] 不是对象。")
amount_text = _required_text(
row,
"中标金额(万元)",
f"/award/competitor-large-deals.{section}[{index}].中标金额(万元)",
)
rows.append(
DealRow(
buyer=_required_text(row, "招标人", f"/award/competitor-large-deals.{section}[{index}].招标人"),
industry=_required_text(row, "行业", f"/award/competitor-large-deals.{section}[{index}].行业"),
project_name=_required_text(row, "项目名称", f"/award/competitor-large-deals.{section}[{index}].项目名称"),
amount_wan=amount_text,
amount_value=_parse_decimal(
amount_text,
f"/award/competitor-large-deals.{section}[{index}].中标金额(万元)",
),
)
)
if len(rows) < TOP_N:
raise P9UpdateError(f"P9 更新失败:/award/competitor-large-deals.{section} 少于 {TOP_N} 条。")
return sorted(rows, key=lambda row: (-row.amount_value, row.buyer, row.project_name))[:TOP_N]
def _table_rows(rows: list[DealRow]) -> list[list[str]]:
return [
TABLE_HEADER,
*[
[row.buyer, row.industry, row.project_name, row.amount_wan]
for row in rows
],
]
def build_p9_update_model(response: dict[str, Any]) -> P9UpdateModel:
period_label = _period_label(response)
telecom_rows = _deal_rows(response, TELECOM_SECTION)
unicom_rows = _deal_rows(response, UNICOM_SECTION)
return P9UpdateModel(
period_label=period_label,
telecom_rows=telecom_rows,
unicom_rows=unicom_rows,
tables={
TELECOM_SECTION: _table_rows(telecom_rows),
UNICOM_SECTION: _table_rows(unicom_rows),
},
)
def fetch_json(api_base: str, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
url = f"{api_base.rstrip('/')}{endpoint}?{urlencode(params)}"
request = Request(url)
try:
with urlopen(request, timeout=20) as response:
payload = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise P9UpdateError(f"P9 更新失败:{endpoint} HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise P9UpdateError(f"P9 更新失败:无法连接 FastAPI {url}: {exc.reason}") from exc
if status != 200:
raise P9UpdateError(f"P9 更新失败:{endpoint} HTTP {status}")
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise P9UpdateError(f"P9 更新失败:{endpoint} 返回值不是 JSON。") from exc
if not isinstance(data, dict):
raise P9UpdateError(f"P9 更新失败:{endpoint} 返回 JSON 根节点不是对象。")
return data
def fetch_p9_response(api_base: str, year: int, month: str) -> dict[str, Any]:
return fetch_json(
api_base,
"/award/competitor-large-deals",
{"year": year, "month": month, "ct": "", "scope": "上海"},
)
def _set_text_nodes(parent: ET.Element, value: str) -> None:
text_nodes = parent.findall(".//a:t", OOXML_NS)
if not text_nodes:
raise P9UpdateError("P9 更新失败:目标元素缺少文本节点。")
text_nodes[0].text = value
for node in text_nodes[1:]:
node.text = ""
def _shape_id(shape: ET.Element) -> str | None:
cnv = shape.find("./p:nvSpPr/p:cNvPr", OOXML_NS)
return cnv.attrib.get("id") if cnv is not None else None
def _update_unicom_title(root: ET.Element) -> None:
shapes = {_shape_id(shape): shape for shape in root.findall(".//p:sp", OOXML_NS)}
shape = shapes.get("7")
if shape is None:
raise P9UpdateError("P9 更新失败:第 9 页缺少联通大单标题形状。")
_set_text_nodes(shape, UNICOM_TITLE_TEXT)
def _table_frames_sorted_by_x(root: ET.Element) -> list[ET.Element]:
frames: list[tuple[int, ET.Element]] = []
for graphic_frame in root.findall(".//p:graphicFrame", OOXML_NS):
table = graphic_frame.find(".//a:tbl", OOXML_NS)
if table is None:
continue
off = graphic_frame.find("./p:xfrm/a:off", OOXML_NS)
x = int(off.attrib.get("x", "0")) if off is not None else 0
frames.append((x, table))
if len(frames) != 2:
raise P9UpdateError("P9 更新失败:第 9 页应包含 2 张大单表。")
return [table for _, table in sorted(frames, key=lambda item: item[0])]
def _update_table(table: ET.Element, rows: list[list[str]], label: str) -> None:
xml_rows = table.findall("./a:tr", OOXML_NS)
if len(xml_rows) < len(rows):
raise P9UpdateError(f"P9 更新失败:第 9 页{label}表格行数不足。")
for extra_row in xml_rows[len(rows):]:
table.remove(extra_row)
xml_rows = table.findall("./a:tr", OOXML_NS)
for row_index, row_values in enumerate(rows):
cells = xml_rows[row_index].findall("./a:tc", OOXML_NS)
if len(cells) < len(row_values):
raise P9UpdateError(f"P9 更新失败:第 9 页{label}表格列数不足。")
for cell, value in zip(cells, row_values, strict=False):
_set_text_nodes(cell, value)
def update_p9_pptx(pptx_path: Path, output_path: Path, model: P9UpdateModel) -> None:
if not pptx_path.exists():
raise P9UpdateError(f"P9 更新失败:输入 PPTX 不存在:{pptx_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
required_parts = {"ppt/slides/slide9.xml"}
with ZipFile(pptx_path, "r") as source:
missing = sorted(required_parts - set(source.namelist()))
if missing:
raise P9UpdateError(f"P9 更新失败:PPTX 缺少必要部件:{', '.join(missing)}")
slide_root = ET.fromstring(source.read("ppt/slides/slide9.xml"))
_update_unicom_title(slide_root)
telecom_table, unicom_table = _table_frames_sorted_by_x(slide_root)
_update_table(telecom_table, model.tables[TELECOM_SECTION], "电信大单")
_update_table(unicom_table, model.tables[UNICOM_SECTION], "联通大单")
updates = {
"ppt/slides/slide9.xml": ET.tostring(slide_root, encoding="utf-8", xml_declaration=True),
}
if pptx_path.resolve() == output_path.resolve():
with tempfile.NamedTemporaryFile(
prefix="p9-update-",
suffix=".pptx",
dir=output_path.parent,
delete=False,
) as tmp:
actual_output = Path(tmp.name)
else:
actual_output = output_path
try:
with ZipFile(actual_output, "w") as target:
for info in source.infolist():
target.writestr(info, updates.get(info.filename, source.read(info.filename)))
if actual_output != output_path:
actual_output.replace(output_path)
except Exception:
if actual_output != output_path and actual_output.exists():
actual_output.unlink()
raise
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update page 9 of the service monthly PPTX.")
parser.add_argument("--pptx", required=True, type=Path, help="Input PPTX template path.")
parser.add_argument("--output", required=True, type=Path, help="Output PPTX path.")
parser.add_argument("--year", required=True, type=int, help="Report year, for example 2026.")
parser.add_argument("--month", required=True, help="Report month, for example 4月.")
parser.add_argument("--api-base", required=True, help="FastAPI base URL.")
return parser.parse_args()
def main() -> None:
args = parse_args()
response = fetch_p9_response(args.api_base, args.year, args.month)
model = build_p9_update_model(response)
update_p9_pptx(args.pptx, args.output, model)
if __name__ == "__main__":
main()
@@ -0,0 +1,13 @@
"""Standalone page update scripts for the Shanghai city service monthly PPTX."""
PAGE_MODULES = (
"P1_update",
"P2_update",
"P3_update",
"P4_update",
"P5_update",
"P6_update",
"P7_update",
"P8_update",
"P9_update",
)
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "market-api"
version = "0.1.0"
description = "FastAPI service for importing monthly market Excel data into PostgreSQL."
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.30,<1.0",
"python-multipart>=0.0.20,<1.0",
"openpyxl>=3.1,<4.0",
"psycopg[binary]>=3.2,<4.0",
"python-docx>=1.1,<2.0",
]
[tool.ruff]
line-length = 100
target-version = "py312"
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30,<1.0
python-multipart>=0.0.20,<1.0
openpyxl>=3.1,<4.0
psycopg[binary]>=3.2,<4.0
python-docx>=1.1,<2.0
+1
View File
@@ -0,0 +1 @@
"""Local utility scripts for market_api."""
+445
View File
@@ -0,0 +1,445 @@
from __future__ import annotations
import argparse
import json
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
# ===== 直接运行时主要改这里 =====
API_BASE = "http://127.0.0.1:8000"
OUTPUT_PATH: str | Path | None = None
OUTPUT_DIR: str | Path | None = None
YEAR = 2026
MONTH = 5
CT = ""
SCOPE = "行政区"
SCOPE_VALUE = "崇明"
TIMEOUT = 15.0
# ================================
VALID_SCOPES = {"上海", "行政区", "属地"}
VALID_CT_VALUES = {"", ""}
class AnalysisTxtError(RuntimeError):
"""Raised when analysis txt generation cannot continue."""
@dataclass(frozen=True)
class ApiCall:
path: str
params: dict[str, object]
@dataclass(frozen=True)
class AnalysisTask:
filename: str
prompt: str
calls: tuple[ApiCall, ...]
@dataclass(frozen=True)
class RunConfig:
api_base: str
output_path: Path
year: int
month: int
ct: str
scope: str
scope_value: str | None
timeout: float
Fetcher = Callable[[str, str, dict[str, object], float], object]
def _normalize_month(month: int) -> int:
if not 1 <= month <= 12:
raise ValueError("month 必须在 1-12 之间")
return month
def _normalize_scope(scope: str, scope_value: str | None) -> tuple[str, str | None]:
normalized_scope = scope.strip()
if normalized_scope not in VALID_SCOPES:
raise ValueError("scope 必须是 上海、行政区 或 属地")
normalized_value = scope_value.strip() if scope_value is not None else ""
if normalized_scope == "上海":
return normalized_scope, None
if not normalized_value:
raise ValueError(f"scope 为{normalized_scope}时 scope_value 不能为空")
return normalized_scope, normalized_value
def _normalize_ct(ct: str) -> str:
normalized_ct = ct.strip()
if normalized_ct not in VALID_CT_VALUES:
raise ValueError("ct 必须是 是 或 否")
return normalized_ct
def _scope_label(scope: str, scope_value: str | None) -> str:
if scope == "上海":
return "上海"
return f"{scope}{scope_value}"
def _safe_filename_segment(value: str) -> str:
safe_value = re.sub(r'[\\/:*?"<>|]+', "_", value)
safe_value = re.sub(r"\s+", "_", safe_value).strip("._ ")
return safe_value or "未命名"
def _base_params(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> dict[str, object]:
params: dict[str, object] = {
"year": year,
"month": month,
"scope": scope,
}
if scope_value is not None:
params["scope_value"] = scope_value
return params
def build_analysis_tasks(
*,
year: int,
month: int,
ct: str,
scope: str,
scope_value: str | None,
) -> list[AnalysisTask]:
normalized_month = _normalize_month(month)
normalized_ct = _normalize_ct(ct)
normalized_scope, normalized_scope_value = _normalize_scope(scope, scope_value)
label = _scope_label(normalized_scope, normalized_scope_value)
tender_params = _base_params(
year=year,
month=normalized_month,
scope=normalized_scope,
scope_value=normalized_scope_value,
)
award_params = {
**tender_params,
"ct": normalized_ct,
}
return [
AnalysisTask(
filename="01_公开市场整体态势.txt",
prompt=f"请基于以下数据分析{label}公开市场整体态势:",
calls=(
ApiCall("/tender/overall", dict(tender_params)),
ApiCall("/tender/region", dict(tender_params)),
),
),
AnalysisTask(
filename="02_公开市场行业分布.txt",
prompt=f"请基于以下数据分析{label}公开市场行业分布:",
calls=(ApiCall("/tender/industry", dict(tender_params)),),
),
AnalysisTask(
filename="03_公开市场单月动向.txt",
prompt=f"请基于以下数据分析{label}公开市场单月动向:",
calls=(ApiCall("/tender/overall", dict(tender_params)),),
),
AnalysisTask(
filename="04_运营商市场整体态势.txt",
prompt=f"请基于以下数据分析{label}运营商市场整体态势:",
calls=(ApiCall("/award/overall", dict(award_params)),),
),
AnalysisTask(
filename="05_运营商市场行业分布.txt",
prompt=f"请基于以下数据分析{label}运营商市场行业分布:",
calls=(ApiCall("/award/industry", dict(award_params)),),
),
AnalysisTask(
filename="06_运营商市场单月动向.txt",
prompt=f"请基于以下数据分析{label}运营商市场单月动向:",
calls=(ApiCall("/award/overall", dict(award_params)),),
),
]
def _request_url(api_base: str, path: str, params: dict[str, object]) -> str:
query = urlencode(params)
return f"{api_base.rstrip('/')}{path}?{query}"
def fetch_json(
api_base: str,
path: str,
params: dict[str, object],
timeout: float,
) -> object:
url = _request_url(api_base, path, params)
request = Request(url, headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout) as response:
payload = response.read().decode("utf-8")
except HTTPError as exc:
return _http_error_payload(exc)
except URLError as exc:
raise AnalysisTxtError(f"无法连接 FastAPI{url},原因:{exc.reason}") from exc
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise AnalysisTxtError(f"接口返回的内容不是 JSON{url}") from exc
def _http_error_payload(exc: HTTPError) -> dict[str, object]:
body = exc.read().decode("utf-8", errors="replace")
try:
response_content: object = json.loads(body)
except json.JSONDecodeError:
response_content = body
return {
"HTTP状态码": exc.code,
"错误信息": f"接口返回 HTTP {exc.code}",
"响应内容": response_content,
}
def render_task(task: AnalysisTask, results: Sequence[object]) -> str:
lines = [task.prompt, ""]
for call, result in zip(task.calls, results, strict=True):
lines.extend(
[
f"调用接口GET {call.path} 记录数据结果",
"请求参数:",
json.dumps(call.params, ensure_ascii=False, indent=2),
"数据结果:",
json.dumps(result, ensure_ascii=False, indent=2),
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def render_unified_txt(task_results: Sequence[tuple[AnalysisTask, Sequence[object]]]) -> str:
return "\n\n".join(
render_task(task, results).rstrip()
for task, results in task_results
) + "\n"
def default_output_filename(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> str:
filename_parts = [
str(year),
str(month),
_safe_filename_segment(scope),
]
if scope_value is not None and scope_value.strip():
filename_parts.append(_safe_filename_segment(scope_value))
return f"{'_'.join(filename_parts)}数据分析结果.txt"
def default_output_path(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> Path:
return Path("outputs") / default_output_filename(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
def default_output_dir(
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> Path:
return default_output_path(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
).parent
def generate_files(
*,
api_base: str,
year: int,
month: int,
ct: str,
scope: str,
scope_value: str | None,
timeout: float,
output_path: Path | None = None,
output_dir: Path | None = None,
fetcher: Fetcher = fetch_json,
) -> list[Path]:
tasks = build_analysis_tasks(
year=year,
month=month,
ct=ct,
scope=scope,
scope_value=scope_value,
)
if output_path is None:
if output_dir is None:
output_path = default_output_path(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
else:
output_path = output_dir / default_output_filename(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
task_results: list[tuple[AnalysisTask, Sequence[object]]] = []
for task in tasks:
results = [
fetcher(api_base, call.path, call.params, timeout)
for call in task.calls
]
task_results.append((task, results))
output_path.write_text(render_unified_txt(task_results), encoding="utf-8")
written_files = [output_path]
return written_files
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="调用 marketAPI 并生成数据分析基础 txt 文件。",
)
parser.add_argument("--api-base", help="覆盖脚本顶部的 API_BASE。")
parser.add_argument("--output-path", type=Path, help="覆盖脚本顶部的 OUTPUT_PATH。")
parser.add_argument(
"--output-dir",
type=Path,
help="自定义输出目录,文件名按规则生成。",
)
parser.add_argument("--year", type=int, help="覆盖脚本顶部的 YEAR。")
parser.add_argument("--month", type=int, help="覆盖脚本顶部的 MONTH。")
parser.add_argument("--ct", help="覆盖脚本顶部的 CT。")
parser.add_argument("--scope", help="覆盖脚本顶部的 SCOPE。")
parser.add_argument("--scope-value", help="覆盖脚本顶部的 SCOPE_VALUE。")
parser.add_argument(
"--timeout",
type=float,
help="覆盖脚本顶部的 TIMEOUT。",
)
return parser.parse_args(argv)
def _output_path_from_config(
output_path: str | Path | None,
output_dir: str | Path | None,
*,
year: int,
month: int,
scope: str,
scope_value: str | None,
) -> Path:
if output_path is not None and str(output_path).strip():
return Path(output_path)
if output_dir is not None and str(output_dir).strip():
return Path(output_dir) / default_output_filename(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
return default_output_path(
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
def resolve_run_config(args: argparse.Namespace) -> RunConfig:
year = args.year if args.year is not None else YEAR
month = _normalize_month(args.month if args.month is not None else MONTH)
ct = _normalize_ct(args.ct if args.ct is not None else CT)
scope_input = args.scope if args.scope is not None else SCOPE
scope_value_input = args.scope_value if args.scope_value is not None else SCOPE_VALUE
output_path_input = args.output_path if args.output_path is not None else OUTPUT_PATH
output_dir_input = args.output_dir if args.output_dir is not None else OUTPUT_DIR
timeout = args.timeout if args.timeout is not None else TIMEOUT
api_base = args.api_base if args.api_base is not None else API_BASE
scope = scope_input.strip()
scope, scope_value = _normalize_scope(scope, scope_value_input)
output_path = _output_path_from_config(
output_path_input,
output_dir_input,
year=year,
month=month,
scope=scope,
scope_value=scope_value,
)
return RunConfig(
api_base=api_base,
output_path=output_path,
year=year,
month=month,
ct=ct,
scope=scope,
scope_value=scope_value,
timeout=timeout,
)
def main(argv: Sequence[str] | None = None) -> int:
config = resolve_run_config(parse_args(argv))
written_files = generate_files(
api_base=config.api_base,
output_path=config.output_path,
year=config.year,
month=config.month,
ct=config.ct,
scope=config.scope,
scope_value=config.scope_value,
timeout=config.timeout,
)
print(f"已生成 {len(written_files)} 个 txt 文件:")
for path in written_files:
print(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
version: "3.8"
services:
postgres:
image: postgres:16
container_name: n8n-postgres
restart: unless-stopped
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: CHANGE_ME_STRONG_PASSWORD
POSTGRES_DB: n8n
volumes:
- n8n_postgres_data:/var/lib/postgresql/data
# 可选:如果你只让 n8n 访问,不需要映射到宿主机端口
# ports:
# - "5432:5432"
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: n8n
restart: unless-stopped
depends_on:
- postgres
ports:
- "3600:5678"
environment:
# ===== 时区 =====
TZ: Asia/Shanghai
GENERIC_TIMEZONE: Asia/Shanghai
# ===== 权限 & 运行机制(官方参数)=====
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
N8N_RUNNERS_ENABLED: "true"
N8N_SECURE_COOKIE: "false"
# ===== 数据库(Postgres=====
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: 5432
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: CHANGE_ME_STRONG_PASSWORD
DB_POSTGRESDB_SCHEMA: public
# ===== 强烈建议:首次就固定(不要后面改)=====
N8N_ENCRYPTION_KEY: "XepzhV7nMbjYN"
# 可选:限制注册/简易认证(看你的版本/用法)
N8N_BASIC_AUTH_ACTIVE: "true"
N8N_BASIC_AUTH_USER: "Kun"
N8N_BASIC_AUTH_PASSWORD: "XepzhV7nMbjYN"
# ===== 如果你后面接 480T Nginx 反代(先注释也行)=====
# N8N_HOST: n8n.kunle777.top
# N8N_PROTOCOL: https
# N8N_PORT: 443
# WEBHOOK_URL: https://n8n.kunle777.top
volumes:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
n8n_postgres_data: