forked from erp-dev/erp
feat: added print-template(test) and sync_mdy_plate_order beat task setting
This commit is contained in:
2
.tmp_docx/document.xml
Normal file
2
.tmp_docx/document.xml
Normal file
File diff suppressed because one or more lines are too long
BIN
.tmp_docx/mock-rendered-2pages.docx
Normal file
BIN
.tmp_docx/mock-rendered-2pages.docx
Normal file
Binary file not shown.
BIN
.tmp_docx/mock-rendered-3pages.docx
Normal file
BIN
.tmp_docx/mock-rendered-3pages.docx
Normal file
Binary file not shown.
BIN
.tmp_docx/mock-rendered.docx
Normal file
BIN
.tmp_docx/mock-rendered.docx
Normal file
Binary file not shown.
BIN
.tmp_docx/rendered-demo.docx
Normal file
BIN
.tmp_docx/rendered-demo.docx
Normal file
Binary file not shown.
BIN
.tmp_docx/rendered-pages-test.docx
Normal file
BIN
.tmp_docx/rendered-pages-test.docx
Normal file
Binary file not shown.
BIN
.tmp_docx/rendered-test.docx
Normal file
BIN
.tmp_docx/rendered-test.docx
Normal file
Binary file not shown.
58
docs/2026-01-26_summary.md
Normal file
58
docs/2026-01-26_summary.md
Normal file
@@ -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 <template.docx> --out <out.docx> --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)
|
||||||
@@ -50,6 +50,9 @@ MDY_PRODUCT_CATEGORY_ID=1
|
|||||||
MDY_SYNC_PAGE_SIZE=100
|
MDY_SYNC_PAGE_SIZE=100
|
||||||
MDY_SYNC_MAX_PAGES=2
|
MDY_SYNC_MAX_PAGES=2
|
||||||
MDY_SYNC_MAX_RECORDS=200
|
MDY_SYNC_MAX_RECORDS=200
|
||||||
|
# 明道云开版暂存同步:每次请求之间的最小间隔(用于限流)
|
||||||
|
# - 0.02 ~ 50 qps(理论值),建议生产环境更保守(例如 0.05/0.1)
|
||||||
|
MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS=0.05
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# 腾讯云 TIIA(建议必填:生产环境请务必使用真实值)
|
# 腾讯云 TIIA(建议必填:生产环境请务必使用真实值)
|
||||||
|
|||||||
@@ -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_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_PAGES = env.int('MDY_SYNC_MAX_PAGES', default=2)
|
||||||
MDY_SYNC_MAX_RECORDS = env.int('MDY_SYNC_MAX_RECORDS', default=200)
|
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)
|
# Tencent Cloud (TIIA) 配置:用于通过 ImageUrl 上传图片到图库(CreateImage)
|
||||||
# 注意:不要把 secret 写死在代码里,建议通过 .env / 环境变量注入。
|
# 注意:不要把 secret 写死在代码里,建议通过 .env / 环境变量注入。
|
||||||
@@ -530,4 +533,20 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
'max_records': MDY_SYNC_MAX_RECORDS,
|
'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),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
print-template.docx
Normal file
BIN
print-template.docx
Normal file
Binary file not shown.
319
printing/docx_template.py
Normal file
319
printing/docx_template.py
Normal file
@@ -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"(?<!#)\{([^{}\r\n]{1,200})\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_placeholder_inner(s: str) -> 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
|
||||||
|
|
||||||
78
printing/management/commands/render_docx_template.py
Normal file
78
printing/management/commands/render_docx_template.py
Normal file
@@ -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}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
96
printing/management/commands/render_mock_docx.py
Normal file
96
printing/management/commands/render_mock_docx.py
Normal file
@@ -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']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
110
printing/management/commands/render_printing_order_docx.py
Normal file
110
printing/management/commands/render_printing_order_docx.py
Normal file
@@ -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']}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
366
printing/print_docx_services.py
Normal file
366
printing/print_docx_services.py
Normal file
@@ -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
|
||||||
|
|
||||||
109
printing/test_docx_pagination.py
Normal file
109
printing/test_docx_pagination.py
Normal file
@@ -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"(?<!#)\{[^{}\r\n]{1,200}\}", all_text))
|
||||||
|
|
||||||
55
printing/test_docx_template.py
Normal file
55
printing/test_docx_template.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class DocxTemplateRenderTest(SimpleTestCase):
|
||||||
|
def _extract_doc_text(self, docx_path: str) -> str:
|
||||||
|
with zipfile.ZipFile(docx_path, "r") as zf:
|
||||||
|
xml = zf.read("word/document.xml").decode("utf-8", errors="replace")
|
||||||
|
# Extract only <w:t> 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"(?<!#)\{[^{}\r\n]{1,200}\}", text),
|
||||||
|
msg="仍残留 {...} 占位符",
|
||||||
|
)
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"redis>=5.0.0",
|
"redis>=5.0.0",
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
"tencentcloud-sdk-python>=3.0.0",
|
"tencentcloud-sdk-python>=3.0.0",
|
||||||
|
"python-docx>=1.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
59
uv.lock
generated
59
uv.lock
generated
@@ -426,6 +426,7 @@ dependencies = [
|
|||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
|
{ name = "python-docx" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "tencentcloud-sdk-python" },
|
{ name = "tencentcloud-sdk-python" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
@@ -455,6 +456,7 @@ requires-dist = [
|
|||||||
{ name = "markdown", specifier = ">=3.10" },
|
{ name = "markdown", specifier = ">=3.10" },
|
||||||
{ name = "pillow", specifier = ">=12.0.0" },
|
{ name = "pillow", specifier = ">=12.0.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
|
||||||
|
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||||
{ name = "redis", specifier = ">=5.0.0" },
|
{ name = "redis", specifier = ">=5.0.0" },
|
||||||
{ name = "tencentcloud-sdk-python", specifier = ">=3.0.0" },
|
{ name = "tencentcloud-sdk-python", specifier = ">=3.0.0" },
|
||||||
{ name = "uvicorn", specifier = ">=0.38.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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.10"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user