diff --git a/480T/market_api/.env b/480T/market_api/.env new file mode 100755 index 0000000..e1813ed --- /dev/null +++ b/480T/market_api/.env @@ -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= \ No newline at end of file diff --git a/480T/market_api/.env.example b/480T/market_api/.env.example new file mode 100755 index 0000000..98c8d27 --- /dev/null +++ b/480T/market_api/.env.example @@ -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= diff --git a/480T/market_api/Dockerfile b/480T/market_api/Dockerfile new file mode 100755 index 0000000..92bd2ab --- /dev/null +++ b/480T/market_api/Dockerfile @@ -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"] diff --git a/480T/market_api/README.md b/480T/market_api/README.md new file mode 100755 index 0000000..ef455a3 --- /dev/null +++ b/480T/market_api/README.md @@ -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 +``` \ No newline at end of file diff --git a/480T/market_api/compose.yaml b/480T/market_api/compose.yaml new file mode 100755 index 0000000..fdb0e99 --- /dev/null +++ b/480T/market_api/compose.yaml @@ -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 diff --git a/480T/market_api/docker-compose.yaml b/480T/market_api/docker-compose.yaml old mode 100644 new mode 100755 index e69de29..d727f30 --- a/480T/market_api/docker-compose.yaml +++ b/480T/market_api/docker-compose.yaml @@ -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 diff --git a/480T/market_api/market_api/__init__.py b/480T/market_api/market_api/__init__.py new file mode 100755 index 0000000..d0ab6d2 --- /dev/null +++ b/480T/market_api/market_api/__init__.py @@ -0,0 +1,2 @@ +"""FastAPI service for monthly market data imports and statistics.""" + diff --git a/480T/market_api/market_api/award/__init__.py b/480T/market_api/market_api/award/__init__.py new file mode 100755 index 0000000..ef9e946 --- /dev/null +++ b/480T/market_api/market_api/award/__init__.py @@ -0,0 +1,2 @@ +"""Award statistics services.""" + diff --git a/480T/market_api/market_api/award/common.py b/480T/market_api/market_api/award/common.py new file mode 100755 index 0000000..3afadd8 --- /dev/null +++ b/480T/market_api/market_api/award/common.py @@ -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}" + diff --git a/480T/market_api/market_api/award/competitor_large_deals.py b/480T/market_api/market_api/award/competitor_large_deals.py new file mode 100755 index 0000000..f3b8e41 --- /dev/null +++ b/480T/market_api/market_api/award/competitor_large_deals.py @@ -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), + } diff --git a/480T/market_api/market_api/award/industry.py b/480T/market_api/market_api/award/industry.py new file mode 100755 index 0000000..ca4d8c1 --- /dev/null +++ b/480T/market_api/market_api/award/industry.py @@ -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), + } diff --git a/480T/market_api/market_api/award/overall.py b/480T/market_api/market_api/award/overall.py new file mode 100755 index 0000000..ace17a4 --- /dev/null +++ b/480T/market_api/market_api/award/overall.py @@ -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, + "单月中标个数", + "单月中标金额(万元)", + "个数份额环比变化(百分点)", + "金额份额环比变化(百分点)", + ), + } diff --git a/480T/market_api/market_api/award/region.py b/480T/market_api/market_api/award/region.py new file mode 100755 index 0000000..5aa132b --- /dev/null +++ b/480T/market_api/market_api/award/region.py @@ -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), + } diff --git a/480T/market_api/market_api/core/__init__.py b/480T/market_api/market_api/core/__init__.py new file mode 100755 index 0000000..0faa80b --- /dev/null +++ b/480T/market_api/market_api/core/__init__.py @@ -0,0 +1,2 @@ +"""Application configuration helpers.""" + diff --git a/480T/market_api/market_api/core/config.py b/480T/market_api/market_api/core/config.py new file mode 100755 index 0000000..5fcd4f2 --- /dev/null +++ b/480T/market_api/market_api/core/config.py @@ -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() diff --git a/480T/market_api/market_api/db/__init__.py b/480T/market_api/market_api/db/__init__.py new file mode 100755 index 0000000..5b6f4bb --- /dev/null +++ b/480T/market_api/market_api/db/__init__.py @@ -0,0 +1,2 @@ +"""Database helpers.""" + diff --git a/480T/market_api/market_api/db/postgres.py b/480T/market_api/market_api/db/postgres.py new file mode 100755 index 0000000..da7058a --- /dev/null +++ b/480T/market_api/market_api/db/postgres.py @@ -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 + diff --git a/480T/market_api/market_api/db/sql.py b/480T/market_api/market_api/db/sql.py new file mode 100755 index 0000000..e1e771b --- /dev/null +++ b/480T/market_api/market_api/db/sql.py @@ -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" + diff --git a/480T/market_api/market_api/importer/__init__.py b/480T/market_api/market_api/importer/__init__.py new file mode 100755 index 0000000..d22f178 --- /dev/null +++ b/480T/market_api/market_api/importer/__init__.py @@ -0,0 +1,2 @@ +"""Excel import mappings and services.""" + diff --git a/480T/market_api/market_api/importer/mappings.py b/480T/market_api/market_api/importer/mappings.py new file mode 100755 index 0000000..bd94520 --- /dev/null +++ b/480T/market_api/market_api/importer/mappings.py @@ -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) diff --git a/480T/market_api/market_api/importer/rows.py b/480T/market_api/market_api/importer/rows.py new file mode 100755 index 0000000..328ac7f --- /dev/null +++ b/480T/market_api/market_api/importer/rows.py @@ -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 + diff --git a/480T/market_api/market_api/importer/service.py b/480T/market_api/market_api/importer/service.py new file mode 100755 index 0000000..8fc06bc --- /dev/null +++ b/480T/market_api/market_api/importer/service.py @@ -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" diff --git a/480T/market_api/market_api/importer/workbook.py b/480T/market_api/market_api/importer/workbook.py new file mode 100755 index 0000000..25e3ce5 --- /dev/null +++ b/480T/market_api/market_api/importer/workbook.py @@ -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() diff --git a/480T/market_api/market_api/main.py b/480T/market_api/market_api/main.py new file mode 100755 index 0000000..08714fd --- /dev/null +++ b/480T/market_api/market_api/main.py @@ -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 + ], + } diff --git a/480T/market_api/market_api/tender/__init__.py b/480T/market_api/market_api/tender/__init__.py new file mode 100755 index 0000000..eb2c5ff --- /dev/null +++ b/480T/market_api/market_api/tender/__init__.py @@ -0,0 +1 @@ +"""Tender statistics services.""" diff --git a/480T/market_api/market_api/tender/class_trend.py b/480T/market_api/market_api/tender/class_trend.py new file mode 100755 index 0000000..fb85fda --- /dev/null +++ b/480T/market_api/market_api/tender/class_trend.py @@ -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 + ], + } diff --git a/480T/market_api/market_api/tender/classification.py b/480T/market_api/market_api/tender/classification.py new file mode 100755 index 0000000..ba146a9 --- /dev/null +++ b/480T/market_api/market_api/tender/classification.py @@ -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), + } diff --git a/480T/market_api/market_api/tender/common.py b/480T/market_api/market_api/tender/common.py new file mode 100755 index 0000000..f930da7 --- /dev/null +++ b/480T/market_api/market_api/tender/common.py @@ -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}" diff --git a/480T/market_api/market_api/tender/customer_139.py b/480T/market_api/market_api/tender/customer_139.py new file mode 100755 index 0000000..f34d3e4 --- /dev/null +++ b/480T/market_api/market_api/tender/customer_139.py @@ -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), + } diff --git a/480T/market_api/market_api/tender/industry.py b/480T/market_api/market_api/tender/industry.py new file mode 100755 index 0000000..52be6a5 --- /dev/null +++ b/480T/market_api/market_api/tender/industry.py @@ -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, + "个数环比增幅", + "金额环比增幅", + ), + } diff --git a/480T/market_api/market_api/tender/major_buyers.py b/480T/market_api/market_api/tender/major_buyers.py new file mode 100755 index 0000000..13b37d7 --- /dev/null +++ b/480T/market_api/market_api/tender/major_buyers.py @@ -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), + } diff --git a/480T/market_api/market_api/tender/overall.py b/480T/market_api/market_api/tender/overall.py new file mode 100755 index 0000000..b6ea305 --- /dev/null +++ b/480T/market_api/market_api/tender/overall.py @@ -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, + }, + } diff --git a/480T/market_api/market_api/tender/region.py b/480T/market_api/market_api/tender/region.py new file mode 100755 index 0000000..23e5824 --- /dev/null +++ b/480T/market_api/market_api/tender/region.py @@ -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, + "个数环比增幅", + "金额环比增幅", + ), + } diff --git a/480T/market_api/market_api/tender/time.py b/480T/market_api/market_api/tender/time.py new file mode 100755 index 0000000..cc91c3f --- /dev/null +++ b/480T/market_api/market_api/tender/time.py @@ -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 + ], + } diff --git a/480T/market_api/ppt_templates/上海市服务月报.pptx b/480T/market_api/ppt_templates/上海市服务月报.pptx new file mode 100755 index 0000000..48dc516 Binary files /dev/null and b/480T/market_api/ppt_templates/上海市服务月报.pptx differ diff --git a/480T/market_api/ppt_templates/行政区服务月报.pptx b/480T/market_api/ppt_templates/行政区服务月报.pptx new file mode 100755 index 0000000..1faa1ca Binary files /dev/null and b/480T/market_api/ppt_templates/行政区服务月报.pptx differ diff --git a/480T/market_api/ppt_update/README.md b/480T/market_api/ppt_update/README.md new file mode 100755 index 0000000..18664c0 --- /dev/null +++ b/480T/market_api/ppt_update/README.md @@ -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 +``` diff --git a/480T/market_api/ppt_update/__init__.py b/480T/market_api/ppt_update/__init__.py new file mode 100755 index 0000000..46cedf4 --- /dev/null +++ b/480T/market_api/ppt_update/__init__.py @@ -0,0 +1 @@ +"""PPTX update modules for service monthly reports.""" diff --git a/480T/market_api/ppt_update/administrative_district/README.md b/480T/market_api/ppt_update/administrative_district/README.md new file mode 100755 index 0000000..3c7e36e --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/README.md @@ -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 编号或表格数量变化,必须同步更新对应页面测试,防止脚本写错对象。 diff --git a/480T/market_api/ppt_update/administrative_district/__init__.py b/480T/market_api/ppt_update/administrative_district/__init__.py new file mode 100755 index 0000000..6f914b1 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/__init__.py @@ -0,0 +1 @@ +"""Administrative district service monthly report update scripts.""" diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P1_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P1_update.py new file mode 100755 index 0000000..1992d98 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P1_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P2_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P2_update.py new file mode 100755 index 0000000..f69fdd5 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P2_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P3_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P3_update.py new file mode 100755 index 0000000..8e37f42 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P3_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P4_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P4_update.py new file mode 100755 index 0000000..1861b89 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P4_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P5_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P5_update.py new file mode 100755 index 0000000..ef24844 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P5_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P6_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P6_update.py new file mode 100755 index 0000000..499fc47 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P6_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/P7_update.py b/480T/market_api/ppt_update/administrative_district/page_updates/P7_update.py new file mode 100755 index 0000000..42b7d33 --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/P7_update.py @@ -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() diff --git a/480T/market_api/ppt_update/administrative_district/page_updates/__init__.py b/480T/market_api/ppt_update/administrative_district/page_updates/__init__.py new file mode 100755 index 0000000..3bbea2d --- /dev/null +++ b/480T/market_api/ppt_update/administrative_district/page_updates/__init__.py @@ -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", +) diff --git a/480T/market_api/ppt_update/shanghai_city/README.md b/480T/market_api/ppt_update/shanghai_city/README.md new file mode 100755 index 0000000..b92c13e --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/README.md @@ -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 编号或表格数量变化,必须同步更新对应页面测试,防止脚本写错对象。 diff --git a/480T/market_api/ppt_update/shanghai_city/__init__.py b/480T/market_api/ppt_update/shanghai_city/__init__.py new file mode 100755 index 0000000..0ff91e4 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/__init__.py @@ -0,0 +1 @@ +"""Shanghai city service monthly report update scripts.""" diff --git a/480T/market_api/ppt_update/shanghai_city/fill_analysis_placeholders.py b/480T/market_api/ppt_update/shanghai_city/fill_analysis_placeholders.py new file mode 100755 index 0000000..ed0b363 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/fill_analysis_placeholders.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P1_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P1_update.py new file mode 100755 index 0000000..d626295 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P1_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P2_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P2_update.py new file mode 100755 index 0000000..331074c --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P2_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P3_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P3_update.py new file mode 100755 index 0000000..38e5d05 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P3_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P4_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P4_update.py new file mode 100755 index 0000000..b6df15f --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P4_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P5_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P5_update.py new file mode 100755 index 0000000..3ac0c13 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P5_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P6_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P6_update.py new file mode 100755 index 0000000..48ca058 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P6_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P7_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P7_update.py new file mode 100755 index 0000000..c44bc73 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P7_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P8_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P8_update.py new file mode 100755 index 0000000..6589343 --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P8_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/P9_update.py b/480T/market_api/ppt_update/shanghai_city/page_updates/P9_update.py new file mode 100755 index 0000000..8cf4fbb --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/P9_update.py @@ -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() diff --git a/480T/market_api/ppt_update/shanghai_city/page_updates/__init__.py b/480T/market_api/ppt_update/shanghai_city/page_updates/__init__.py new file mode 100755 index 0000000..6a8753d --- /dev/null +++ b/480T/market_api/ppt_update/shanghai_city/page_updates/__init__.py @@ -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", +) diff --git a/480T/market_api/pyproject.toml b/480T/market_api/pyproject.toml new file mode 100755 index 0000000..e6473e0 --- /dev/null +++ b/480T/market_api/pyproject.toml @@ -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" diff --git a/480T/market_api/requirements.txt b/480T/market_api/requirements.txt new file mode 100755 index 0000000..447d5c0 --- /dev/null +++ b/480T/market_api/requirements.txt @@ -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 diff --git a/480T/market_api/scripts/__init__.py b/480T/market_api/scripts/__init__.py new file mode 100755 index 0000000..61a014a --- /dev/null +++ b/480T/market_api/scripts/__init__.py @@ -0,0 +1 @@ +"""Local utility scripts for market_api.""" diff --git a/480T/market_api/scripts/generate_analysis_txt.py b/480T/market_api/scripts/generate_analysis_txt.py new file mode 100755 index 0000000..da27b0a --- /dev/null +++ b/480T/market_api/scripts/generate_analysis_txt.py @@ -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()) diff --git a/480T/n8n_service/docker-compose.yaml b/480T/n8n_service/docker-compose.yaml index e69de29..89b59b3 100644 --- a/480T/n8n_service/docker-compose.yaml +++ b/480T/n8n_service/docker-compose.yaml @@ -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: \ No newline at end of file