Files
inquiry_robot/inquiry-agent/agent/jobs/quote_fill.py

216 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
按主账 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