216 lines
6.2 KiB
Python
216 lines
6.2 KiB
Python
"""
|
||
按主账 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
|