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

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