baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
"""对账单二维码(批次 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": "扫码查看对账单",
|
||||
}
|
||||
Reference in New Issue
Block a user