"""打印服务:单据上下文构建 + 默认模板 + 整页 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 = """

{{company.name}}

电话:{{company.phone}} 地址:{{company.address}}

销 售 单

单号:{{bill.bill_no}}日期:{{bill.bill_date}}仓库:{{warehouse.name}}
客户:{{customer.name}}电话:{{customer.phone}}打印时间:{{printed_at}}
{{#if has_batch_lines}}{{/if}} {{#each lines}} {{#if item.has_batch}}{{/if}} {{/each}} {{#if has_batch_lines}}{{/if}}{{#if has_batch_lines}}{{/if}}
#编码品名单位数量单价金额批次号
{{item.index}}{{item.product_code}}{{item.product_name}} {{item.spec}}{{item.unit_name}}{{item.source_quantity}}{{item.unit_price}}{{item.amount}}{{item.batch_no}}
合计:{{totals.count}} 行 / 数量 {{totals.quantity}}抹零优惠{{totals.round_off}}
应收合计(元){{totals.net_amount}}
制单:__________送货人:__________客户签收:__________
备注:{{bill.remark}}
{{#if statement_qr}}
对账单二维码
{{statement_qr.tip}}
{{/if}}
{{company.footer_note}} 开户/账号:{{company.bank_info}}
""" PURCHASE_BILL_DEFAULT = """

{{company.name}}

电话:{{company.phone}} 地址:{{company.address}}

进 货 单

单号:{{bill.bill_no}}日期:{{bill.bill_date}}仓库:{{warehouse.name}}
供应商:{{supplier.name}}电话:{{supplier.phone}}打印时间:{{printed_at}}
{{#if has_batch_lines}}{{/if}} {{#each lines}} {{#if item.has_batch}}{{/if}} {{/each}}
#编码品名单位数量单价金额批次号
{{item.index}}{{item.product_code}}{{item.product_name}} {{item.spec}}{{item.unit_name}}{{item.source_quantity}}{{item.unit_price}}{{item.amount}}{{item.batch_no}}
合计:{{totals.count}} 行 / 数量 {{totals.quantity}}应付合计(元):{{totals.net_amount}}
制单:__________验收人:__________供应商签收:__________
{{company.footer_note}}
""" SALES_RECEIPT_58 = """

{{company.name}}

{{company.phone}}
销 售 单
单号:{{bill.bill_no}}
日期:{{bill.bill_date}}
客户:{{customer.name}}
仓库:{{warehouse.name}}
{{#each lines}} {{/each}}
品名数量单价金额
{{item.product_name}} {{item.source_quantity}}{{item.unit_name}} {{item.unit_price}} {{item.amount}}
共 {{totals.count}} 行{{#if totals.round_off}} 抹零 {{totals.round_off}}{{/if}}
应收合计:¥{{totals.net_amount}}
{{company.footer_note}}
打印时间 {{printed_at}}
""" # 三联送货单模板 SALES_TRIPLICATE = """
{{#each slips}}
{{item.tag}}

{{item.company_name}}

送 货 单

单号:{{item.bill_no}}日期:{{item.bill_date}}
客户:{{item.customer_name}}电话:{{item.customer_phone}}
地址:{{item.customer_address}}
{{item.lines_html}}
品名数量单价金额
合计(元):{{item.net_amount}}
送货人:______客户签收:______
{{/each}}
""" 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 = """ {title}
{body} {script} """ # 三联送货单:A4 横排三份(存根/客户/财务) TRIPLICATE_SHELL = """ {title}
{body} {script} """ PAGE_SHELL = """ {title}
{body} {script} """ AUTO_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: """把行渲染成 串(含转义)。 三联模板是"循环套循环",而渲染器的 `item` 只能表示一层——内层再用 item 会被外层覆盖。这里在 Python 侧先把行拼好,模板里只留一层循环。 """ from django.utils.html import escape as _esc rows = [] for ln in lines: rows.append( "{name}{qty}{unit}{price}{amount}".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}