133 lines
5.6 KiB
Python
133 lines
5.6 KiB
Python
"""对账单客户侧 HTML 渲染(匿名分享页,手机优先,可直接打印)。
|
||
|
||
安全:所有字段经 django.utils.html.escape;不含任何 Django 模板执行。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
|
||
from django.utils.html import escape
|
||
|
||
|
||
def _money(value) -> str:
|
||
try:
|
||
d = Decimal(str(value or 0))
|
||
except Exception:
|
||
return escape(str(value))
|
||
return f"{d:,.2f}"
|
||
|
||
|
||
_PAGE = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>对账单 {customer_name} {date_from}~{date_to}</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; }}
|
||
body {{ font-family: "Microsoft YaHei", -apple-system, sans-serif; margin: 0;
|
||
background: #f5f6f8; color: #1f2329; font-size: 14px; }}
|
||
.sheet {{ max-width: 900px; margin: 0 auto; background: #fff; padding: 20px 18px 40px; }}
|
||
h1 {{ font-size: 20px; margin: 0 0 4px; text-align: center; }}
|
||
.sub {{ text-align: center; color: #6b7280; font-size: 12px; margin-bottom: 14px; }}
|
||
.meta {{ display: flex; flex-wrap: wrap; gap: 10px 24px; margin: 12px 0 16px;
|
||
padding: 10px 12px; background: #fafbfc; border: 1px solid #e5e7eb; border-radius: 6px; }}
|
||
.meta div {{ font-size: 13px; }}
|
||
.meta b {{ color: #111827; }}
|
||
.cards {{ display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }}
|
||
.card {{ flex: 1 1 140px; border: 1px solid #e5e7eb; border-radius: 6px; padding: 10px 12px; }}
|
||
.card .k {{ color: #6b7280; font-size: 12px; }}
|
||
.card .v {{ font-size: 18px; font-weight: 700; margin-top: 4px; }}
|
||
.card.closing .v {{ color: #d4380d; }}
|
||
table {{ width: 100%; border-collapse: collapse; }}
|
||
th, td {{ border: 1px solid #e5e7eb; padding: 6px 8px; text-align: left; font-size: 13px; }}
|
||
th {{ background: #f3f4f6; font-weight: 600; white-space: nowrap; }}
|
||
td.num, th.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
|
||
tr.debit td.type {{ color: #d4380d; }}
|
||
tr.credit td.type {{ color: #15803d; }}
|
||
.empty {{ text-align: center; color: #9ca3af; padding: 18px 0; }}
|
||
.foot {{ margin-top: 18px; color: #9ca3af; font-size: 12px; text-align: center; }}
|
||
.toolbar {{ max-width: 900px; margin: 12px auto 0; text-align: right; }}
|
||
.toolbar button {{ padding: 7px 16px; border: 1px solid #d1d5db; background: #fff;
|
||
border-radius: 6px; cursor: pointer; font-size: 13px; }}
|
||
@media print {{ body {{ background: #fff; }} .toolbar {{ display: none; }}
|
||
.sheet {{ padding: 0; max-width: none; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="sheet">
|
||
<h1>对 账 单</h1>
|
||
<div class="sub">本页由系统自动生成,仅供对账参考</div>
|
||
<div class="meta">
|
||
<div>客户:<b>{customer_name}</b>{customer_code}</div>
|
||
<div>区间:<b>{date_from} ~ {date_to}</b></div>
|
||
<div>生成时间:{generated_at}</div>
|
||
</div>
|
||
<div class="cards">
|
||
<div class="card"><div class="k">期初余额(元)</div><div class="v">{opening}</div></div>
|
||
<div class="card"><div class="k">本期应收(元)</div><div class="v">{period_debit}</div></div>
|
||
<div class="card"><div class="k">本期收款(元)</div><div class="v">{period_credit}</div></div>
|
||
<div class="card closing"><div class="k">期末余额(元)</div><div class="v">{closing}</div></div>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>日期</th><th>类型</th><th>单号</th><th>摘要</th>
|
||
<th class="num">应收</th><th class="num">收款</th><th class="num">余额</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows}
|
||
</tbody>
|
||
</table>
|
||
<div class="foot">如对本对账单有异议,请在收到后 3 个工作日内与业务员联系核对。</div>
|
||
</div>
|
||
<div class="toolbar"><button onclick="window.print()">打印 / 保存为 PDF</button></div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def render_statement_html(data: dict, *, generated_at: str) -> str:
|
||
"""把 customer_statement 的返回值渲染成客户侧 HTML 页面。"""
|
||
lines = data.get("lines") or []
|
||
debit_total = Decimal("0")
|
||
credit_total = Decimal("0")
|
||
rows = []
|
||
for ln in lines:
|
||
d = Decimal(str(ln.get("debit") or 0))
|
||
c = Decimal(str(ln.get("credit") or 0))
|
||
debit_total += d
|
||
credit_total += c
|
||
kind = "应收" if ln.get("type") == "receivable" else "收款"
|
||
rows.append(
|
||
'<tr class="{cls}"><td>{date}</td><td class="type">{kind}</td>'
|
||
'<td>{ref}</td><td>{desc}</td><td class="num">{debit}</td>'
|
||
'<td class="num">{credit}</td><td class="num">{balance}</td></tr>'.format(
|
||
cls="debit" if d else "credit",
|
||
date=escape(str(ln.get("date") or "")),
|
||
kind=kind,
|
||
ref=escape(str(ln.get("ref") or "")),
|
||
desc=escape(str(ln.get("description") or "")),
|
||
debit=_money(d) if d else "",
|
||
credit=_money(c) if c else "",
|
||
balance=_money(ln.get("balance")),
|
||
)
|
||
)
|
||
if not rows:
|
||
rows.append('<tr><td colspan="7" class="empty">本期无往来明细</td></tr>')
|
||
|
||
customer = data.get("customer") or {}
|
||
code = customer.get("code")
|
||
return _PAGE.format(
|
||
customer_name=escape(str(customer.get("name") or "")),
|
||
customer_code=f"({escape(str(code))})" if code else "",
|
||
date_from=escape(str(data.get("date_from") or "")),
|
||
date_to=escape(str(data.get("date_to") or "")),
|
||
generated_at=escape(generated_at),
|
||
opening=_money(data.get("opening_balance")),
|
||
period_debit=_money(debit_total),
|
||
period_credit=_money(credit_total),
|
||
closing=_money(data.get("closing_balance")),
|
||
rows="\n ".join(rows),
|
||
)
|