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