Files

535 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""打印服务:单据上下文构建 + 默认模板 + 整页 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}