""" 报价模板 AI 映射入队路由。 本文件职责:校验 callback token,接收主账单元格清单,写入 recognition Stream 后快回。 禁止:在本请求线程调千问 / 等完整 mapping;禁止直连 MySQL。 Worker:recognition 槽认领 kind=quote_template_parse 后跑 mode_sheet_map。 """ from __future__ import annotations import logging from typing import Any, Optional from fastapi import APIRouter, Header, HTTPException, Response from pydantic import BaseModel, Field from agent.config import get_settings from agent.jobs.recognition import enqueue_recognition logger = logging.getLogger(__name__) router = APIRouter(prefix="/internal/quote-template", tags=["quote-template"]) class QuoteTemplateParseBody(BaseModel): """主账提交的模板映射解析任务。""" version_id: str = Field(default="", alias="versionId") template_id: str = Field(default="", alias="templateId") template_name: str = Field(default="", alias="templateName") file_name: str = Field(default="", alias="fileName") biz_type: str = Field(default="", alias="bizType") transport_mode: str = Field(default="sea", alias="transportMode") sheet: str = "" cell_inventory: list[Any] = Field(default_factory=list, alias="cellInventory") cell_count: int = Field(default=0, alias="cellCount") yellow_count: int = Field(default=0, alias="yellowCount") truncated: bool = False request_id: str = Field(default="", alias="requestId") # 未换文件重新解析:true + existingMapping,回写时智能合并(对齐保留、对不上改坐标) merge_existing: bool = Field(default=False, alias="mergeExisting") existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping") model_config = {"populate_by_name": True} def _expected_token() -> str: s = get_settings() return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip() def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None: expected = _expected_token() if not expected: if (get_settings().ytd_env or "").lower() == "prod": raise HTTPException(status_code=503, detail="callback token 未配置") logger.warning("quote-template parse:未配置 callback token,test 允许空校验") return provided = (x_agent_token or "").strip() if not provided and authorization: auth = authorization.strip() if auth.lower().startswith("bearer "): provided = auth[7:].strip() else: provided = auth if provided != expected: raise HTTPException(status_code=401, detail="unauthorized") @router.get("/ping") def parse_ping() -> dict[str, str]: return {"status": "ok", "channel": "quote-template-parse"} @router.post("/parse") def enqueue_parse( body: QuoteTemplateParseBody, response: Response, authorization: Optional[str] = Header(default=None), x_agent_callback_token: Optional[str] = Header( default=None, alias="X-Agent-Callback-Token" ), ) -> dict[str, Any]: """ 接收主账解析任务:入 recognition 队列后立即 202。 不在此调用千问。 """ _check_token(authorization, x_agent_callback_token) version_id = (body.version_id or "").strip() if not version_id: raise HTTPException(status_code=400, detail="versionId 必填") inventory = body.cell_inventory or [] if not inventory: raise HTTPException(status_code=400, detail="cellInventory 不能为空") payload = { "versionId": version_id, "templateId": body.template_id, "templateName": body.template_name, "fileName": body.file_name, "bizType": body.biz_type, "transportMode": (body.transport_mode or "sea").strip().lower() or "sea", "sheet": body.sheet or "", "cellInventory": inventory, "cellCount": body.cell_count or len(inventory), "yellowCount": body.yellow_count, "truncated": bool(body.truncated), "mergeExisting": bool(body.merge_existing), } if body.merge_existing and isinstance(body.existing_mapping, dict): payload["existingMapping"] = body.existing_mapping result = enqueue_recognition( kind="quote_template_parse", payload=payload, idempotency_key=(body.request_id or "").strip() or f"quote-tpl-parse:{version_id}:{len(inventory)}", ) if not result.accepted: # 幂等重复:视为已接受,避免主账把「重复」当成失败 if result.reason == "duplicate_idempotency_key": response.status_code = 202 return {"accepted": True, "entry_id": "", "reason": "duplicate_idempotency_key"} response.status_code = 503 return {"accepted": False, "reason": result.reason or "enqueue_failed"} response.status_code = 202 logger.info( "quote_template.parse.enqueued versionId=%s cells=%s entry=%s", version_id, len(inventory), result.entry_id, ) return {"accepted": True, "entry_id": result.entry_id}