落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 14:09:45 +08:00
co-authored by Cursor
parent 8bdf4990a2
commit a70bce3c8f
138 changed files with 7239 additions and 1085 deletions
+9 -3
View File
@@ -238,9 +238,15 @@ def main() -> int:
from agent.quote_templates import list_templates_from_ledger
lt = list_templates_from_ledger(LedgerClient.from_settings(get_settings()))
assert lt.status_code == 501
assert lt.error and "list_quote_templates" in lt.error
# 主账可达时返回 2xx;不可达时 status_code=0 + error,不再假装 501
assert isinstance(lt.status_code, int)
assert lt.ok or lt.error or lt.data is not None
logger.info(
"OK: list_quote_templates status=%s ok=%s err=%s",
lt.status_code,
lt.ok,
(lt.error or "")[:80],
)
vf = validate_required_fields_shell(facts={}, require_formal=True)
assert vf.get("implemented") is False
+1
View File
@@ -18,6 +18,7 @@ from agent.graph.runtime import GraphRuntime
from agent.jobs.archive import archive_stream, process_archive_stub
from agent.jobs.libreoffice import LibreOfficeConverter
from agent.jobs.pdf_job import pdf_stream, process_pdf_job
from agent.jobs.quote_fill import fill_quote_workbook
from agent.jobs.recognition import process_recognition_stub
from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream
from agent.llm import LlmGateway, LlmMode
+25 -2
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass
from typing import Any, Optional
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
from agent.jobs.quote_fill import fill_then_convert_pdf
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
from agent.redis_coord.keys import STREAM_PDF
from agent.redis_coord.runtime import RedisRuntime
@@ -71,10 +72,32 @@ def process_pdf_job(
"""
处理一条 PDF 任务(调用方必须已占用 libreoffice 槽)。
缺 xlsx_path 或本机无 soffice 时返回 ok=False,不抛崩 Worker。
若 payload 含 template_xlsx + mapping_json + facts,先 quote_fill 再转 PDF。
缺路径或本机无 soffice 时返回 ok=False,不抛崩 Worker。
"""
path = str(data.get("xlsx_path") or "")
conv = converter or LibreOfficeConverter.from_settings()
template_xlsx = str(data.get("template_xlsx") or "")
mapping_json = data.get("mapping_json")
facts = data.get("facts")
work_dir = str(data.get("work_dir") or "")
if template_xlsx and mapping_json is not None and isinstance(facts, dict):
from pathlib import Path
import tempfile
wd = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="quote_fill_"))
fill, pdf = fill_then_convert_pdf(
source_xlsx=template_xlsx,
work_dir=wd,
mapping=mapping_json if not isinstance(mapping_json, str) else mapping_json,
facts=facts,
converter=conv,
)
if not fill.ok:
return LibreOfficeResult(ok=False, error=f"fill_failed:{fill.error}")
assert pdf is not None
return pdf
path = str(data.get("xlsx_path") or "")
if not path:
return LibreOfficeResult(ok=False, error="missing_xlsx_path")
return conv.convert_xlsx_to_pdf(path)
+215
View File
@@ -0,0 +1,215 @@
"""
按主账 mappingJson 向报价 Excel 写入已冻结事实。
本文件职责:复制模板 → 跳过黄区 → 写 fields / fee_rows → 产出工作副本路径。
调用方:Worker LibreOffice 槽之前;禁止在 HTTP 回调线程执行。
禁止:发明价格;直连 TMS;把本模块当六态 Owner。
"""
from __future__ import annotations
import json
import logging
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Optional
logger = logging.getLogger(__name__)
# 黄区近似 RGB(与 Java QuoteTemplateExcelSupport 对齐)
_YELLOW_RGB = {
"FFFFFF00",
"FFFF00",
"FFFFEB9C",
"FFFFC000",
"FFFFFF99",
"FFFFCC00",
"FFFFD966",
"FFFFF2CC",
"FFFFE699",
}
@dataclass(frozen=True)
class QuoteFillResult:
"""一次填表结果。"""
ok: bool
output_xlsx: str = ""
error: str = ""
fields_written: int = 0
def _col_letter_to_index(letter: str) -> int:
letter = (letter or "").strip().upper()
n = 0
for ch in letter:
if not ("A" <= ch <= "Z"):
continue
n = n * 26 + (ord(ch) - ord("A") + 1)
return max(n - 1, 0)
def _parse_cell_ref(cell_ref: str) -> tuple[str, int, int]:
"""
解析 Sheet!A1 → (sheet, row0, col0)。
"""
sheet = "Sheet1"
addr = cell_ref
if "!" in cell_ref:
sheet, addr = cell_ref.split("!", 1)
m = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", addr.strip())
if not m:
raise ValueError(f"非法单元格引用: {cell_ref}")
col = _col_letter_to_index(m.group(1))
row = int(m.group(2)) - 1
return sheet, row, col
def fill_quote_workbook(
*,
source_xlsx: str | Path,
output_xlsx: str | Path,
mapping: Mapping[str, Any] | str,
facts: Mapping[str, Any],
) -> QuoteFillResult:
"""
按 mapping 写入冻结事实。
参数:
source_xlsx:主账版本对象落地的原模板
output_xlsx:工作副本输出路径
mapping:mappingJson 对象或 JSON 字符串
facts:已冻结字段(含可选 fee_rows 列表)
副作用:写磁盘 xlsx;不调 LibreOffice、不打主账。
线程:必须在 Worker 槽内调用。
"""
try:
from openpyxl import load_workbook
except ImportError:
return QuoteFillResult(ok=False, error="openpyxl_not_installed")
src = Path(source_xlsx)
out = Path(output_xlsx)
if not src.is_file():
return QuoteFillResult(ok=False, error="source_not_found")
if isinstance(mapping, str):
try:
mapping_obj = json.loads(mapping)
except json.JSONDecodeError as exc:
return QuoteFillResult(ok=False, error=f"mapping_json_invalid:{exc}")
else:
mapping_obj = dict(mapping)
yellow = set(mapping_obj.get("fixedYellowCells") or [])
fields = mapping_obj.get("fields") or {}
fee_rows_cfg = mapping_obj.get("fee_rows")
out.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, out)
try:
wb = load_workbook(out)
except Exception as exc:
return QuoteFillResult(ok=False, error=f"open_failed:{exc}")
written = 0
try:
for code, cell_ref in fields.items():
if not cell_ref or cell_ref in yellow:
continue
if code == "fee_rows":
continue
val = facts.get(code)
if val is None:
_write_cell(wb, str(cell_ref), "")
continue
_write_cell(wb, str(cell_ref), str(val))
written += 1
if fee_rows_cfg and facts.get("fee_rows"):
written += _write_fee_rows(wb, fee_rows_cfg, facts.get("fee_rows"), yellow)
wb.save(out)
except Exception as exc:
logger.exception("quote_fill 写入失败")
return QuoteFillResult(ok=False, error=f"write_failed:{exc}")
finally:
wb.close()
logger.info("quote_fill ok fields=%s out=%s", written, out)
return QuoteFillResult(ok=True, output_xlsx=str(out), fields_written=written)
def _write_cell(wb, cell_ref: str, value: str) -> None:
sheet_name, row, col = _parse_cell_ref(cell_ref)
ws = wb[sheet_name] if sheet_name in wb.sheetnames else wb.worksheets[0]
ws.cell(row=row + 1, column=col + 1, value=value)
def _write_fee_rows(wb, fee_cfg: Mapping[str, Any], rows: Any, yellow: set[str]) -> int:
if not isinstance(rows, list) or not rows:
return 0
anchor = fee_cfg.get("anchor")
columns = fee_cfg.get("columns") or {}
if not anchor:
return 0
sheet_name, start_row, _ = _parse_cell_ref(str(anchor))
ws = wb[sheet_name] if sheet_name in wb.sheetnames else wb.worksheets[0]
n = 0
for i, row_obj in enumerate(rows):
if not isinstance(row_obj, Mapping):
continue
r = start_row + i
for key in ("name", "amount", "remark"):
col_letter = columns.get(key)
if not col_letter:
continue
col = _col_letter_to_index(str(col_letter))
ref = f"{ws.title}!{ _index_to_col(col) }{r + 1}"
if ref in yellow:
continue
ws.cell(row=r + 1, column=col + 1, value=str(row_obj.get(key) or ""))
n += 1
return n
def _index_to_col(idx: int) -> str:
n = idx + 1
s = ""
while n:
n, rem = divmod(n - 1, 26)
s = chr(65 + rem) + s
return s
def fill_then_convert_pdf(
*,
source_xlsx: str | Path,
work_dir: str | Path,
mapping: Mapping[str, Any] | str,
facts: Mapping[str, Any],
converter: Any,
) -> tuple[QuoteFillResult, Optional[Any]]:
"""
填表后在调用方已占用的 LibreOffice 单槽内转 PDF。
converter:LibreOfficeConverter 实例;本函数不抢槽。
返回:(FillResult, LibreOfficeResult|None)
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
out_xlsx = work / "filled.xlsx"
fill = fill_quote_workbook(
source_xlsx=source_xlsx,
output_xlsx=out_xlsx,
mapping=mapping,
facts=facts,
)
if not fill.ok:
return fill, None
pdf_result = converter.convert_xlsx_to_pdf(str(out_xlsx))
return fill, pdf_result
+2
View File
@@ -112,6 +112,7 @@ class LedgerClient:
path: str,
*,
json_body: Optional[dict[str, Any]] = None,
params: Optional[dict[str, Any]] = None,
idempotency_key: str = "",
) -> LedgerResponse:
"""
@@ -128,6 +129,7 @@ class LedgerClient:
method.upper(),
path,
json=json_body,
params=params,
headers=headers,
)
data: Any
+8 -4
View File
@@ -92,12 +92,16 @@ class LedgerApiShell:
def list_quote_templates(self, *, enabled_only: bool = True) -> LedgerResponse:
"""
报价模板列表壳。
报价模板列表。
真相:后台上传/启用;智能体只读已启用项,再经对象存储取字节。
真相:后台上传/启用;智能体只读已启用项,再经对象存储/本地路径取字节。
"""
_ = enabled_only
return self._nyi("list_quote_templates")
path = "/inquiry/quoteTemplate/agent/listEnabled"
return self.client.request_json(
"GET",
path,
params={"enabledOnly": str(bool(enabled_only)).lower()},
)
def wake_agent_ack(
self,
+86 -17
View File
@@ -1,10 +1,9 @@
"""
报价模板访问壳。
报价模板访问。
真相在主账:后台上传/启用/停用 → 对象存储存字节 → 主账存元数据。
智能体生成报价前经 ledger 拉取已启用模板指针,再从对象存储取字节。
真相在主账:后台上传/启用/停用 → 对象存储/本地上传路径 → 主账元数据 + mappingJson。
智能体生成前经 ledger 拉取已启用模板,再解析本地/下载字节后交 quote_fill。
本文件职责:声明「从主账列模板」的空 API;可选本地缓存目录仅联调。
禁止:把仓库 resources 当生产模板真相;禁止在本包发明价格或改 quoteVersion。
"""
@@ -16,6 +15,7 @@ from pathlib import Path
from typing import Any, Optional
from agent.ledger import LedgerClient, LedgerResponse
from agent.ledger.api_shell import LedgerApiShell
logger = logging.getLogger(__name__)
@@ -29,6 +29,12 @@ class TemplateEntry:
object_key: str = ""
enabled: bool = False
source: str = "ledger"
template_id: str = ""
version_no: int = 0
version_id: str = ""
mapping_json: str = ""
biz_type: str = ""
category: str = ""
def local_cache_dir() -> Path:
@@ -43,20 +49,53 @@ def local_cache_dir() -> Path:
def list_templates_from_ledger(
client: Optional[LedgerClient] = None,
*,
enabled_only: bool = True,
) -> LedgerResponse:
"""
向主账查询已启用报价模板列表(壳)。
向主账查询已启用报价模板列表。
正式路径以主账 OpenAPI 为准;当前返回 not_implemented,不假装本地文件即生产模板。
正式路径:GET /inquiry/quoteTemplate/agent/listEnabled
"""
c = client or LedgerClient.from_settings()
logger.info("quote_templates.list_from_ledger(壳,未打真实 API)")
return LedgerResponse(
ok=False,
status_code=501,
error="not_implemented:list_quote_templates",
data={"hint": "admin_upload_via_ledger"},
)
shell = LedgerApiShell(client=client)
return shell.list_quote_templates(enabled_only=enabled_only)
def entries_from_ledger_response(resp: LedgerResponse) -> list[TemplateEntry]:
"""把主账 listEnabled 结果转成 TemplateEntry。"""
if not resp.ok or resp.data is None:
return []
raw = resp.data
# Jeecg Result 可能包在 result / data
if isinstance(raw, dict):
items = raw.get("result") or raw.get("data") or raw.get("records") or []
if isinstance(raw.get("result"), list):
items = raw["result"]
elif isinstance(raw, list):
items = raw
else:
items = []
out: list[TemplateEntry] = []
for it in items:
if not isinstance(it, dict):
continue
tid = str(it.get("templateId") or it.get("id") or "")
out.append(
TemplateEntry(
code=tid or str(it.get("name") or ""),
filename=str(it.get("fileName") or ""),
object_key=str(it.get("objectKey") or ""),
enabled=True,
source="ledger",
template_id=tid,
version_no=int(it.get("versionNo") or 0),
version_id=str(it.get("versionId") or ""),
mapping_json=str(it.get("mappingJson") or ""),
biz_type=str(it.get("bizType") or ""),
category=str(it.get("category") or ""),
)
)
return out
def list_local_cache_files() -> list[TemplateEntry]:
@@ -91,10 +130,12 @@ def list_local_cache_files() -> list[TemplateEntry]:
def list_template_registry() -> list[TemplateEntry]:
"""
兼容旧名:框架阶段返回本地缓存列表(可空)。
生产应改用 list_templates_from_ledger + 对象存储下载。
优先主账;失败则回落本地缓存(可空)。
"""
resp = list_templates_from_ledger(enabled_only=True)
entries = entries_from_ledger_response(resp)
if entries:
return entries
return list_local_cache_files()
@@ -107,6 +148,34 @@ def registry_as_dicts() -> list[dict[str, Any]]:
"object_key": e.object_key,
"enabled": e.enabled,
"source": e.source,
"template_id": e.template_id,
"version_no": e.version_no,
"biz_type": e.biz_type,
"category": e.category,
}
for e in list_template_registry()
]
def pick_template_for_biz(
*,
biz_type: str,
keywords: Optional[list[str]] = None,
entries: Optional[list[TemplateEntry]] = None,
) -> Optional[TemplateEntry]:
"""
按业务线 + 关键词挑选模板(优先 is_default 由主账排序保证)。
关键词命中任一即优;否则同 biz 第一条。
"""
items = entries if entries is not None else list_template_registry()
same = [e for e in items if (e.biz_type == biz_type or not e.biz_type)]
if not same:
same = list(items)
if keywords:
kw_l = [k.lower() for k in keywords if k]
for e in same:
blob = f"{e.filename} {e.category} {e.code}".lower()
if any(k in blob for k in kw_l):
return e
return same[0] if same else None
+2
View File
@@ -14,3 +14,5 @@ psycopg[binary]>=3.1,<4.0
psycopg-pool>=3.2,<4.0
# Redis short-lived coordination only
redis>=5.0,<6.0
# 报价模板填表(按 mappingJson 写 xlsx)
openpyxl>=3.1,<4.0