"""对账单二维码(批次 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'' f'' f'{"".join(parts)}' ) 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": "扫码查看对账单", }