444 lines
14 KiB
Python
444 lines
14 KiB
Python
"""对账单二维码(批次 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": "扫码查看对账单",
|
||
}
|