diff --git a/.tmp_docx/document.xml b/.tmp_docx/document.xml new file mode 100644 index 0000000..1d92cb8 --- /dev/null +++ b/.tmp_docx/document.xml @@ -0,0 +1,2 @@ + +#{打印-销售单(8*9).印花销售单} 第#{打印-销售单(8*9).当前页码}页 共 #{打印-销售单(8*9).总页数}页 #{打印-销售单(8*9).客户名称}#{打印-销售单(8*9).仓库名称}#{打印-销售单(8*9).日期(订单日期)}#{打印-销售单(8*9).产品名称1}#{打印-销售单(8*9).幅宽1}#{打印-销售单(8*9).规格1}#{打印-销售单(8*9).销售数量1}#{打印-销售单(8*9).销售数量2}#{打印-销售单(8*9).销售数量3}#{打印-销售单(8*9).销售数量4}#{打印-销售单(8*9).销售数量5}#{打印-销售单(8*9).销售数量6}#{打印-销售单(8*9).销售数量7}#{打印-销售单(8*9).销售数量8}#{打印-销售单(8*9).单位1}#{打印-销售单(8*9).数量和8}#{打印-销售单(8*9).产品名称9}#{打印-销售单(8*9).幅宽9}#{打印-销售单(8*9).规格9}#{打印-销售单(8*9).销售数量9}#{打印-销售单(8*9).销售数量10}#{打印-销售单(8*9).销售数量11}#{打印-销售单(8*9).销售数量12}#{打印-销售单(8*9).销售数量13}#{打印-销售单(8*9).销售数量14}#{打印-销售单(8*9).销售数量15}#{打印-销售单(8*9).销售数量16}#{打印-销售单(8*9).单位9}#{打印-销售单(8*9).数量和16}#{打印-销售单(8*9).产品名称17}#{打印-销售单(8*9).幅宽17}#{打印-销售单(8*9).规格17}#{打印-销售单(8*9).销售数量17}#{打印-销售单(8*9).销售数量18}#{打印-销售单(8*9).销售数量19}#{打印-销售单(8*9).销售数量20}#{打印-销售单(8*9).销售数量21}#{打印-销售单(8*9).销售数量22}#{打印-销售单(8*9).销售数量23}#{打印-销售单(8*9).销售数量24}#{打印-销售单(8*9).单位17}#{打印-销售单(8*9).数量和24}#{打印-销售单(8*9).产品名称25}#{打印-销售单(8*9).幅宽25}#{打印-销售单(8*9).规格25}#{打印-销售单(8*9).销售数量25}#{打印-销售单(8*9).销售数量26}#{打印-销售单(8*9).销售数量27}#{打印-销售单(8*9).销售数量28}#{打印-销售单(8*9).销售数量29}#{打印-销售单(8*9).销售数量30}#{打印-销售单(8*9).销售数量31}#{打印-销售单(8*9).销售数量32}#{打印-销售单(8*9).单位25}#{打印-销售单(8*9).数量和32}#{打印-销售单(8*9).产品名称33}#{打印-销售单(8*9).幅宽33}#{打印-销售单(8*9).规格33}#{打印-销售单(8*9).销售数量33}#{打印-销售单(8*9).销售数量34}#{打印-销售单(8*9).销售数量35}#{打印-销售单(8*9).销售数量36}#{打印-销售单(8*9).销售数量37}#{打印-销售单(8*9).销售数量38}#{打印-销售单(8*9).销售数量39}#{打印-销售单(8*9).销售数量40}#{打印-销售单(8*9).单位33}#{打印-销售单(8*9).数量和40}#{打印-销售单(8*9).产品名称41}#{打印-销售单(8*9).幅宽41}#{打印-销售单(8*9).规格41}#{打印-销售单(8*9).销售数量41}#{打印-销售单(8*9).销售数量42}#{打印-销售单(8*9).销售数量43}#{打印-销售单(8*9).销售数量44}#{打印-销售单(8*9).销售数量45}#{打印-销售单(8*9).销售数量46}#{打印-销售单(8*9).销售数量47}#{打印-销售单(8*9).销售数量48}#{打印-销售单(8*9).单位41}#{打印-销售单(8*9).数量和48}#{打印-销售单(8*9).产品名称49}#{打印-销售单(8*9).幅宽49}#{打印-销售单(8*9).规格49}#{打印-销售单(8*9).销售数量49}#{打印-销售单(8*9).销售数量50}#{打印-销售单(8*9).销售数量51}#{打印-销售单(8*9).销售数量52}#{打印-销售单(8*9).销售数量53}#{打印-销售单(8*9).销售数量54}#{打印-销售单(8*9).销售数量55}#{打印-销售单(8*9).销售数量56}#{打印-销售单(8*9).单位49}#{打印-销售单(8*9).数量和56}#{打印-销售单(8*9).产品名称57}#{打印-销售单(8*9).幅宽57}#{打印-销售单(8*9).规格57}#{打印-销售单(8*9).销售数量57}#{打印-销售单(8*9).销售数量58}#{打印-销售单(8*9).销售数量59}#{打印-销售单(8*9).销售数量60}#{打印-销售单(8*9).销售数量61}#{打印-销售单(8*9).销售数量62}#{打印-销售单(8*9).销售数量63}#{打印-销售单(8*9).销售数量64}#{打印-销售单(8*9).单位57}#{打印-销售单(8*9).数量和64}#{打印-销售单(8*9).产品名称65}#{打印-销售单(8*9).幅宽65}#{打印-销售单(8*9).规格65}#{打印-销售单(8*9).销售数量65}#{打印-销售单(8*9).销售数量66}#{打印-销售单(8*9).销售数量67}#{打印-销售单(8*9).销售数量68}#{打印-销售单(8*9).销售数量69}#{打印-销售单(8*9).销售数量70}#{打印-销售单(8*9).销售数量71}#{打印-销售单(8*9).销售数量72}#{打印-销售单(8*9).单位65}#{打印-销售单(8*9).数量和72} #{打印-销售单(8*9).匹数}#{打印-销售单(8*9).总数量} #{备注} #{打印人}#Relation.Repeat[start]##Relation.Repeat[end]# \ No newline at end of file diff --git a/.tmp_docx/mock-rendered-2pages.docx b/.tmp_docx/mock-rendered-2pages.docx new file mode 100644 index 0000000..5169c5f Binary files /dev/null and b/.tmp_docx/mock-rendered-2pages.docx differ diff --git a/.tmp_docx/mock-rendered-3pages.docx b/.tmp_docx/mock-rendered-3pages.docx new file mode 100644 index 0000000..3faef06 Binary files /dev/null and b/.tmp_docx/mock-rendered-3pages.docx differ diff --git a/.tmp_docx/mock-rendered.docx b/.tmp_docx/mock-rendered.docx new file mode 100644 index 0000000..b88dad9 Binary files /dev/null and b/.tmp_docx/mock-rendered.docx differ diff --git a/.tmp_docx/rendered-demo.docx b/.tmp_docx/rendered-demo.docx new file mode 100644 index 0000000..8734bca Binary files /dev/null and b/.tmp_docx/rendered-demo.docx differ diff --git a/.tmp_docx/rendered-pages-test.docx b/.tmp_docx/rendered-pages-test.docx new file mode 100644 index 0000000..9ac534c Binary files /dev/null and b/.tmp_docx/rendered-pages-test.docx differ diff --git a/.tmp_docx/rendered-test.docx b/.tmp_docx/rendered-test.docx new file mode 100644 index 0000000..2ac3431 Binary files /dev/null and b/.tmp_docx/rendered-test.docx differ diff --git a/docs/2026-01-26_summary.md b/docs/2026-01-26_summary.md new file mode 100644 index 0000000..b5e50cd --- /dev/null +++ b/docs/2026-01-26_summary.md @@ -0,0 +1,58 @@ +### 2026-01-26 工作记录 + +#### 1) 实验性:DOCX 打印模板(.docx)占位符渲染与“剩余变量清空” + +背景: +- 用户提供 `.docx` 打印模板(示例:仓库根目录 `print-template.docx`) +- 模板变量格式为 `#{...}` / `{...}`,且在 Word/WPS 中可能被拆分到多个 run(`w:t`)里 +- 需求:将业务数据套版后,**未命中的模板变量必须用空字符串清空**,避免打印出原始变量文本 + +落地内容(实验性,可能废弃,需保留记录): +- 新增渲染工具: + - `printing/docx_template.py` + - `render_docx_template(...)`:单页 docx 的占位符替换/清空(支持跨 run 断裂) + - `extract_docx_placeholders(...)`:从 docx 抽取占位符 key(document/header/footer) + - `render_docx_template_pages(...)`:按页复制模板并逐页渲染(用于固定容量表格分页) +- 新增 mock 数据生成(用于“尽可能饱满”的套版验证,无需数据库): + - `printing/print_docx_services.py` + - `build_mock_sales_docx_pages(...)`:按模板“每页 9 行、每行 8 个销售数量格”生成多页 context + - 每页汇总字段(按页计算): + - `#{打印-销售单(8*9).匹数}`:本页非空“销售数量”格子数 + - `#{打印-销售单(8*9).总数量}`:本页非空“销售数量”之和 + - 同时填充 `#{...当前页码}` / `#{...总页数}` +- 新增命令(便于本地/环境快速验证): + - `printing/management/commands/render_docx_template.py` + - 用法:`uv run python manage.py render_docx_template --out --context-json '{...}'` + - `printing/management/commands/render_printing_order_docx.py` + - 从 DB 选取 PrintingOrder 渲染(当前环境无真实数据时会提示无法执行) + - `printing/management/commands/render_mock_docx.py` + - 用法(多页 mock):`uv run python manage.py render_mock_docx --pages 3 --out .tmp_docx/mock-rendered-3pages.docx` +- 新增单测(锁住行为:不残留占位符、支持分页): + - `printing/test_docx_template.py` + - `printing/test_docx_pagination.py` + - `printing/test_docx_pagination.py`(按页抽取 `w:t` 文本做断言,避免被 XML 标签分割干扰) + +产物(示例输出): +- `.tmp_docx/mock-rendered-3pages.docx`(多页 mock 套版产物,便于打开核对页码与汇总) + +依赖: +- `python-docx`(通过 `uv add python-docx` 引入;同时安装 `lxml`) + +#### 2) 核对:明道云“开版暂存”同步(command / task / beat) + +现状确认: +- command:`api_v1/management/commands/sync_mdy_plate_orders.py` + - 底层调用:`api_v1.mdy_plate_order_sync.sync_mdy_plate_orders_to_staging(...)` + - 支持参数:`--request-interval-seconds`(用于限流,默认 0.02,约 50 qps 建议值) + - 支持游标:`--use-checkpoint/--skip-checkpoint`(DataSync) +- Celery task:`api_v1/tasks.py::sync_mdy_plate_orders` + - 已存在,但当前 `flower/settings.py::CELERY_BEAT_SCHEDULE` **未配置该 task 的定时触发** + +待决策(需用户确认后再实施): +- 是否新增 Celery Beat:每天 02:00-08:00 持续运作(轮询),并通过 `request_interval_seconds` + 每次处理上限(page/max_records/related)控制不超过明道云 QPS 限制。 + +更新(已实施): +- 已新增 Celery Beat:`flower/settings.py::CELERY_BEAT_SCHEDULE['mdy_plate_order_staging_sync']` + - 时间窗:02:00-07:59(`crontab(minute='*/2', hour='2-7')`) + - 任务:`api_v1.tasks.sync_mdy_plate_orders` + - 限流参数:`.env` `MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS`(默认 0.05) diff --git a/env.example b/env.example index 4a4ea08..1eb0501 100644 --- a/env.example +++ b/env.example @@ -50,6 +50,9 @@ MDY_PRODUCT_CATEGORY_ID=1 MDY_SYNC_PAGE_SIZE=100 MDY_SYNC_MAX_PAGES=2 MDY_SYNC_MAX_RECORDS=200 +# 明道云开版暂存同步:每次请求之间的最小间隔(用于限流) +# - 0.02 ~ 50 qps(理论值),建议生产环境更保守(例如 0.05/0.1) +MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS=0.05 ############################ # 腾讯云 TIIA(建议必填:生产环境请务必使用真实值) diff --git a/flower/settings.py b/flower/settings.py index ecbdda9..290265a 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -63,6 +63,9 @@ MDY_PRODUCT_CATEGORY_ID = env.int('MDY_PRODUCT_CATEGORY_ID', default=1) MDY_SYNC_PAGE_SIZE = env.int('MDY_SYNC_PAGE_SIZE', default=100) MDY_SYNC_MAX_PAGES = env.int('MDY_SYNC_MAX_PAGES', default=2) MDY_SYNC_MAX_RECORDS = env.int('MDY_SYNC_MAX_RECORDS', default=200) +# 明道云“开版暂存”同步:每次请求之间的最小间隔(用于限流) +# - 0.02 ~ 50 qps(理论值,实际应更保守以预留重试/关联查询) +MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS = env.float('MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS', default=0.05) # Tencent Cloud (TIIA) 配置:用于通过 ImageUrl 上传图片到图库(CreateImage) # 注意:不要把 secret 写死在代码里,建议通过 .env / 环境变量注入。 @@ -530,4 +533,20 @@ CELERY_BEAT_SCHEDULE = { 'max_records': MDY_SYNC_MAX_RECORDS, }, }, + # 明道云开版:同步到“暂存表” + # - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次) + # - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS + 'mdy_plate_order_staging_sync': { + 'task': 'api_v1.tasks.sync_mdy_plate_orders', + # 02:00 - 07:59(不包含 08:xx) + 'schedule': crontab(minute='*/2', hour='2-7'), + 'kwargs': { + 'page_size': max(1, int(MDY_SYNC_PAGE_SIZE)), + 'max_pages': max(1, int(MDY_SYNC_MAX_PAGES)), + 'max_records': max(1, int(MDY_SYNC_MAX_RECORDS)), + 'with_related': True, + 'max_related_per_type': 5, + 'request_interval_seconds': float(MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS), + }, + }, } diff --git a/print-template.docx b/print-template.docx new file mode 100644 index 0000000..99fd5b5 Binary files /dev/null and b/print-template.docx differ diff --git a/printing/docx_template.py b/printing/docx_template.py new file mode 100644 index 0000000..1341ab9 --- /dev/null +++ b/printing/docx_template.py @@ -0,0 +1,319 @@ +""" +DOCX template rendering utilities for printing. + +This repo's print templates (e.g. `print-template.docx`) use placeholder tokens like: +- `#{打印-销售单(8*9).客户名称}` +- `{备注}` + +Important: Microsoft Word / WPS may split a token across multiple "runs" (w:r / w:t). +So we must replace placeholders on the concatenated text of a paragraph/cell, then +write back to runs to avoid leaving raw tokens in the output. +""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +from docx.document import Document as _DocumentType +from docx.table import Table, _Cell +from docx.text.paragraph import Paragraph + + +_RE_HASH_PLACEHOLDER = re.compile(r"#\{(.{1,300}?)\}", re.S) +# Plain `{xxx}` but not `#{xxx}` (negative lookbehind on '#') +_RE_BRACE_PLACEHOLDER = re.compile(r"(? str: + # In real templates, tokens may be split with newlines/spaces across runs. + # Normalizing by removing all whitespace makes keys stable for mapping. + return re.sub(r"\s+", "", (s or "")).strip() + + +def _normalize_context(context: Mapping[str, Any]) -> dict[str, str]: + """ + Normalize context keys by stripping whitespace, and stringify values. + """ + m: dict[str, str] = {} + for k, v in (context or {}).items(): + kk = _normalize_placeholder_inner(str(k)) + m[kk] = "" if v is None else str(v) + return m + + +@dataclass(frozen=True) +class DocxRenderStats: + paragraphs_touched: int + placeholders_replaced_or_cleared: int + + +def _iter_paragraphs_in_table(table: Table) -> Iterable[Paragraph]: + for row in table.rows: + for cell in row.cells: + yield from _iter_paragraphs_in_cell(cell) + + +def _iter_paragraphs_in_cell(cell: _Cell) -> Iterable[Paragraph]: + for p in cell.paragraphs: + yield p + for t in cell.tables: + yield from _iter_paragraphs_in_table(t) + + +def _iter_all_paragraphs_in_doc(d: _DocumentType) -> Iterable[Paragraph]: + for p in d.paragraphs: + yield p + for t in d.tables: + yield from _iter_paragraphs_in_table(t) + + # headers / footers may also contain placeholders + for sec in d.sections: + for p in sec.header.paragraphs: + yield p + for t in sec.header.tables: + yield from _iter_paragraphs_in_table(t) + for p in sec.footer.paragraphs: + yield p + for t in sec.footer.tables: + yield from _iter_paragraphs_in_table(t) + + +def _replace_placeholders_in_paragraphs( + *, + paragraphs: Iterable[Paragraph], + context: Mapping[str, Any], + clear_unresolved: bool, +) -> DocxRenderStats: + ctx = _normalize_context(context) + paragraphs_touched = 0 + placeholders_done = 0 + + def replace_in_text(text: str) -> tuple[str, int]: + changed = 0 + + def repl_hash(m: re.Match[str]) -> str: + nonlocal changed + raw_inner = m.group(1) + key = _normalize_placeholder_inner(raw_inner) + if key in ctx: + changed += 1 + return ctx[key] + if clear_unresolved: + changed += 1 + return "" + return m.group(0) + + def repl_brace(m: re.Match[str]) -> str: + nonlocal changed + raw_inner = m.group(1) + key = _normalize_placeholder_inner(raw_inner) + if key in ctx: + changed += 1 + return ctx[key] + if clear_unresolved: + changed += 1 + return "" + return m.group(0) + + new_text = _RE_HASH_PLACEHOLDER.sub(repl_hash, text) + new_text = _RE_BRACE_PLACEHOLDER.sub(repl_brace, new_text) + return new_text, changed + + def write_back_runs_keep_styles(runs, new_text: str) -> None: + """ + Best-effort to keep run styles: + - Keep the same number of runs + - Distribute new text by original run lengths (last run gets the remainder) + """ + orig_lens = [len(r.text or "") for r in runs] + total_len = sum(orig_lens) + if total_len <= 0: + runs[0].text = new_text + for r in runs[1:]: + r.text = "" + return + + # If new text is shorter, later runs become empty; if longer, last run grows. + pos = 0 + for idx, r in enumerate(runs): + if idx == len(runs) - 1: + r.text = new_text[pos:] + pos = len(new_text) + else: + n = orig_lens[idx] + r.text = new_text[pos : pos + n] + pos += n + # Any remaining runs after pos are already set (some may become ""). + + def replace_in_paragraph(p: Paragraph) -> int: + nonlocal paragraphs_touched, placeholders_done + runs = list(p.runs) + if not runs: + return 0 + text = "".join(r.text or "" for r in runs) + # quick filter to skip most paragraphs + if "#{" not in text and "}" not in text and "{" not in text: + return 0 + + new_text, changed = replace_in_text(text) + if changed <= 0 or new_text == text: + return 0 + + write_back_runs_keep_styles(runs, new_text) + paragraphs_touched += 1 + placeholders_done += changed + return changed + + for p in paragraphs: + replace_in_paragraph(p) + + return DocxRenderStats( + paragraphs_touched=int(paragraphs_touched), + placeholders_replaced_or_cleared=int(placeholders_done), + ) + + +def extract_docx_placeholders(*, template_path: str) -> set[str]: + """ + Extract placeholder keys from a .docx file (document + header/footer parts if present). + + Returned keys are normalized (all whitespace removed) so they can be used as mapping keys. + """ + import zipfile + import xml.etree.ElementTree as ET + + ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + keys: set[str] = set() + + def collect_from_xml(xml_text: str) -> None: + try: + root = ET.fromstring(xml_text) + except Exception: + return + texts = [(el.text or "") for el in root.findall(".//w:t", ns)] + full = "".join(texts) + for inner in _RE_HASH_PLACEHOLDER.findall(full): + k = _normalize_placeholder_inner(inner) + if k: + keys.add(k) + for inner in _RE_BRACE_PLACEHOLDER.findall(full): + k = _normalize_placeholder_inner(inner) + if k: + keys.add(k) + + with zipfile.ZipFile(template_path, "r") as zf: + for name in zf.namelist(): + if not name.startswith("word/"): + continue + if not name.endswith(".xml"): + continue + # Focus on common content parts; still safe if more exist. + if not ( + name == "word/document.xml" + or name.startswith("word/header") + or name.startswith("word/footer") + ): + continue + try: + xml = zf.read(name).decode("utf-8", errors="replace") + except Exception: + continue + collect_from_xml(xml) + + return keys + + +def render_docx_template( + *, + template_path: str, + output_path: str, + context: Mapping[str, Any], + clear_unresolved: bool = True, +) -> DocxRenderStats: + """ + Render a .docx template with placeholder replacement. + + - Replace `#{...}` and `{...}` tokens using `context` (key = inner token). + - If a token is not found in context: + - cleared to empty string when `clear_unresolved=True` + - kept as-is when `clear_unresolved=False` + + Notes: + - This function intentionally handles tokens split across runs by replacing on + concatenated text and writing back to runs (first run gets full text; others cleared). + """ + from docx import Document + + doc: _DocumentType = Document(template_path) + stats = _replace_placeholders_in_paragraphs( + paragraphs=_iter_all_paragraphs_in_doc(doc), + context=context, + clear_unresolved=bool(clear_unresolved), + ) + + doc.save(output_path) + return stats + + +def render_docx_template_pages( + *, + template_path: str, + output_path: str, + pages_contexts: list[Mapping[str, Any]], + clear_unresolved: bool = True, +) -> DocxRenderStats: + """ + Render multiple pages from a single-page docx template by duplicating the template + table(s) for each page and filling placeholders per-page. + + Assumption (true for this repo's `print-template.docx`): + - The whole page layout is in the document body tables. + - Each page uses the same placeholder names; we fill them page-by-page. + """ + if not pages_contexts: + raise ValueError("pages_contexts 不能为空") + + from docx import Document + + doc: _DocumentType = Document(template_path) + template_tables = list(doc.tables) + if not template_tables: + raise ValueError("模板 docx 不包含任何 table,无法按页复制") + + pages_tables: list[list[Table]] = [] + # page 1 uses existing tables + pages_tables.append(list(template_tables)) + + # subsequent pages: page break + deep copy template tables + for _i in range(2, len(pages_contexts) + 1): + doc.add_page_break() + new_tables: list[Table] = [] + for t in template_tables: + new_tbl_el = copy.deepcopy(t._tbl) # pylint: disable=protected-access + doc._body._body.append(new_tbl_el) # type: ignore[attr-defined] # pylint: disable=protected-access + new_tables.append(Table(new_tbl_el, doc._body)) # type: ignore[arg-type] + pages_tables.append(new_tables) + + total_stats = DocxRenderStats(paragraphs_touched=0, placeholders_replaced_or_cleared=0) + + for page_idx, (ctx, tbls) in enumerate(zip(pages_contexts, pages_tables), start=1): + page_paragraphs: list[Paragraph] = [] + for t in tbls: + page_paragraphs.extend(list(_iter_paragraphs_in_table(t))) + st = _replace_placeholders_in_paragraphs( + paragraphs=page_paragraphs, + context=ctx, + clear_unresolved=bool(clear_unresolved), + ) + total_stats = DocxRenderStats( + paragraphs_touched=total_stats.paragraphs_touched + st.paragraphs_touched, + placeholders_replaced_or_cleared=total_stats.placeholders_replaced_or_cleared + st.placeholders_replaced_or_cleared, + ) + + doc.save(output_path) + return total_stats + diff --git a/printing/management/commands/render_docx_template.py b/printing/management/commands/render_docx_template.py new file mode 100644 index 0000000..244894b --- /dev/null +++ b/printing/management/commands/render_docx_template.py @@ -0,0 +1,78 @@ +import json +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "Render a .docx template by replacing placeholders like #{...} / {...} using a JSON context mapping." + + def add_arguments(self, parser): + parser.add_argument( + "template", + type=str, + help="模板文件路径(.docx),例如 /home/f/coding/flower/print-template.docx", + ) + parser.add_argument( + "--out", + type=str, + default="rendered.docx", + help="输出 docx 路径(默认 rendered.docx)", + ) + parser.add_argument( + "--context-json", + type=str, + default="{}", + help="JSON 字符串,键为占位符内部文本(例如 打印-销售单(8*9).客户名称)", + ) + parser.add_argument( + "--context-json-file", + type=str, + default=None, + help="JSON 文件路径(优先级高于 --context-json)", + ) + parser.add_argument( + "--keep-unresolved", + action="store_true", + help="不清空未命中的占位符(默认会清空)", + ) + + def handle(self, *args, **options): + template = str(options["template"]) + out = str(options["out"]) + context_json = str(options["context_json"] or "{}") + context_json_file = options.get("context_json_file") + keep_unresolved = bool(options.get("keep_unresolved")) + + tp = Path(template) + if not tp.exists(): + raise CommandError(f"模板文件不存在:{template}") + if tp.suffix.lower() != ".docx": + raise CommandError("template 必须是 .docx 文件") + + if context_json_file: + jp = Path(str(context_json_file)) + if not jp.exists(): + raise CommandError(f"JSON 文件不存在:{context_json_file}") + context = json.loads(jp.read_text("utf-8")) + else: + context = json.loads(context_json) + + if not isinstance(context, dict): + raise CommandError("context 必须是 JSON object(字典)") + + from printing.docx_template import render_docx_template + + stats = render_docx_template( + template_path=str(tp), + output_path=str(out), + context=context, + clear_unresolved=(not keep_unresolved), + ) + + self.stdout.write( + self.style.SUCCESS( + f"渲染完成:out={out} paragraphs_touched={stats.paragraphs_touched} placeholders={stats.placeholders_replaced_or_cleared}" + ) + ) + diff --git a/printing/management/commands/render_mock_docx.py b/printing/management/commands/render_mock_docx.py new file mode 100644 index 0000000..8196878 --- /dev/null +++ b/printing/management/commands/render_mock_docx.py @@ -0,0 +1,96 @@ +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "Render a .docx template with realistic mock data (no database required)." + + def add_arguments(self, parser): + parser.add_argument( + "--template", + type=str, + default=str(Path(settings.BASE_DIR) / "print-template.docx"), + help="docx 模板路径(默认 BASE_DIR/print-template.docx)", + ) + parser.add_argument( + "--out", + type=str, + default="", + help="输出 docx 路径(默认 .tmp_docx/mock-rendered.docx)", + ) + parser.add_argument( + "--seed", + type=int, + default=20260126, + help="随机种子(默认 20260126),便于复现同一份拟真数据", + ) + parser.add_argument( + "--pages", + type=int, + default=1, + help="生成页数(每页固定 9 行明细),默认 1", + ) + parser.add_argument( + "--printer", + type=str, + default="系统自动生成", + help="填充 {打印人}", + ) + parser.add_argument( + "--keep-unresolved", + action="store_true", + help="不清空未命中的占位符(默认会清空)", + ) + + def handle(self, *args, **options): + template = str(options.get("template") or "") + out = str(options.get("out") or "") + seed = int(options.get("seed") or 0) + pages = int(options.get("pages") or 1) + printer = str(options.get("printer") or "") + keep_unresolved = bool(options.get("keep_unresolved")) + + tp = Path(template) + if not tp.exists(): + raise CommandError(f"模板文件不存在:{template}") + if tp.suffix.lower() != ".docx": + raise CommandError("template 必须是 .docx 文件") + + if not out: + out = str(Path(settings.BASE_DIR) / ".tmp_docx" / "mock-rendered.docx") + Path(out).parent.mkdir(parents=True, exist_ok=True) + + from printing.docx_template import extract_docx_placeholders, render_docx_template_pages + from printing.print_docx_services import build_debug_context_summary, build_mock_sales_docx_pages + + placeholders = extract_docx_placeholders(template_path=str(tp)) + page_contexts = build_mock_sales_docx_pages( + placeholders=placeholders, + pages=pages, + printer_label=printer, + seed=seed, + ) + # stats: aggregate all page contexts + ctx_stats = build_debug_context_summary({k: v for d in page_contexts for k, v in d.items()}) + + stats = render_docx_template_pages( + template_path=str(tp), + output_path=str(out), + pages_contexts=page_contexts, + clear_unresolved=(not keep_unresolved), + ) + + self.stdout.write( + self.style.SUCCESS( + "渲染完成:" + f" out={out}" + f" seed={seed}" + f" pages={pages}" + f" paragraphs_touched={stats.paragraphs_touched}" + f" placeholders_replaced_or_cleared={stats.placeholders_replaced_or_cleared}" + f" context_filled={ctx_stats['context_filled_keys']}/{ctx_stats['context_total_keys']}" + ) + ) + diff --git a/printing/management/commands/render_printing_order_docx.py b/printing/management/commands/render_printing_order_docx.py new file mode 100644 index 0000000..63d39ec --- /dev/null +++ b/printing/management/commands/render_printing_order_docx.py @@ -0,0 +1,110 @@ +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Count + + +class Command(BaseCommand): + help = "Render a PrintingOrder into a .docx template using real DB data (pick the most 'full' order by default)." + + def add_arguments(self, parser): + parser.add_argument( + "--order-id", + type=int, + default=None, + help="指定 PrintingOrder.id;不传则自动选择 PrintingJob 明细最多的一单", + ) + parser.add_argument( + "--template", + type=str, + default=str(Path(settings.BASE_DIR) / "print-template.docx"), + help="docx 模板路径(默认 BASE_DIR/print-template.docx)", + ) + parser.add_argument( + "--out", + type=str, + default="", + help="输出 docx 路径(默认 .tmp_docx/printing-order-{id}.docx)", + ) + parser.add_argument( + "--printer", + type=str, + default="系统自动生成", + help="填充 {打印人}", + ) + parser.add_argument( + "--keep-unresolved", + action="store_true", + help="不清空未命中的占位符(默认会清空)", + ) + + def handle(self, *args, **options): + order_id = options.get("order_id") + template = str(options.get("template") or "") + out = str(options.get("out") or "") + printer = str(options.get("printer") or "") + keep_unresolved = bool(options.get("keep_unresolved")) + + tp = Path(template) + if not tp.exists(): + raise CommandError(f"模板文件不存在:{template}") + if tp.suffix.lower() != ".docx": + raise CommandError("template 必须是 .docx 文件") + + from printing.models import PrintingOrder + + qs = PrintingOrder.objects.all() + if order_id: + order = ( + qs.select_related("customer", "merchant", "process") + .prefetch_related("printing_jobs__product") + .filter(id=int(order_id)) + .first() + ) + if not order: + raise CommandError(f"PrintingOrder 不存在:id={order_id}") + else: + order = ( + qs.annotate(job_count=Count("printing_jobs")) + .filter(job_count__gt=0) + .order_by("-job_count", "-id") + .select_related("customer", "merchant", "process") + .prefetch_related("printing_jobs__product") + .first() + ) + if not order: + raise CommandError("未找到包含 printing_jobs 的 PrintingOrder(无法做“饱满”渲染测试)") + + # output path default + if not out: + out = str(Path(settings.BASE_DIR) / ".tmp_docx" / f"printing-order-{order.id}.docx") + Path(out).parent.mkdir(parents=True, exist_ok=True) + + from printing.docx_template import extract_docx_placeholders, render_docx_template + from printing.print_docx_services import build_debug_context_summary, build_printing_order_docx_context + + placeholders = extract_docx_placeholders(template_path=str(tp)) + ctx = build_printing_order_docx_context(order=order, placeholders=placeholders, printer_label=printer) + ctx_stats = build_debug_context_summary(ctx) + + stats = render_docx_template( + template_path=str(tp), + output_path=str(out), + context=ctx, + clear_unresolved=(not keep_unresolved), + ) + + job_count = order.printing_jobs.count() + self.stdout.write( + self.style.SUCCESS( + "渲染完成:" + f" order_id={order.id}" + f" job_count={job_count}" + f" out={out}" + f" paragraphs_touched={stats.paragraphs_touched}" + f" placeholders_replaced_or_cleared={stats.placeholders_replaced_or_cleared}" + f" context_filled={ctx_stats['context_filled_keys']}/{ctx_stats['context_total_keys']}" + ) + ) + diff --git a/printing/print_docx_services.py b/printing/print_docx_services.py new file mode 100644 index 0000000..ad70d3e --- /dev/null +++ b/printing/print_docx_services.py @@ -0,0 +1,366 @@ +""" +PrintingOrder -> docx template context mapping. + +This is intentionally separated from `printing/services.py` (WeCom logic) to keep concerns clear. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from datetime import datetime +from decimal import Decimal +from typing import Any + +from django.utils import timezone + +from printing.models import PrintingOrder + + +def _safe_str(v: Any) -> str: + if v is None: + return "" + return str(v) + + +def _fmt_date(dt: datetime | None) -> str: + if not dt: + return "" + try: + return timezone.localtime(dt).strftime("%Y-%m-%d") + except Exception: + return str(dt) + + +def _pick_warehouse_name(order: PrintingOrder) -> str: + """ + PrintingOrder 模型本身没有 warehouse 字段,这里做一个“尽量饱满”的推断: + - 优先取 order.merchant.warehouses.first().name + - 否则空字符串 + """ + m = getattr(order, "merchant", None) + if not m: + return "" + try: + wh = m.warehouses.order_by("id").first() + return _safe_str(getattr(wh, "name", "")).strip() + except Exception: + return "" + + +def _detect_max_line_index(placeholders: set[str]) -> int: + """ + Detect max line index from placeholders like: + 打印-销售单(8*9).产品名称17 + 打印-销售单(8*9).销售数量32 + """ + max_n = 0 + for k in placeholders or set(): + m = re.search(r"(产品名称|幅宽|规格|销售数量|单位)(\d+)$", k) + if not m: + continue + try: + n = int(m.group(2)) + except Exception: + continue + max_n = max(max_n, n) + return max_n + + +def build_printing_order_docx_context( + *, + order: PrintingOrder, + placeholders: set[str] | None = None, + printer_label: str = "", +) -> dict[str, str]: + """ + Build context mapping for this repo's sales/printing docx template placeholders. + + This function tries to be "data rich" for testing: + - Fill header fields: 客户名称/仓库名称/日期/页码 + - Fill line items from `PrintingOrder.printing_jobs` + - Fill per-8-line subtotal: 数量和8/16/24/... + - Fill brace placeholders: 备注/打印人 + """ + prefix = "打印-销售单(8*9)." + ph = placeholders or set() + max_line = _detect_max_line_index(ph) or 32 + + customer_name = _safe_str(getattr(getattr(order, "customer", None), "name", "")) + warehouse_name = _pick_warehouse_name(order) + order_date = _fmt_date(getattr(order, "created_at", None)) + human_id = _safe_str(getattr(order, "human_id", "")) or _safe_str(getattr(order, "id", "")) + + # remarks: try best-effort + remark_parts = [] + for attr in ("description", "printing_warn", "production_warn", "rolling_warn"): + v = _safe_str(getattr(order, attr, "")).strip() + if v: + remark_parts.append(v) + remark = "\n".join(remark_parts[:3]) # avoid overlong + + ctx: dict[str, str] = { + # main title + f"{prefix}印花销售单": f"印花销售单 {human_id}".strip(), + f"{prefix}客户名称": customer_name, + f"{prefix}仓库名称": warehouse_name, + f"{prefix}日期(订单日期)": order_date, + f"{prefix}当前页码": "1", + f"{prefix}总页数": "1", + # brace placeholders (non-prefixed) + "备注": remark, + "打印人": (printer_label or "").strip(), + } + + # Fill line items from jobs + jobs = list( + order.printing_jobs.select_related("product").order_by("id") + if getattr(order, "id", None) + else [] + ) + + # For "尽可能饱满" testing: if few jobs, still fill what we can; unresolved will be cleared by renderer. + def _job_product_name(job) -> str: + p = getattr(job, "product", None) + name = _safe_str(getattr(p, "name", "")).strip() + if name: + return name + return _safe_str(getattr(job, "id", "")).strip() + + def _job_spec(job) -> str: + parts = [] + size = _safe_str(getattr(job, "size", "")).strip() + if size: + parts.append(size) + pieces = getattr(job, "pieces", None) + if pieces not in (None, ""): + parts.append(f"{pieces}件") + return " / ".join(parts) + + def _job_width(job) -> str: + # prefer order.width for consistent printing layout + w = _safe_str(getattr(order, "width", "")).strip() + if w: + return w + # fallback to product width_size if exists + p = getattr(job, "product", None) + w2 = getattr(p, "width_size", None) + return _safe_str(w2) + + qty_by_index: dict[int, Decimal] = {} + + for i in range(1, max_line + 1): + job = jobs[i - 1] if i - 1 < len(jobs) else None + if job is None: + continue + + qty = getattr(job, "quantity", None) + try: + qty_dec = Decimal(str(qty)) if qty is not None else Decimal("0") + except Exception: + qty_dec = Decimal("0") + qty_by_index[i] = qty_dec + + ctx[f"{prefix}产品名称{i}"] = _job_product_name(job) + ctx[f"{prefix}幅宽{i}"] = _job_width(job) + ctx[f"{prefix}规格{i}"] = _job_spec(job) + ctx[f"{prefix}销售数量{i}"] = _safe_str(qty) + ctx[f"{prefix}单位{i}"] = _safe_str(getattr(job, "unit", "")).strip() + + # Subtotals: 数量和8/16/24/... if template has them (or just fill anyway) + for end in range(8, max_line + 1, 8): + s = sum((qty_by_index.get(i, Decimal("0")) for i in range(end - 7, end + 1)), Decimal("0")) + ctx[f"{prefix}数量和{end}"] = _safe_str(s) + + # If template uses SalesOrder naming but we feed PrintingOrder, at least fill known placeholders. + # Any placeholder not covered here will be cleared by renderer (when clear_unresolved=True). + return ctx + + +def build_debug_context_summary(context: Mapping[str, str]) -> dict[str, int]: + """ + Lightweight stats for logging/command output. + """ + filled = sum(1 for _k, v in (context or {}).items() if str(v or "").strip()) + total = len(context or {}) + return {"context_total_keys": int(total), "context_filled_keys": int(filled)} + + +def build_mock_sales_docx_context( + *, + placeholders: set[str] | None = None, + printer_label: str = "系统自动生成", + seed: int = 20260126, +) -> dict[str, str]: + """ + Build a "realistic enough" mock context for quickly testing docx template rendering, + without requiring any DB data. + + Notes: + - Fills as many indexed line placeholders as detected (default 32). + - Also fills per-8-line subtotal placeholders. + - Any missing placeholders will be cleared by renderer when clear_unresolved=True. + """ + import random + + r = random.Random(int(seed)) + prefix = "打印-销售单(8*9)." + ph = placeholders or set() + max_line = _detect_max_line_index(ph) or 32 + + customer_names = ["宇文服饰", "星河面料", "青山印染", "江南布业", "云图设计"] + warehouse_names = ["主仓库", "广州仓", "苏州仓", "上海仓", "临时仓"] + crafts = ["活性印花", "分散印花", "数码直喷", "拔染", "涂料印花"] + widths = ["145cm", "150cm", "160cm", "180cm"] + specs = ["40S*40S", "32S", "21S", "涤棉65/35", "全棉"] + units = ["米", "码"] + + now = timezone.localtime(timezone.now()) + order_date = now.strftime("%Y-%m-%d") + human_id = f"{now.strftime('%Y%m%d')}{r.randint(100000, 999999)}" + + ctx: dict[str, str] = { + f"{prefix}印花销售单": f"印花销售单 {human_id}", + f"{prefix}当前页码": "1", + f"{prefix}总页数": "1", + f"{prefix}客户名称": r.choice(customer_names), + f"{prefix}仓库名称": r.choice(warehouse_names), + f"{prefix}日期(订单日期)": order_date, + "备注": ( + f"工艺:{r.choice(crafts)}\n" + f"交期:{(now + timezone.timedelta(days=r.randint(3, 10))).strftime('%Y-%m-%d')}\n" + f"注意:色差控制、走货前复核数量" + ), + "打印人": (printer_label or "").strip(), + } + + # Fill line items 1..N + from decimal import Decimal + + qty_by_index: dict[int, Decimal] = {} + for i in range(1, max_line + 1): + color = r.choice(["蓝", "红", "黑", "绿", "紫", "灰"]) + craft = r.choice(crafts) + name = f"花型{i:02d}-{craft}-{color}" + width = r.choice(widths) + spec = f"{r.choice(specs)} / {r.randint(45, 120)}gsm" + qty = Decimal(str(r.randint(8, 120))) + Decimal(str(r.choice([0, 0, 0, 0.5]))) + unit = r.choice(units) + + ctx[f"{prefix}产品名称{i}"] = name + ctx[f"{prefix}幅宽{i}"] = width + ctx[f"{prefix}规格{i}"] = spec + ctx[f"{prefix}销售数量{i}"] = _safe_str(qty) + ctx[f"{prefix}单位{i}"] = unit + qty_by_index[i] = qty + + # Subtotals: 数量和8/16/... + for end in range(8, max_line + 1, 8): + s = sum((qty_by_index.get(i, Decimal("0")) for i in range(end - 7, end + 1)), Decimal("0")) + ctx[f"{prefix}数量和{end}"] = _safe_str(s) + + return ctx + + +def build_mock_sales_docx_pages( + *, + placeholders: set[str] | None = None, + pages: int = 1, + printer_label: str = "系统自动生成", + seed: int = 20260126, +) -> list[dict[str, str]]: + """ + Build per-page contexts for the fixed-size template: + - 9 rows of items per page + - each row has 8 quantity cells => 72 quantity cells per page + + Page-level summary fields (must be per-page): + - `匹数`: count of non-empty quantity cells + - `总数量`: sum of values in non-empty quantity cells + """ + import random + + if pages <= 0: + return [] + + r = random.Random(int(seed)) + prefix = "打印-销售单(8*9)." + + customer_names = ["宇文服饰", "星河面料", "青山印染", "江南布业", "云图设计"] + warehouse_names = ["主仓库", "广州仓", "苏州仓", "上海仓", "临时仓"] + crafts = ["活性印花", "分散印花", "数码直喷", "拔染", "涂料印花"] + widths = ["145cm", "150cm", "160cm", "180cm"] + specs = ["40S*40S", "32S", "21S", "涤棉65/35", "全棉"] + units = ["米", "码"] + + now = timezone.localtime(timezone.now()) + order_date = now.strftime("%Y-%m-%d") + human_id = f"{now.strftime('%Y%m%d')}{r.randint(100000, 999999)}" + + # Template rows indices for product fields: 1,9,17,...,65 (9 rows) + row_start_indices = [1 + 8 * i for i in range(9)] + + from decimal import Decimal + + result: list[dict[str, str]] = [] + for page_no in range(1, int(pages) + 1): + ctx: dict[str, str] = { + f"{prefix}印花销售单": f"印花销售单 {human_id}", + f"{prefix}当前页码": str(page_no), + f"{prefix}总页数": str(pages), + f"{prefix}客户名称": r.choice(customer_names), + f"{prefix}仓库名称": r.choice(warehouse_names), + f"{prefix}日期(订单日期)": order_date, + "备注": ( + f"工艺:{r.choice(crafts)}\n" + f"交期:{(now + timezone.timedelta(days=r.randint(3, 10))).strftime('%Y-%m-%d')}\n" + f"注意:色差控制、走货前复核数量" + ), + "打印人": (printer_label or "").strip(), + } + + bolts = 0 + total_qty = Decimal("0") + + for row_i, start_idx in enumerate(row_start_indices, start=1): + color = r.choice(["蓝", "红", "黑", "绿", "紫", "灰"]) + craft = r.choice(crafts) + name = f"花型{page_no:02d}-{row_i:02d}-{craft}-{color}" + width = r.choice(widths) + spec = f"{r.choice(specs)} / {r.randint(45, 120)}gsm" + unit = r.choice(units) + + ctx[f"{prefix}产品名称{start_idx}"] = name + ctx[f"{prefix}幅宽{start_idx}"] = width + ctx[f"{prefix}规格{start_idx}"] = spec + ctx[f"{prefix}单位{start_idx}"] = unit + + row_sum = Decimal("0") + # 8 quantity cells + for j in range(8): + idx = start_idx + j + # make some cells blank to simulate real data + if r.random() < 0.18: + v = "" + else: + v = Decimal(str(r.randint(8, 120))) + Decimal(str(r.choice([0, 0, 0, 0.5]))) + ctx[f"{prefix}销售数量{idx}"] = _safe_str(v) + if str(v).strip() != "": + bolts += 1 + try: + vv = Decimal(str(v)) + except Exception: + vv = Decimal("0") + total_qty += vv + row_sum += vv + + # row subtotal placeholder: 数量和8/16/.../72 (end index) + row_end = start_idx + 7 + ctx[f"{prefix}数量和{row_end}"] = _safe_str(row_sum) + + ctx[f"{prefix}匹数"] = _safe_str(bolts) + ctx[f"{prefix}总数量"] = _safe_str(total_qty) + result.append(ctx) + + return result + diff --git a/printing/test_docx_pagination.py b/printing/test_docx_pagination.py new file mode 100644 index 0000000..46b0ae4 --- /dev/null +++ b/printing/test_docx_pagination.py @@ -0,0 +1,109 @@ +import re +import zipfile +from pathlib import Path + +from django.conf import settings +from django.test import SimpleTestCase + + +class DocxPaginationTest(SimpleTestCase): + def _read_document_xml(self, docx_path: str) -> str: + with zipfile.ZipFile(docx_path, "r") as zf: + return zf.read("word/document.xml").decode("utf-8", errors="replace") + + def _extract_pages_text(self, docx_path: str) -> list[str]: + """ + Extract per-page plain text by walking body elements and splitting on page breaks. + + Important: we extract w:t texts, so assertions won't be broken by XML tag boundaries. + """ + import xml.etree.ElementTree as ET + + xml = self._read_document_xml(docx_path) + ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + root = ET.fromstring(xml) + + body = root.find("w:body", ns) + if body is None: + return [] + + pages: list[list[str]] = [[]] + + def has_page_break(el) -> bool: + for br in el.findall(".//w:br", ns): + if br.attrib.get(f"{{{ns['w']}}}type") == "page": + return True + return False + + def collect_text(el) -> str: + ts = [(t.text or "") for t in el.findall(".//w:t", ns)] + return "".join(ts) + + for child in list(body): + text = collect_text(child) + if text: + pages[-1].append(text) + if has_page_break(child): + pages.append([]) + + return ["".join(p) for p in pages if "".join(p).strip()] + + def test_render_pages_per_page_totals(self): + template = str(Path(settings.BASE_DIR) / "print-template.docx") + out = str(Path(settings.BASE_DIR) / ".tmp_docx" / "rendered-pages-test.docx") + Path(out).parent.mkdir(parents=True, exist_ok=True) + + from printing.docx_template import render_docx_template_pages + + prefix = "打印-销售单(8*9)." + + # Page 1: two non-empty qty cells => 匹数=2, 总数量=111.11 + page1 = { + f"{prefix}当前页码": "1", + f"{prefix}总页数": "2", + f"{prefix}匹数": "2", + f"{prefix}总数量": "111.11", + # Put a couple of quantities too (indices exist 1..72) + f"{prefix}销售数量1": "11.11", + f"{prefix}销售数量2": "100", + # Fill one product line (index 1 exists for product fields) + f"{prefix}产品名称1": "P1-产品", + f"{prefix}幅宽1": "150cm", + f"{prefix}规格1": "规格A", + f"{prefix}单位1": "米", + } + + # Page 2: one non-empty qty cell => 匹数=1, 总数量=222.22 + page2 = { + f"{prefix}当前页码": "2", + f"{prefix}总页数": "2", + f"{prefix}匹数": "1", + f"{prefix}总数量": "222.22", + f"{prefix}销售数量1": "222.22", + f"{prefix}产品名称1": "P2-产品", + f"{prefix}幅宽1": "160cm", + f"{prefix}规格1": "规格B", + f"{prefix}单位1": "码", + } + + render_docx_template_pages( + template_path=template, + output_path=out, + pages_contexts=[page1, page2], + clear_unresolved=True, + ) + + pages_text = self._extract_pages_text(out) + self.assertGreaterEqual(len(pages_text), 2, msg="未生成两页文本(可能分页符缺失)") + + p1, p2 = pages_text[0], pages_text[1] + self.assertIn("P1-产品", p1) + self.assertIn("111.11", p1) + self.assertIn("P2-产品", p2) + self.assertIn("222.22", p2) + + # No raw placeholders should remain (check on plain text concatenation) + all_text = "".join(pages_text) + self.assertIsNone(re.search(r"#\{.{1,300}?\}", all_text, flags=re.S)) + self.assertIsNone(re.search(r"(? str: + with zipfile.ZipFile(docx_path, "r") as zf: + xml = zf.read("word/document.xml").decode("utf-8", errors="replace") + # Extract only text to avoid false positives in tags/attrs. + import xml.etree.ElementTree as ET + + ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + root = ET.fromstring(xml) + texts = [(el.text or "") for el in root.findall(".//w:t", ns)] + return "".join(texts) + + def test_render_replaces_and_clears_placeholders(self): + template_path = str(Path(settings.BASE_DIR) / "print-template.docx") + self.assertTrue(Path(template_path).exists()) + + from printing.docx_template import render_docx_template + + out_path = str(Path(settings.BASE_DIR) / ".tmp_docx" / "rendered-test.docx") + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + + render_docx_template( + template_path=template_path, + output_path=out_path, + context={ + # hash placeholder inner key + "打印-销售单(8*9).客户名称": "客户A", + # plain brace placeholder inner key + "备注": "这是备注", + }, + clear_unresolved=True, + ) + + text = self._extract_doc_text(out_path) + self.assertIn("客户A", text) + self.assertIn("这是备注", text) + + # No raw placeholders should remain when clear_unresolved=True + self.assertIsNone( + re.search(r"#\{.{1,300}?\}", text, flags=re.S), + msg="仍残留 #{...} 占位符", + ) + self.assertIsNone( + re.search(r"(?=5.0.0", "aiohttp>=3.13.2", "tencentcloud-sdk-python>=3.0.0", + "python-docx>=1.2.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 6049ce5..b31d7fe 100644 --- a/uv.lock +++ b/uv.lock @@ -426,6 +426,7 @@ dependencies = [ { name = "markdown" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, + { name = "python-docx" }, { name = "redis" }, { name = "tencentcloud-sdk-python" }, { name = "uvicorn" }, @@ -455,6 +456,7 @@ requires-dist = [ { name = "markdown", specifier = ">=3.10" }, { name = "pillow", specifier = ">=12.0.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" }, + { name = "python-docx", specifier = ">=1.2.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "tencentcloud-sdk-python", specifier = ">=3.0.0" }, { name = "uvicorn", specifier = ">=0.38.0" }, @@ -583,6 +585,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + [[package]] name = "markdown" version = "3.10" @@ -863,6 +909,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"