update 26.7.25
This commit is contained in:
Executable
+7
@@ -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=
|
||||
Executable
+8
@@ -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=
|
||||
Executable
+29
@@ -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"]
|
||||
Executable
+14
@@ -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
|
||||
```
|
||||
Executable
+17
@@ -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
|
||||
Regular → Executable
+17
@@ -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
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
"""FastAPI service for monthly market data imports and statistics."""
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
"""Award statistics services."""
|
||||
|
||||
Executable
+39
@@ -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
@@ -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),
|
||||
}
|
||||
Executable
+200
@@ -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),
|
||||
}
|
||||
Executable
+238
@@ -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,
|
||||
"单月中标个数",
|
||||
"单月中标金额(万元)",
|
||||
"个数份额环比变化(百分点)",
|
||||
"金额份额环比变化(百分点)",
|
||||
),
|
||||
}
|
||||
Executable
+202
@@ -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),
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
"""Application configuration helpers."""
|
||||
|
||||
Executable
+71
@@ -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()
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
"""Database helpers."""
|
||||
|
||||
Executable
+40
@@ -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
|
||||
|
||||
Executable
+51
@@ -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
@@ -0,0 +1,2 @@
|
||||
"""Excel import mappings and services."""
|
||||
|
||||
+146
@@ -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)
|
||||
Executable
+137
@@ -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
@@ -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
@@ -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()
|
||||
Executable
+492
@@ -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
@@ -0,0 +1 @@
|
||||
"""Tender statistics services."""
|
||||
+128
@@ -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
@@ -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),
|
||||
}
|
||||
Executable
+66
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
Executable
+108
@@ -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,
|
||||
},
|
||||
}
|
||||
Executable
+162
@@ -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,
|
||||
"个数环比增幅",
|
||||
"金额环比增幅",
|
||||
),
|
||||
}
|
||||
Executable
+140
@@ -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
|
||||
],
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+80
@@ -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
|
||||
```
|
||||
Executable
+1
@@ -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."""
|
||||
+561
@@ -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()
|
||||
+465
@@ -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()
|
||||
+663
@@ -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()
|
||||
+668
@@ -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()
|
||||
+780
@@ -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()
|
||||
+537
@@ -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()
|
||||
+349
@@ -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()
|
||||
+11
@@ -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
@@ -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 编号或表格数量变化,必须同步更新对应页面测试,防止脚本写错对象。
|
||||
@@ -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",
|
||||
)
|
||||
Executable
+17
@@ -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"
|
||||
Executable
+6
@@ -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
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""Local utility scripts for market_api."""
|
||||
+445
@@ -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())
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user