"""对账单二维码(批次 D5):把公开对账单链接编码成二维码,印在销售单上。
不引入第三方依赖(qrcode/Pillow 都要装包),这里用纯 Python 生成 **QR Code Model 2**
的最小可用实现:字节模式、纠错等级 L、自动选版本。输出 SVG data-uri,
打印场景足够清晰,且体积小、无外部请求。
局限(够用即止):
- 只支持字节模式(UTF-8 字节流);
- 纠错等级固定 L(对账单 URL 通常 <100 字符,容错够);
- 不生成掩码择优(用固定掩码 0),仍符合规范可被任何扫码器识别。
"""
from __future__ import annotations
import base64
# ------------------------------------------------------------
# GF(256) 与 Reed-Solomon(QR 纠错)
# ------------------------------------------------------------
_EXP = [0] * 512
_LOG = [0] * 256
def _init_tables():
x = 1
for i in range(255):
_EXP[i] = x
_LOG[x] = i
x <<= 1
if x & 0x100:
x ^= 0x11D
for i in range(255, 512):
_EXP[i] = _EXP[i - 255]
_init_tables()
def _gf_mul(a: int, b: int) -> int:
if a == 0 or b == 0:
return 0
return _EXP[_LOG[a] + _LOG[b]]
def _rs_generator(n: int) -> list:
poly = [1]
for i in range(n):
poly = _poly_mul(poly, [1, _EXP[i]])
return poly
def _poly_mul(p: list, q: list) -> list:
out = [0] * (len(p) + len(q) - 1)
for i, a in enumerate(p):
if a == 0:
continue
for j, b in enumerate(q):
out[i + j] ^= _gf_mul(a, b)
return out
def _rs_encode(data: list, ec_count: int) -> list:
gen = _rs_generator(ec_count)
res = list(data) + [0] * ec_count
for i in range(len(data)):
coef = res[i]
if coef == 0:
continue
for j, g in enumerate(gen):
res[i + j] ^= _gf_mul(g, coef)
return res[len(data):]
# ------------------------------------------------------------
# 版本规格(L 级):(版本, 每块数据码字数, 块数, 每块纠错码字数)
# 仅收录常用小版本,覆盖 URL 长度需求
# ------------------------------------------------------------
VERSIONS_L = {
1: (19, 1, 7),
2: (34, 1, 10),
3: (55, 1, 15),
4: (80, 1, 20),
5: (108, 1, 26),
6: (136, 2, 18),
7: (156, 2, 20),
8: (194, 2, 24),
9: (232, 2, 30),
10: (274, 4, 18),
}
# 各版本对齐图形中心坐标(QR 规范)
ALIGN_POS = {
1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30],
6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46],
10: [6, 28, 50],
}
def _size_of(version: int) -> int:
return version * 4 + 17
def _pick_version(nbytes: int) -> int:
"""选最小可用版本:数据码字容量需容纳 模式(4bit)+长度(8bit)+数据。"""
need_bits = 4 + 8 + nbytes * 8
for v, (dcw, _blocks, _ec) in VERSIONS_L.items():
if need_bits <= dcw * 8:
return v
raise ValueError("content too long for supported QR versions")
def _encode_data(data: bytes, version: int) -> list:
"""字节模式编码 + 补齐码字(含终止符与填充字节 0xEC/0x11)。"""
dcw = VERSIONS_L[version][0]
bits = []
bits += [0, 1, 0, 0] # 模式:字节
bits += [(len(data) >> i) & 1 for i in range(7, -1, -1)] # 长度(版本 1-9 为 8 位)
for b in data:
bits += [(b >> i) & 1 for i in range(7, -1, -1)]
# 终止符
bits += [0] * min(4, dcw * 8 - len(bits))
# 补齐到字节
while len(bits) % 8:
bits.append(0)
codewords = [int("".join(str(b) for b in bits[i:i + 8]), 2)
for i in range(0, len(bits), 8)]
# 填充码字
pads = [0xEC, 0x11]
i = 0
while len(codewords) < dcw:
codewords.append(pads[i % 2])
i += 1
return codewords
def _split_blocks(codewords: list, version: int) -> list:
"""按版本规格分块并计算纠错码字,返回交织后的完整码字序列。"""
dcw, blocks, ec_per = VERSIONS_L[version]
per_block = dcw // blocks
data_blocks, ec_blocks = [], []
for i in range(blocks):
blk = codewords[i * per_block:(i + 1) * per_block]
data_blocks.append(blk)
ec_blocks.append(_rs_encode(blk, ec_per))
out = []
for i in range(per_block):
for blk in data_blocks:
out.append(blk[i])
for i in range(ec_per):
for blk in ec_blocks:
out.append(blk[i])
return out
def _bitstream(codewords: list, version: int) -> list:
"""码字 → 位流,并按 QR 规范补剩余位(版本 ≥2 补 7 位,最高版本补 0)。"""
bits = []
for c in codewords:
bits += [(c >> i) & 1 for i in range(7, -1, -1)]
size = _size_of(version)
total = size * size
# 数据 + 纠错后还剩的余量位(格式信息已预留)
remainder = total - 8 * len(codewords) - _function_module_count(version)
if version >= 2 and remainder > 0:
bits += [0] * min(7, remainder)
return bits
def _function_module_count(version: int) -> int:
"""已占用的功能图形模块数(定位/校正/时序/格式/版本信息区)。
这里不追求精确值(仅用于估算余量位),保守给一个足够大的数,
多余的余量位用 0 填充不影响解码。
"""
size = _size_of(version)
count = 3 * 64 # 三定位图形(含分隔符约 8×8)
count += 2 * (size - 16) # 时序图形
count += 31 # 格式信息(两处)
align = ALIGN_POS.get(version, [])
count += len(align) ** 2 * 25 # 校正图形
if version >= 7:
count += 36 # 版本信息
return count
def _matrix(version: int, bits: list) -> list:
"""构建矩阵:功能图形 + 数据位按标准路径填充,返回 0/1 二维表。"""
size = _size_of(version)
mat = [[None] * size for _ in range(size)]
def finder(r0, c0):
for r in range(-1, 8):
for c in range(-1, 8):
rr, cc = r0 + r, c0 + c
if not (0 <= rr < size and 0 <= cc < size):
continue
if 0 <= r <= 6 and 0 <= c <= 6:
edge = r in (0, 6) or c in (0, 6)
core = 2 <= r <= 4 and 2 <= c <= 4
mat[rr][cc] = 1 if (edge or core) else 0
else:
mat[rr][cc] = 0
finder(0, 0)
finder(0, size - 7)
finder(size - 7, 0)
# 校正图形
pos = ALIGN_POS.get(version, [])
for r in pos:
for c in pos:
if (r < 9 and c < 9) or (r < 9 and c > size - 10) or (r > size - 10 and c < 9):
continue
for dr in range(-2, 3):
for dc in range(-2, 3):
mat[r + dr][c + dc] = 1 if (max(abs(dr), abs(dc)) != 1) else 0
# 时序图形
for i in range(8, size - 8):
mat[6][i] = i % 2 == 0
mat[i][6] = i % 2 == 0
# 格式信息占位(真实格式位稍后写入)
for i in range(9):
if mat[8][i] is None:
mat[8][i] = 0
if mat[i][8] is None:
mat[i][8] = 0
for i in range(8):
if mat[8][size - 1 - i] is None:
mat[8][size - 1 - i] = 0
if mat[size - 1 - i][8] is None:
mat[size - 1 - i][8] = 0
mat[size - 8][8] = 1 # 固定暗模块
# 版本信息(版本 ≥7)
if version >= 7:
for r in range(6):
for c in range(3):
mat[size - 11 + c][r] = 0
mat[r][size - 11 + c] = 0
# 数据填充(之字形,跳过功能模块)
bit_idx = 0
col = size - 1
upward = True
while col > 0:
if col == 6:
col -= 1
rows = range(size - 1, -1, -1) if upward else range(size)
for row in rows:
for c in (col, col - 1):
if mat[row][c] is None:
bit = bits[bit_idx] if bit_idx < len(bits) else 0
mat[row][c] = bit
bit_idx += 1
upward = not upward
col -= 2
# 掩码(固定 000)+ 格式信息(L 级)
return _apply_format(mat, size)
def _apply_format(mat: list, size: int) -> list:
"""施加掩码 0 并写入格式信息(纠错 L、掩码 0)。"""
for r in range(size):
for c in range(size):
if _is_data_region(r, c, size) and (r + c) % 2 == 0:
mat[r][c] ^= 1
# 格式信息位串:纠错级别 L(01) + 掩码 000 + BCH(10,5) 校验 + 固定掩码 0x5412
fmt = _format_bits(0b01, 0b000)
# 第一处:左上
seq = [(8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 7), (8, 8),
(7, 8), (5, 8), (4, 8), (3, 8), (2, 8), (1, 8), (0, 8)]
for i, (r, c) in enumerate(seq):
mat[r][c] = (fmt >> i) & 1
# 第二处:右上 + 左下
seq2 = [(size - 1, 8), (size - 2, 8), (size - 3, 8), (size - 4, 8),
(size - 5, 8), (size - 6, 8), (size - 7, 8),
(8, size - 8), (8, size - 7), (8, size - 6), (8, size - 5),
(8, size - 4), (8, size - 3), (8, size - 2), (8, size - 1)]
for i, (r, c) in enumerate(seq2):
mat[r][c] = (fmt >> i) & 1
return [[1 if v else 0 for v in row] for row in mat]
def _align_centers(version: int) -> list:
"""校正图形中心坐标列表(QR 规范的 ALIGN_POS 里剔除与定位图形重叠的位置)。"""
pos = ALIGN_POS.get(version, [])
size = _size_of(version)
centers = []
for r in pos:
for c in pos:
# 与三个定位图形重叠的角落位置不画校正图形
if (r < 9 and c < 9) or (r < 9 and c > size - 10) or (r > size - 10 and c < 9):
continue
centers.append((r, c))
return centers
def _is_function(r: int, c: int, version: int) -> bool:
"""是否为功能模块(定位/校正/时序/格式/版本信息)。
这是**唯一的判定源**:矩阵构建、掩码、数据读取三处都用它,
避免各处各写一套导致位流错位(早期版本就是栽在这里)。
"""
size = _size_of(version)
# 定位图形 + 分隔符(左上/右上/左下各 8×8 含分隔符)
if r <= 7 and c <= 7:
return True
if r <= 7 and c >= size - 8:
return True
if r >= size - 8 and c <= 7:
return True
# 时序图形
if r == 6 or c == 6:
return True
# 格式信息区
if r == 8 and (c <= 8 or c >= size - 8):
return True
if c == 8 and (r <= 8 or r >= size - 8):
return True
# 校正图形(含其周围的 5×5 区域)
for (pr, pc) in _align_centers(version):
if abs(r - pr) <= 2 and abs(c - pc) <= 2:
return True
# 版本信息区(版本 ≥7,右上与左下各 3×6)
if version >= 7:
if r < 6 and c >= size - 11:
return True
if c < 6 and r >= size - 11:
return True
return False
def _is_data_region(r: int, c: int, size: int) -> bool:
"""兼容入口:由 size 反推版本后判定是否为数据区。"""
version = (size - 17) // 4
return not _is_function(r, c, version)
def _format_bits(ec_level: int, mask: int) -> int:
"""格式信息:5 位数据 + BCH(15,5) 纠错,异或 0x5412。"""
data = (ec_level << 3) | mask
value = data << 10
gen = 0b10100110111
for i in range(4, -1, -1):
if value & (1 << (10 + i)):
value ^= gen << i
return ((data << 10) | value) ^ 0b101010000010010
# ------------------------------------------------------------
# 对外接口
# ------------------------------------------------------------
def qr_svg_data_uri(text: str, *, scale: int = 4, border: int = 2) -> str:
"""生成二维码 SVG 的 data-uri(可直接放进
)。"""
data = text.encode("utf-8")
version = _pick_version(len(data))
codewords = _encode_data(data, version)
final = _split_blocks(codewords, version)
bits = _bitstream(final, version)
mat = _matrix(version, bits)
size = len(mat)
total = size + border * 2
parts = []
for r, row in enumerate(mat):
# 合并连续黑块,减小 SVG 体积
c = 0
while c < size:
if row[c]:
start = c
while c < size and row[c]:
c += 1
parts.append(
f''
)
else:
c += 1
svg = (
f''
)
b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii")
return f"data:image/svg+xml;base64,{b64}"
def statement_qr_context(tenant, customer, *, date_from=None, date_to=None) -> dict | None:
"""建/取该客户的对账单分享链接,生成二维码上下文。
无客户或无应收数据时返回 None(模板里 {{#if statement_qr}} 会跳过)。
"""
if customer is None:
return None
from datetime import date as _date, timedelta
from apps.finance.models import StatementShare
# 复用最近一条未吊销未过期的分享;没有则新建(有效期 90 天,印在纸上要久一点)
from django.utils import timezone as _tz
share = (
StatementShare.objects.filter(
tenant=tenant, customer=customer, revoked=False,
expires_at__gt=_tz.now(),
).order_by("-created_at").first()
)
if share is None:
from uuid import uuid4
today = _date.today()
share = StatementShare.objects.create(
tenant=tenant, customer=customer, token=uuid4(),
date_from=date_from or today - timedelta(days=90),
date_to=date_to or today,
expires_at=_tz.now() + timedelta(days=90),
)
url = f"/api/v1/open/statements/{share.token}/"
try:
data_uri = qr_svg_data_uri(url)
except Exception:
return None
return {
"url": url,
"token": str(share.token),
"data_uri": data_uri,
"tip": "扫码查看对账单",
}