1
0
forked from erp-dev/erp

feat: added print-template(test) and sync_mdy_plate_order beat task setting

This commit is contained in:
2026-01-26 21:27:29 +08:00
parent 4d33bc7f68
commit 44557f3d0c
20 changed files with 1275 additions and 0 deletions

2
.tmp_docx/document.xml Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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 抽取占位符 keydocument/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

View File

@@ -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建议必填生产环境请务必使用真实值

View File

@@ -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

Binary file not shown.

319
printing/docx_template.py Normal file
View 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

View 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}"
)
)

View 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']}"
)
)

View 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']}"
)
)

View 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

View 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))

View 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="仍残留 {...} 占位符",
)

View File

@@ -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
View File

@@ -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"