baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
"""打印服务:单据上下文构建 + 默认模板 + 整页 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}