落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user