""" 按海运询价单标签补写协同字段。 主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。 本文件只改 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)