Files

444 lines
14 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.
"""对账单二维码(批次 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(可直接放进 <img src>)。"""
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'<rect x="{(start + border) * scale}" y="{(r + border) * scale}" '
f'width="{(c - start) * scale}" height="{scale}"/>'
)
else:
c += 1
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{total * scale}" '
f'height="{total * scale}" viewBox="0 0 {total * scale} {total * scale}">'
f'<rect width="100%" height="100%" fill="#fff"/>'
f'<g fill="#000">{"".join(parts)}</g></svg>'
)
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": "扫码查看对账单",
}