"""
按海运询价单标签补写协同字段。
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
本文件只改 Excel 字节,不打主账、不占回调线程。
注意:openpyxl 保存会弄坏海运顶部 Form Control(drawing/VML/sheet1 锚点)。
因此只用来「读」标签位置;真正写入走 zip 内定点改格,不动控件骨架。
无待写字段、或目标格已是目标值时原样返回。
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Mapping
logger = logging.getLogger(__name__)
# 长的「含油,电,磁」必须排在「是否含油」前面,避免只写到油。
_COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("是否含油,电,磁", ("oil_elec_mag", "是否含油,电,磁")),
("OIL,ELEC,MAG", ("oil_elec_mag", "是否含油,电,磁")),
("贸易条款", ("贸易条款", "trade_terms")),
("货好时间", ("货好时间", "cargo_ready_date")),
("商品海关编码", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
("HS Code", ("商品海关编码", "HS编码", "海关编码", "hs_code")),
("是否含油", ("是否含油", "has_oil")),
("是否含电", ("是否含电", "has_battery")),
("是否含磁", ("是否含磁", "has_magnet")),
("客户名称", ("客户名称", "customer_name")),
("货值", ("货值", "cargo_value")),
("是否为危险品", ("是否为危险品", "is_dangerous")),
("包装方式", ("包装方式", "包装类型")),
)
# 旧路径兜底:若仍误走 openpyxl 保存,至少盖回这些部件
_PRESERVE_ZIP_PARTS = (
"xl/drawings/drawing1.xml",
"xl/drawings/vmlDrawing1.vml",
"xl/styles.xml",
"xl/ctrlProps/ctrlProp1.xml",
"xl/ctrlProps/ctrlProp2.xml",
"xl/worksheets/sheet1.xml",
"xl/worksheets/_rels/sheet1.xml.rels",
"[Content_Types].xml",
)
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
val = str((facts or {}).get(key) or "").strip()
if val and val != "-":
return val
return ""
def _restore_sea_draw_parts(original: bytes, rewritten: bytes) -> bytes:
"""把原件里的 drawing/VML/ctrl/styles/sheet1 控件骨架盖回 openpyxl 重写后的 xlsx。"""
import io
import zipfile
try:
with zipfile.ZipFile(io.BytesIO(original)) as zin:
keep = {
name: zin.read(name)
for name in _PRESERVE_ZIP_PARTS
if name in zin.namelist()
}
if not keep:
return rewritten
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(rewritten)) as zin, zipfile.ZipFile(
out, "w", compression=zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
data = keep.get(info.filename) or zin.read(info.filename)
zout.writestr(info, data)
for name, data in keep.items():
if name not in zin.namelist():
zout.writestr(name, data)
return out.getvalue()
except Exception:
logger.exception("overlay 盖回海运 drawing 失败,退回 openpyxl 结果")
return rewritten
def _col_letter(col: int) -> str:
"""1-based 列号 → Excel 列字母。"""
n = int(col)
letters: list[str] = []
while n > 0:
n, rem = divmod(n - 1, 26)
letters.append(chr(65 + rem))
return "".join(reversed(letters)) or "A"
def _xml_escape(text: str) -> str:
return (
str(text)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
def _patch_cells_keep_structure(data: bytes, cells: list[tuple[int, int, str]]) -> bytes:
"""
只改 sheet1 里指定格的字符串,保留 AlternateContent / 控件关系。
禁止整表经 openpyxl 重存:否则拉群协同 Excel 顶部 Port/其它单选会空白,
而「直接生成」不经本函数所以看起来正常(WO031 vs WO033)。
"""
import io
import zipfile
if not cells:
return data
with zipfile.ZipFile(io.BytesIO(data)) as zin:
if "xl/worksheets/sheet1.xml" not in zin.namelist():
return data
sheet = zin.read("xl/worksheets/sheet1.xml").decode("utf-8", "replace")
others = {name: zin.read(name) for name in zin.namelist() if name != "xl/worksheets/sheet1.xml"}
for row, col, val in cells:
ref = f"{_col_letter(col)}{int(row)}"
cell_xml = (
f'{_xml_escape(val)}'
)
pat = re.compile(rf']*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?)')
if pat.search(sheet):
sheet = pat.sub(cell_xml, sheet, count=1)
continue
row_pat = re.compile(rf'(]*\br="{int(row)}"[^>]*>)([\s\S]*?)(
)')
m = row_pat.search(sheet)
if m:
sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :]
continue
sheet = sheet.replace(
"",
f'{cell_xml}
',
1,
)
out = io.BytesIO()
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, blob in others.items():
zout.writestr(name, blob)
zout.writestr("xl/worksheets/sheet1.xml", sheet.encode("utf-8"))
return out.getvalue()
def overlay_labeled_facts(
*,
facts: Mapping[str, Any],
filepath: str = "",
file_b64: str = "",
) -> dict[str, str]:
"""
按模板标签再写协同格。失败则原样交回。
副作用:可能写临时 xlsx。无待写字段、或格子已是目标值时不改字节,
避免毁掉海运顶部运输分类单选。
"""
import base64
import tempfile
raw = b""
if filepath:
try:
raw = Path(filepath).read_bytes()
except OSError as exc:
logger.warning("overlay 读文件失败 err=%s", exc)
if not raw and file_b64 and len(file_b64) > 64:
try:
raw = base64.b64decode(file_b64)
except Exception as exc: # noqa: BLE001
logger.warning("overlay 解码失败 err=%s", exc)
if not raw:
return {"filepath": filepath, "file_b64": file_b64}
try:
from openpyxl import load_workbook
except ImportError:
return {"filepath": filepath, "file_b64": file_b64}
tmp = Path(tempfile.mkdtemp(prefix="quote-overlay-")) / "filled.xlsx"
tmp.write_bytes(raw)
written = 0
try:
wb = load_workbook(tmp)
ws = wb.worksheets[0]
pending: list[tuple[int, int, str]] = []
used_rows: set[int] = set()
for row in ws.iter_rows(min_row=1, max_row=ws.max_row or 1, max_col=min(ws.max_column or 1, 16)):
for cell in row:
text = str(cell.value or "").strip()
if not text or cell.row in used_rows:
continue
keys = _match_collab_label(text)
if not keys:
continue
val = _fact_for_markers(facts, keys)
if not val:
continue
dest = _label_value_cell(ws, cell)
# 主账已填过同值(如贸易条款):再写会经 openpyxl 毁掉单选
if str(dest.value or "").strip() == val:
used_rows.add(cell.row)
break
pending.append((dest.row, dest.column, val))
used_rows.add(cell.row)
break
wb.close()
if not pending:
# 无增量可写:原样返回,禁止重存
logger.info("overlay 协同字段 written=0 keep_original")
return {
"filepath": filepath or str(tmp),
"file_b64": file_b64 or base64.b64encode(raw).decode("ascii"),
}
patched = _patch_cells_keep_structure(raw, pending)
tmp.write_bytes(patched)
written = len(pending)
except Exception:
logger.exception("overlay 协同字段失败")
return {"filepath": filepath, "file_b64": file_b64}
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
logger.info("overlay 协同字段 written=%s keep_structure=1", written)
return {"filepath": str(tmp), "file_b64": out_b64}
def _band_value_goes_below(ws, label_cell) -> bool:
"""同一行凑齐业务员、客户名称、始发地,就是集拼询价条带。"""
text = str(label_cell.value or "").replace(" ", "")
if not any(key in text for key in ("客户名称", "业务员", "始发地", "货物类型")):
return False
hits = 0
for cell in ws[label_cell.row]:
compact = str(cell.value or "").replace(" ", "").replace(":", "").replace(":", "")
if compact in {"业务员", "客户名称", "始发地", "货物类型", "条款", "发货日期"}:
hits += 1
return hits >= 3
def _match_collab_label(text: str) -> tuple[str, ...] | None:
raw = (text or "").replace(":", ":").strip()
for marker, keys in _COLLAB_LABEL_MARKERS:
if marker in raw:
return keys
return None
def _label_value_cell(ws, label_cell):
"""标签右侧格子;合并标签则从合并区右边开始。
跨境集拼蓝条是「业务员 | 客户名称 | 始发地」,值在下一行。
写到右侧会把客户名称填进「始发地」表头。
"""
if _band_value_goes_below(ws, label_cell):
col = label_cell.column
merged = getattr(ws, "merged_cells", None)
if merged is not None:
for rng in merged.ranges:
if (
rng.min_row <= label_cell.row <= rng.max_row
and rng.min_col <= label_cell.column <= rng.max_col
):
col = rng.min_col
break
return ws.cell(row=label_cell.row + 1, column=col)
row_idx = label_cell.row
start_col = label_cell.column + 1
merged = getattr(ws, "merged_cells", None)
if merged is not None:
for rng in merged.ranges:
if rng.min_row <= row_idx <= rng.max_row and rng.min_col <= label_cell.column <= rng.max_col:
start_col = rng.max_col + 1
break
return ws.cell(row=row_idx, column=start_col)