535 lines
21 KiB
Python
535 lines
21 KiB
Python
"""打印服务:单据上下文构建 + 默认模板 + 整页 HTML 输出。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from django.utils import timezone
|
||
from django.utils.html import escape
|
||
|
||
from apps.core.base_models import TenantScopedModel
|
||
from .models import PrintSettings, PrintTemplate
|
||
from .renderer import render_template
|
||
|
||
|
||
# ============================================================
|
||
# 上下文构建
|
||
# ============================================================
|
||
|
||
def _line_ctx(line, *, index: int) -> dict:
|
||
product = line.product
|
||
unit = line.source_unit or product.base_unit
|
||
# 出库单的行带 FEFO 分摊明细(batch_detail),进货单行直接带批次号
|
||
batch_no = getattr(line, "batch_no", "") or ""
|
||
batch_text = batch_no
|
||
movement_batches = _movement_batches(line)
|
||
if movement_batches:
|
||
batch_text = "/".join(movement_batches)
|
||
return {
|
||
"index": index + 1,
|
||
"product_code": product.code,
|
||
"product_name": product.name,
|
||
"spec": product.spec,
|
||
"quantity": line.quantity,
|
||
"source_quantity": line.source_quantity if line.source_quantity is not None else line.quantity,
|
||
"unit_name": unit.name if unit else "",
|
||
"unit_price": line.unit_price,
|
||
"amount": line.amount,
|
||
"batch_no": batch_text,
|
||
"has_batch": bool(batch_text),
|
||
"is_batch_managed": bool(getattr(product, "is_batch_managed", False)),
|
||
}
|
||
|
||
|
||
def _movement_batches(line) -> list:
|
||
"""从该行商品对应的最新出库流水里取批次分摊明细(销售单打印批次列用)。"""
|
||
bill = getattr(line, "bill", None)
|
||
if bill is None or not getattr(bill, "bill_no", ""):
|
||
return []
|
||
from apps.inventory.models import StockMovement
|
||
|
||
mv = (
|
||
StockMovement.objects.filter(
|
||
tenant=bill.tenant, source_ref=bill.bill_no, product_id=line.product_id,
|
||
)
|
||
.exclude(batch_detail=[])
|
||
.order_by("-id")
|
||
.first()
|
||
)
|
||
if mv is None or not mv.batch_detail:
|
||
return []
|
||
return [str(b.get("batch_no") or "") for b in mv.batch_detail]
|
||
|
||
|
||
def sales_bill_context(bill) -> dict:
|
||
from apps.printing.models import PrintSettings
|
||
|
||
settings_obj = PrintSettings.objects.filter(tenant=bill.tenant).first()
|
||
lines = [_line_ctx(ln, index=i) for i, ln in enumerate(bill.lines.select_related(
|
||
"product", "product__base_unit", "source_unit").all())]
|
||
total_amount = bill.total_amount or Decimal("0")
|
||
round_off = getattr(bill, "round_off", None) or Decimal("0")
|
||
return {
|
||
"company": {
|
||
"name": settings_obj.company_name if settings_obj else "",
|
||
"phone": settings_obj.phone if settings_obj else "",
|
||
"address": settings_obj.address if settings_obj else "",
|
||
"bank_info": settings_obj.bank_info if settings_obj else "",
|
||
"footer_note": settings_obj.footer_note if settings_obj else "",
|
||
},
|
||
"bill": {
|
||
"bill_no": bill.bill_no,
|
||
"bill_date": bill.bill_date.isoformat(),
|
||
"state": bill.state,
|
||
"remark": bill.remark,
|
||
"amount": total_amount,
|
||
"round_off": round_off,
|
||
},
|
||
"customer": {
|
||
"code": bill.customer.code,
|
||
"name": bill.customer.name,
|
||
"phone": bill.customer.phone,
|
||
"address": bill.customer.address,
|
||
"tax_number": bill.customer.tax_number,
|
||
},
|
||
"warehouse": {"code": bill.warehouse.code, "name": bill.warehouse.name},
|
||
"lines": lines,
|
||
"has_batch_lines": any(l["has_batch"] for l in lines),
|
||
"totals": {
|
||
"count": len(lines),
|
||
"quantity": sum(l["quantity"] for l in lines),
|
||
"amount": total_amount + round_off,
|
||
"round_off": round_off,
|
||
"net_amount": total_amount,
|
||
},
|
||
"statement_qr": _statement_qr(bill),
|
||
"printed_at": timezone.localtime().strftime("%Y-%m-%d %H:%M"),
|
||
}
|
||
|
||
|
||
def _statement_qr(bill):
|
||
"""销售单尾部的对账单二维码(D5)。失败/无客户时返回 None(模板自动跳过)。"""
|
||
try:
|
||
from .qr import statement_qr_context
|
||
|
||
return statement_qr_context(bill.tenant, bill.customer)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def purchase_bill_context(bill) -> dict:
|
||
settings_obj = PrintSettings.objects.filter(tenant=bill.tenant).first()
|
||
lines = [_line_ctx(ln, index=i) for i, ln in enumerate(bill.lines.select_related(
|
||
"product", "product__base_unit", "source_unit").all())]
|
||
return {
|
||
"company": {
|
||
"name": settings_obj.company_name if settings_obj else "",
|
||
"phone": settings_obj.phone if settings_obj else "",
|
||
"address": settings_obj.address if settings_obj else "",
|
||
"bank_info": settings_obj.bank_info if settings_obj else "",
|
||
"footer_note": settings_obj.footer_note if settings_obj else "",
|
||
},
|
||
"bill": {
|
||
"bill_no": bill.bill_no,
|
||
"bill_date": bill.bill_date.isoformat(),
|
||
"state": bill.state,
|
||
"remark": bill.remark,
|
||
"amount": bill.total_amount or Decimal("0"),
|
||
"round_off": getattr(bill, "round_off", None) or Decimal("0"),
|
||
},
|
||
"supplier": {
|
||
"code": bill.supplier.code,
|
||
"name": bill.supplier.name,
|
||
"phone": bill.supplier.phone,
|
||
"address": bill.supplier.address,
|
||
},
|
||
"warehouse": {"code": bill.warehouse.code, "name": bill.warehouse.name},
|
||
"lines": lines,
|
||
"has_batch_lines": any(l["has_batch"] for l in lines),
|
||
"totals": {
|
||
"count": len(lines),
|
||
"quantity": sum(l["quantity"] for l in lines),
|
||
"amount": bill.total_amount or Decimal("0"),
|
||
"round_off": Decimal("0"),
|
||
"net_amount": bill.total_amount or Decimal("0"),
|
||
},
|
||
"printed_at": timezone.localtime().strftime("%Y-%m-%d %H:%M"),
|
||
}
|
||
|
||
|
||
DOC_CONTEXT_BUILDERS = {
|
||
"sales_bill": ("apps.sales.models", "SalesBill", sales_bill_context),
|
||
"purchase_bill": ("apps.purchase.models", "PurchaseBill", purchase_bill_context),
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 默认模板
|
||
# ============================================================
|
||
|
||
SALES_BILL_DEFAULT = """
|
||
<div class="doc-head">
|
||
<h1>{{company.name}}</h1>
|
||
<div class="sub">电话:{{company.phone}} 地址:{{company.address}}</div>
|
||
<h2>销 售 单</h2>
|
||
</div>
|
||
<table class="meta">
|
||
<tr><td>单号:{{bill.bill_no}}</td><td>日期:{{bill.bill_date}}</td><td>仓库:{{warehouse.name}}</td></tr>
|
||
<tr><td>客户:{{customer.name}}</td><td>电话:{{customer.phone}}</td><td>打印时间:{{printed_at}}</td></tr>
|
||
</table>
|
||
<table class="lines">
|
||
<thead><tr><th>#</th><th>编码</th><th>品名</th><th>单位</th><th>数量</th><th>单价</th><th>金额</th>{{#if has_batch_lines}}<th>批次号</th>{{/if}}</tr></thead>
|
||
<tbody>
|
||
{{#each lines}}
|
||
<tr><td>{{item.index}}</td><td>{{item.product_code}}</td><td>{{item.product_name}} {{item.spec}}</td><td>{{item.unit_name}}</td><td>{{item.source_quantity}}</td><td>{{item.unit_price}}</td><td>{{item.amount}}</td>{{#if item.has_batch}}<td>{{item.batch_no}}</td>{{/if}}</tr>
|
||
{{/each}}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr><td colspan="4">合计:{{totals.count}} 行 / 数量 {{totals.quantity}}</td><td colspan="2">抹零优惠</td><td>{{totals.round_off}}</td>{{#if has_batch_lines}}<td></td>{{/if}}</tr>
|
||
<tr><td colspan="6" class="strong">应收合计(元)</td><td class="strong">{{totals.net_amount}}</td>{{#if has_batch_lines}}<td></td>{{/if}}</tr>
|
||
</tfoot>
|
||
</table>
|
||
<div class="sign">
|
||
<span>制单:__________</span><span>送货人:__________</span><span>客户签收:__________</span>
|
||
</div>
|
||
<div class="remark">备注:{{bill.remark}}</div>
|
||
{{#if statement_qr}}<div class="qr-block">
|
||
<img class="qr-img" src="{{statement_qr.data_uri}}" alt="对账单二维码">
|
||
<div class="qr-tip">{{statement_qr.tip}}</div>
|
||
</div>{{/if}}
|
||
<div class="foot">{{company.footer_note}} 开户/账号:{{company.bank_info}}</div>
|
||
"""
|
||
|
||
PURCHASE_BILL_DEFAULT = """
|
||
<div class="doc-head">
|
||
<h1>{{company.name}}</h1>
|
||
<div class="sub">电话:{{company.phone}} 地址:{{company.address}}</div>
|
||
<h2>进 货 单</h2>
|
||
</div>
|
||
<table class="meta">
|
||
<tr><td>单号:{{bill.bill_no}}</td><td>日期:{{bill.bill_date}}</td><td>仓库:{{warehouse.name}}</td></tr>
|
||
<tr><td>供应商:{{supplier.name}}</td><td>电话:{{supplier.phone}}</td><td>打印时间:{{printed_at}}</td></tr>
|
||
</table>
|
||
<table class="lines">
|
||
<thead><tr><th>#</th><th>编码</th><th>品名</th><th>单位</th><th>数量</th><th>单价</th><th>金额</th>{{#if has_batch_lines}}<th>批次号</th>{{/if}}</tr></thead>
|
||
<tbody>
|
||
{{#each lines}}
|
||
<tr><td>{{item.index}}</td><td>{{item.product_code}}</td><td>{{item.product_name}} {{item.spec}}</td><td>{{item.unit_name}}</td><td>{{item.source_quantity}}</td><td>{{item.unit_price}}</td><td>{{item.amount}}</td>{{#if item.has_batch}}<td>{{item.batch_no}}</td>{{/if}}</tr>
|
||
{{/each}}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr><td colspan="4">合计:{{totals.count}} 行 / 数量 {{totals.quantity}}</td><td colspan="{{#if has_batch_lines}}4{{else}}3{{/if}}" class="strong">应付合计(元):{{totals.net_amount}}</td></tr>
|
||
</tfoot>
|
||
</table>
|
||
<div class="sign">
|
||
<span>制单:__________</span><span>验收人:__________</span><span>供应商签收:__________</span>
|
||
</div>
|
||
<div class="foot">{{company.footer_note}}</div>
|
||
"""
|
||
|
||
SALES_RECEIPT_58 = """
|
||
<div class="r-head">
|
||
<h1>{{company.name}}</h1>
|
||
<div class="sub">{{company.phone}}</div>
|
||
</div>
|
||
<div class="r-title">销 售 单</div>
|
||
<div class="r-meta">
|
||
单号:{{bill.bill_no}}<br>
|
||
日期:{{bill.bill_date}}<br>
|
||
客户:{{customer.name}}<br>
|
||
仓库:{{warehouse.name}}
|
||
</div>
|
||
<table class="r-lines">
|
||
<thead>
|
||
<tr><th>品名</th><th class="num">数量</th><th class="num">单价</th><th class="num">金额</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{{#each lines}}
|
||
<tr>
|
||
<td>{{item.product_name}}</td>
|
||
<td class="num">{{item.source_quantity}}{{item.unit_name}}</td>
|
||
<td class="num">{{item.unit_price}}</td>
|
||
<td class="num">{{item.amount}}</td>
|
||
</tr>
|
||
{{/each}}
|
||
</tbody>
|
||
</table>
|
||
<div class="r-total">
|
||
共 {{totals.count}} 行{{#if totals.round_off}} 抹零 {{totals.round_off}}{{/if}}<br>
|
||
应收合计:<span class="big">¥{{totals.net_amount}}</span>
|
||
</div>
|
||
<div class="r-foot">
|
||
{{company.footer_note}}<br>
|
||
打印时间 {{printed_at}}
|
||
</div>
|
||
"""
|
||
|
||
# 三联送货单模板
|
||
SALES_TRIPLICATE = """
|
||
<div class="tpl-page">
|
||
{{#each slips}}
|
||
<div class="tpl-slip">
|
||
<div class="slip-tag">{{item.tag}}</div>
|
||
<h1>{{item.company_name}}</h1>
|
||
<h2>送 货 单</h2>
|
||
<table class="meta">
|
||
<tr><td>单号:{{item.bill_no}}</td><td>日期:{{item.bill_date}}</td></tr>
|
||
<tr><td>客户:{{item.customer_name}}</td><td>电话:{{item.customer_phone}}</td></tr>
|
||
<tr><td colspan="2">地址:{{item.customer_address}}</td></tr>
|
||
</table>
|
||
<table class="lines">
|
||
<thead><tr><th>品名</th><th>数量</th><th>单价</th><th>金额</th></tr></thead>
|
||
<tbody>{{item.lines_html}}</tbody>
|
||
</table>
|
||
<div style="margin-top:6px;font-size:12px">
|
||
合计(元):<b>{{item.net_amount}}</b>
|
||
</div>
|
||
<div class="sign">
|
||
<span>送货人:______</span><span>客户签收:______</span>
|
||
</div>
|
||
</div>
|
||
{{/each}}
|
||
</div>
|
||
"""
|
||
|
||
DEFAULT_TEMPLATES = {
|
||
"sales_bill": [
|
||
("xs-default", "销售单(默认)", SALES_BILL_DEFAULT),
|
||
("xs-receipt-58", "销售小票(58mm 热敏)", SALES_RECEIPT_58),
|
||
("xs-triplicate", "三联送货单(A4 横向)", SALES_TRIPLICATE),
|
||
],
|
||
"purchase_bill": [("cg-default", "进货单(默认)", PURCHASE_BILL_DEFAULT)],
|
||
}
|
||
|
||
# 模板 → 版式(决定用哪个 HTML 外壳,以及是否横排)
|
||
TEMPLATE_SHELL = {
|
||
"xs-receipt-58": "receipt",
|
||
"xs-triplicate": "triplicate",
|
||
}
|
||
|
||
|
||
def ensure_default_templates(tenant) -> int:
|
||
"""幂等创建租户默认模板(is_default=True)。返回新建数量。"""
|
||
created = 0
|
||
for doc_type, templates in DEFAULT_TEMPLATES.items():
|
||
for code, name, body in templates:
|
||
_, was_created = PrintTemplate.objects.get_or_create(
|
||
tenant=tenant, code=code,
|
||
defaults={
|
||
"name": name, "doc_type": doc_type,
|
||
"body_html": body, "is_default": True,
|
||
},
|
||
)
|
||
created += 1 if was_created else 0
|
||
return created
|
||
|
||
|
||
def ensure_settings(tenant) -> PrintSettings:
|
||
obj = PrintSettings.objects.filter(tenant=tenant).first()
|
||
if obj is None:
|
||
obj = PrintSettings.objects.create(
|
||
tenant=tenant, company_name=f"{tenant.name}"
|
||
)
|
||
return obj
|
||
|
||
|
||
# ============================================================
|
||
# 渲染输出
|
||
# ============================================================
|
||
|
||
RECEIPT_SHELL = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>{title}</title>
|
||
<style>
|
||
@page {{ size: 58mm auto; margin: 3mm 2mm; }}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{ font-family: "Microsoft YaHei", "SimSun", monospace; font-size: 12px;
|
||
color: #000; margin: 0; width: 54mm; }}
|
||
.r-head {{ text-align: center; }}
|
||
.r-head h1 {{ margin: 0 0 2px; font-size: 15px; }}
|
||
.r-head .sub {{ font-size: 10px; }}
|
||
.r-title {{ text-align: center; font-size: 14px; font-weight: 700;
|
||
margin: 6px 0; letter-spacing: 2px; }}
|
||
.r-meta {{ font-size: 11px; line-height: 1.5; border-bottom: 1px dashed #000;
|
||
padding-bottom: 4px; }}
|
||
.r-lines {{ width: 100%; border-collapse: collapse; margin-top: 4px; font-size: 11px; }}
|
||
.r-lines th {{ border-bottom: 1px solid #000; text-align: left; padding: 2px 0; }}
|
||
.r-lines td {{ padding: 2px 0; vertical-align: top; }}
|
||
.r-lines td.num, .r-lines th.num {{ text-align: right; }}
|
||
.r-total {{ border-top: 1px dashed #000; margin-top: 4px; padding-top: 4px;
|
||
font-size: 12px; }}
|
||
.r-total .big {{ font-size: 15px; font-weight: 700; }}
|
||
.r-foot {{ text-align: center; font-size: 10px; margin-top: 8px;
|
||
border-top: 1px dashed #000; padding-top: 4px; }}
|
||
.noprint {{ margin: 6px 0; }}
|
||
.noprint button {{ width: 100%; padding: 6px; }}
|
||
@media print {{ .noprint {{ display: none; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="noprint"><button onclick="window.print()">打 印</button></div>
|
||
{body}
|
||
{script}
|
||
</body>
|
||
</html>"""
|
||
|
||
# 三联送货单:A4 横排三份(存根/客户/财务)
|
||
TRIPLICATE_SHELL = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>{title}</title>
|
||
<style>
|
||
@page {{ size: A4 landscape; margin: 8mm; }}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{ font-family: "Microsoft YaHei", "SimSun", sans-serif; font-size: 12px;
|
||
color: #111; margin: 0; }}
|
||
.tpl-page {{ display: flex; gap: 6mm; }}
|
||
.tpl-slip {{ flex: 1; border: 1px solid #333; padding: 5mm 4mm;
|
||
display: flex; flex-direction: column; }}
|
||
.tpl-slip .slip-tag {{ text-align: right; font-size: 11px; color: #444; }}
|
||
.tpl-slip h1 {{ margin: 0 0 2px; font-size: 15px; text-align: center; }}
|
||
.tpl-slip h2 {{ margin: 2px 0 8px; font-size: 14px; text-align: center;
|
||
letter-spacing: 4px; }}
|
||
.tpl-slip table {{ width: 100%; border-collapse: collapse; }}
|
||
.tpl-slip .lines th, .tpl-slip .lines td {{ border: 1px solid #333;
|
||
padding: 2px 3px; font-size: 11px; }}
|
||
.tpl-slip .meta td {{ padding: 1px 2px; font-size: 11px; }}
|
||
.tpl-slip .sign {{ margin-top: auto; padding-top: 10px; font-size: 11px;
|
||
display: flex; justify-content: space-between; }}
|
||
.noprint {{ margin: 8px 0; }}
|
||
@media print {{ .noprint {{ display: none; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="noprint"><button onclick="window.print()">打 印(三联)</button></div>
|
||
{body}
|
||
{script}
|
||
</body>
|
||
</html>"""
|
||
|
||
PAGE_SHELL = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>{title}</title>
|
||
<style>
|
||
@page {{ size: {paper} portrait; margin: 12mm 10mm; }}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{ font-family: "Microsoft YaHei", "SimSun", sans-serif; font-size: 13px; color: #111; margin: 0; }}
|
||
.doc-head {{ text-align: center; }}
|
||
.doc-head h1 {{ margin: 0 0 2px; font-size: 20px; }}
|
||
.doc-head h2 {{ margin: 6px 0 10px; font-size: 17px; letter-spacing: 8px; }}
|
||
.doc-head .sub {{ color: #444; font-size: 12px; }}
|
||
table {{ width: 100%; border-collapse: collapse; margin-top: 6px; }}
|
||
.meta td {{ padding: 2px 4px; border: none; }}
|
||
.lines th, .lines td {{ border: 1px solid #333; padding: 4px 6px; text-align: left; }}
|
||
.lines th {{ background: #f2f2f2; }}
|
||
.lines tfoot td {{ border: 1px solid #333; font-size: 13px; }}
|
||
.strong {{ font-weight: 700; font-size: 15px; }}
|
||
.sign {{ margin-top: 22px; display: flex; justify-content: space-between; }}
|
||
.remark {{ margin-top: 10px; white-space: pre-wrap; }}
|
||
.foot {{ margin-top: 14px; color: #555; font-size: 12px; border-top: 1px dashed #999; padding-top: 6px; }}
|
||
.qr-block {{ margin-top: 12px; text-align: center; }}
|
||
.qr-img {{ width: 88px; height: 88px; }}
|
||
.qr-tip {{ font-size: 11px; color: #555; margin-top: 2px; }}
|
||
.noprint {{ margin: 10px 0; }}
|
||
@media print {{ .noprint {{ display: none; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="noprint"><button onclick="window.print()">打 印</button></div>
|
||
{body}
|
||
{script}
|
||
</body>
|
||
</html>"""
|
||
|
||
AUTO_PRINT_SCRIPT = '<script>window.addEventListener("load", function(){ window.print(); });</script>'
|
||
|
||
|
||
def render_document(*, tenant, doc_type: str, document, template_code: str = "",
|
||
autoprint: bool = True) -> str:
|
||
"""渲染整页可打印 HTML。
|
||
|
||
template_code 为空时取该 doc_type 的 is_default 模板(无则用内置默认并落库)。
|
||
"""
|
||
from django.apps import apps as django_apps
|
||
|
||
ensure_default_templates(tenant)
|
||
ensure_settings(tenant)
|
||
|
||
if template_code:
|
||
tpl = PrintTemplate.objects.get(tenant=tenant, code=template_code, doc_type=doc_type)
|
||
else:
|
||
tpl = PrintTemplate.objects.filter(
|
||
tenant=tenant, doc_type=doc_type, is_default=True
|
||
).first()
|
||
if tpl is None:
|
||
code, name, body = DEFAULT_TEMPLATES[doc_type][0]
|
||
tpl = PrintTemplate.objects.create(
|
||
tenant=tenant, code=code, name=name,
|
||
doc_type=doc_type, body_html=body, is_default=True,
|
||
)
|
||
|
||
builder = DOC_CONTEXT_BUILDERS[doc_type][2]
|
||
ctx = builder(document)
|
||
|
||
# 版式选择(D5):模板 code 决定外壳;未登记的走标准 A4
|
||
shell_kind = TEMPLATE_SHELL.get(tpl.code, "page")
|
||
if shell_kind == "triplicate":
|
||
ctx = _triplicate_context(ctx, document)
|
||
body = render_template(tpl.body_html, ctx)
|
||
script = AUTO_PRINT_SCRIPT if autoprint else ""
|
||
title = escape(f"{tpl.name} {document.bill_no}")
|
||
|
||
if shell_kind == "receipt":
|
||
return RECEIPT_SHELL.format(title=title, body=body, script=script)
|
||
if shell_kind == "triplicate":
|
||
return TRIPLICATE_SHELL.format(title=title, body=body, script=script)
|
||
|
||
paper = "A4" if tpl.paper_size == "A4" else tpl.paper_size
|
||
return PAGE_SHELL.format(
|
||
title=title, paper=paper, body=body, script=script,
|
||
)
|
||
|
||
|
||
def _line_rows_html(lines: list) -> str:
|
||
"""把行渲染成 <tr> 串(含转义)。
|
||
|
||
三联模板是"循环套循环",而渲染器的 `item` 只能表示一层——内层再用 item
|
||
会被外层覆盖。这里在 Python 侧先把行拼好,模板里只留一层循环。
|
||
"""
|
||
from django.utils.html import escape as _esc
|
||
|
||
rows = []
|
||
for ln in lines:
|
||
rows.append(
|
||
"<tr><td>{name}</td><td>{qty}{unit}</td><td>{price}</td><td>{amount}</td></tr>".format(
|
||
name=_esc(str(ln.get("product_name", ""))),
|
||
qty=_esc(str(ln.get("source_quantity", ""))),
|
||
unit=_esc(str(ln.get("unit_name", ""))),
|
||
price=_esc(str(ln.get("unit_price", ""))),
|
||
amount=_esc(str(ln.get("amount", ""))),
|
||
)
|
||
)
|
||
return "".join(rows)
|
||
|
||
|
||
def _triplicate_context(ctx: dict, document) -> dict:
|
||
"""三联送货单上下文:把同一份单据复制成三份,各带标签(存根/客户/送货)。"""
|
||
base = {
|
||
"company_name": (ctx.get("company") or {}).get("name", ""),
|
||
"bill_no": (ctx.get("bill") or {}).get("bill_no", ""),
|
||
"bill_date": (ctx.get("bill") or {}).get("bill_date", ""),
|
||
"customer_name": (ctx.get("customer") or {}).get("name", ""),
|
||
"customer_phone": (ctx.get("customer") or {}).get("phone", ""),
|
||
"customer_address": (ctx.get("customer") or {}).get("address", ""),
|
||
"lines_html": _line_rows_html(ctx.get("lines") or []),
|
||
"net_amount": (ctx.get("totals") or {}).get("net_amount", ""),
|
||
}
|
||
slips = [{"tag": tag, **base} for tag in ("① 存根联", "② 客户联", "③ 财务联")]
|
||
return {**ctx, "slips": slips}
|