完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,11 +3,16 @@
|
||||
|
||||
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
|
||||
本文件只改 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
|
||||
|
||||
@@ -30,6 +35,18 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("包装方式", ("包装方式", "包装类型")),
|
||||
)
|
||||
|
||||
# 旧路径兜底:若仍误走 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:
|
||||
@@ -39,6 +56,102 @@ def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
|
||||
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'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(val)}</t></is></c>'
|
||||
)
|
||||
pat = re.compile(rf'<c\b[^>]*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?</c>)')
|
||||
if pat.search(sheet):
|
||||
sheet = pat.sub(cell_xml, sheet, count=1)
|
||||
continue
|
||||
row_pat = re.compile(rf'(<row\b[^>]*\br="{int(row)}"[^>]*>)([\s\S]*?)(</row>)')
|
||||
m = row_pat.search(sheet)
|
||||
if m:
|
||||
sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :]
|
||||
continue
|
||||
sheet = sheet.replace(
|
||||
"</sheetData>",
|
||||
f'<row r="{int(row)}">{cell_xml}</row></sheetData>',
|
||||
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],
|
||||
@@ -48,7 +161,8 @@ def overlay_labeled_facts(
|
||||
"""
|
||||
按模板标签再写协同格。失败则原样交回。
|
||||
|
||||
副作用:可能写临时 xlsx。
|
||||
副作用:可能写临时 xlsx。无待写字段、或格子已是目标值时不改字节,
|
||||
避免毁掉海运顶部运输分类单选。
|
||||
"""
|
||||
import base64
|
||||
import tempfile
|
||||
@@ -91,20 +205,30 @@ def overlay_labeled_facts(
|
||||
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
|
||||
for r, c, val in pending:
|
||||
ws.cell(row=r, column=c, value=val)
|
||||
written += 1
|
||||
wb.save(tmp)
|
||||
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", written)
|
||||
logger.info("overlay 协同字段 written=%s keep_structure=1", written)
|
||||
return {"filepath": str(tmp), "file_b64": out_b64}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user