Chữ số kiểm tra: số học modulo bắt lỗi gõ IMEI và số thẻ
Luhn, Damm và MOD 97-10 bắt lỗi gõ IMEI, số thẻ, số phiếu ra sao, vì sao Luhn lọt 09 ↔ 90, và tỉ lệ bắt lỗi đếm trên một triệu số.
14:12 ngày 2026-05-14, cửa hàng số 37 của BanHang bán một điện thoại cho đơn 10097, 18.490.000 đ. Nhân viên đọc IMEI trên tem hộp máy, 864251307094619, rồi gõ vào phiếu bảo hành số 2604177. Hai chữ số 5 và 1 bị gõ đảo: 864215307094619. Form chỉ kiểm đủ 15 chữ số nên vẫn lưu. Ngày 2026-09-28 khách mang máy đến bảo hành, và tra IMEI không ra phiếu nào. Ví BHPay gặp cùng loại lỗi khi khách gõ sai một chữ số của số thẻ ngân hàng liên kết lúc rút 2.000.000 đ: lệnh đi tới ngân hàng rồi mới bị trả về (diễn biến là minh họa). Một chữ số kiểm tra chặn cả hai lỗi ngay ở form. Bài này tính tay ba thuật toán Luhn, Damm và MOD 97-10, chứng minh chỗ mỗi thuật toán để lọt, rồi đếm bằng cách liệt kê một triệu số.
Đọc nhanh
- Chữ số kiểm tra biến một số thành một phương trình modulo mà số đúng phải thỏa. Form kiểm phương trình đó trước khi gọi database hay ngân hàng. Một chuỗi gõ bừa vẫn qua với xác suất 1/10.
- Luhn (số thẻ, IMEI) bắt mọi lỗi sai một chữ số, nhưng lọt cặp 09 ↔ 90, ba cặp chữ số đôi 22 ↔ 55, 33 ↔ 66, 44 ↔ 77 và mọi lỗi đảo hai chữ số cách một. Không tổng có trọng số nào theo mod 10 bắt được hết lỗi đảo kề.
- Damm thay phép cộng bằng một bảng 10 × 10 và bắt hết lỗi một chữ số lẫn đảo kề với một chữ số. MOD 97-10 của IBAN dùng hai chữ số và bắt hết cả bốn loại lỗi đo trong bài. Liệt kê 1.000.000 số: Luhn lọt 2,22% lỗi đảo kề, Damm và MOD 97-10 lọt 0.
- Chữ số kiểm tra chỉ bắt lỗi vô tình. Ai cũng tính được nên nó không chống sửa số, và số đúng công thức chưa chắc là thẻ hay máy có thật.
1. Lỗi gõ nào hay gặp
Cuối thập niên 1960, nhà toán học Hà Lan J. Verhoeff thống kê các kiểu lỗi khi người ta chép số. Năm 2004, H. Michael Damm làm lại trên khoảng 16.000 mã bưu chính 5 chữ số hỏi qua điện thoại. Bảng dưới chép từ mục 1.1 luận án của Damm:
| Loại lỗi | Dạng | Verhoeff | Damm |
|---|---|---|---|
| Sai một chữ số | a → b | 79,0% | 62,3% |
| Đảo hai chữ số kề | ab → ba | 10,2% | 14,0% |
| Đảo hai chữ số cách một | acb → bca | 0,8% | 0,9% |
| Chữ số đôi | aa → bb | 0,6% | 1,3% |
| Đọc nhầm số | a0 → 1a, như 50 → 15 | 0,5% | 0,4% |
| Chữ số đôi cách một | aca → bcb | 0,3% | 0,4% |
| Lỗi khác | 8,6% | 19,9% |
Hai loại đầu chiếm 89,2% trong thống kê của Verhoeff, nên một thuật toán chữ số kiểm tra phải bắt hết hai loại này trước. Cách làm chung: thêm vào cuối số một chữ số c, tính từ các chữ số còn lại sao cho cả số thỏa một phương trình modulo. Form tính lại phương trình mà không cần tra gì. Mỗi phần dữ liệu chỉ có đúng một c thỏa, nên trong 10 chuỗi gõ bừa có đúng 1 chuỗi qua được. Bài này gọi một lỗi là lọt khi số gõ sai vẫn thỏa phương trình.
2. Luhn: phép tính tay trên một IMEI
Hans Peter Luhn ở IBM nộp bằng sáng chế cho thuật toán này năm 1954 (US 2.950.048, cấp năm 1960). Số thẻ thanh toán theo ISO/IEC 7812 gồm mã tổ chức phát hành 8 chữ số, phần tài khoản tối đa 12 chữ số và một chữ số kiểm tra tính bằng Luhn. IMEI cũng vậy. 3GPP TS 23.003 định nghĩa IMEI gồm mã TAC 8 chữ số, số serial SNR 6 chữ số và chữ số kiểm tra CD tính bằng Luhn trên 14 chữ số đầu. Mục đích ghi trong chuẩn là tránh lỗi chép tay, ví dụ khi khách báo mất máy ở quầy chăm sóc khách hàng.
Đánh số từ phải sang, chữ số kiểm tra ở vị trí 1.
f(d) = 2d nếu d ≤ 4
f(d) = 2d − 9 nếu d ≥ 5 (gấp đôi rồi cộng hai chữ số: 7 → 14 → 5)
Hợp lệ khi Σ dᵢ (i lẻ) + Σ f(dᵢ) (i chẵn) ≡ 0 (mod 10)
Tính tay chữ số kiểm tra cho IMEI của đơn 10097, với 14 chữ số đầu 86425130709461 (số tự đặt cho bài). Các chữ số ở vị trí chẵn tính từ phải là 1, 4, 0, 0, 1, 2, 6. Gấp đôi được 2, 8, 0, 0, 2, 4, 12, và 12 thành 3. Tổng nhóm này là 19. Bảy chữ số ở vị trí lẻ là 6, 9, 7, 3, 5, 4, 8, tổng 42. Cả hai nhóm được 61, nên chữ số kiểm tra là 70 − 61 = 9 và IMEI đầy đủ là 864251307094619.
Lỗi ở đầu bài, gõ 51 thành 15, làm tổng thành 65, không chia hết cho 10, nên form có Luhn báo lỗi ngay. Ba đoạn code ở mục 2, 4 và 5 ghép thành một file chu_so.py, chỉ dùng thư viện chuẩn. Đoạn đầu là Luhn:
GAP_DOI = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9] # f(d): gấp đôi, quá 9 thì trừ 9
def luhn_tong(so: str) -> int: # chữ số cuối ở vị trí 1, không gấp đôi
return sum(GAP_DOI[int(c)] if i % 2 else int(c) for i, c in enumerate(reversed(so)))
def luhn_tao(than: str) -> str: # than: phần chưa có chữ số kiểm tra
return str(-luhn_tong(than + "0") % 10)
def luhn_dung(so: str) -> bool:
return luhn_tong(so) % 10 == 0
3. Vì sao Luhn để lọt 09 ↔ 90
Hai chữ số kề nhau luôn có một chữ số ở vị trí gấp đôi và một chữ số không. Đảo a, b làm phần đóng góp của cặp đổi từ f(a) + b thành f(b) + a, tức tổng đổi một lượng g(a) − g(b), với g(x) = f(x) − x. Lỗi lọt khi lượng đó chia hết cho 10. Bảng dưới tính g, cùng h(x) = f(x) + x dùng cho lỗi chữ số đôi:
x 0 1 2 3 4 5 6 7 8 9
f(x) 0 2 4 6 8 1 3 5 7 9
g(x) = f(x) − x 0 1 2 3 4 6 7 8 9 0 (mod 10)
h(x) = f(x) + x 0 3 6 9 2 6 9 2 5 8 (mod 10)
g chỉ trùng ở 0 và 9, nên trong 45 cặp chữ số khác nhau chỉ cặp {0, 9} lọt. Ở IMEI trên, cặp 09 tại vị trí 6 và 5 tính từ phải góp f(0) + 9 = 9. Đảo thành 90 thì góp f(9) + 0 = 9. Tổng vẫn 70, và 864251307904619 qua kiểm tra.
Lỗi chữ số đôi aa → bb đổi tổng một lượng h(a) − h(b). h trùng ở ba cặp (2, 5), (3, 6), (4, 7), nên 22 ↔ 55, 33 ↔ 66 và 44 ↔ 77 lọt, tức 6 trong 90 cách thay. Lỗi đảo hai chữ số cách một còn tệ hơn. Hai vị trí cách nhau hai ô có cùng trọng số, nên đổi chỗ không làm tổng đổi, và Luhn lọt mọi lỗi loại này. Đảo 8 và 4 ở đầu IMEI thành 468251307094619 vẫn qua.
Chỗ hở ở cặp đảo kề không phải lỗi riêng của Luhn. Xét mọi lược đồ dạng Σ σᵢ(dᵢ) ≡ 0 (mod 10), với σᵢ là một hoán vị các chữ số dùng cho vị trí i. Bắt hết lỗi đảo ở vị trí i, i + 1 nghĩa là g(x) = σᵢ(x) − σᵢ₊₁(x) cho mười giá trị khác nhau, tức g là một hoán vị của 0..9. Khi đó Σ g(x) ≡ 0 + 1 + … + 9 = 45 ≡ 5 (mod 10). Nhưng Σ g(x) = Σ σᵢ(x) − Σ σᵢ₊₁(x) = 45 − 45 = 0, mâu thuẫn. Với Luhn, Σ g(x) = 0 + 1 + 2 + 3 + 4 + 6 + 7 + 8 + 9 + 0 = 40 ≡ 0, và giá trị 5 không bao giờ xuất hiện. Đổi bộ trọng số nào cũng vậy: phải bỏ phép cộng mod 10, hoặc thêm chữ số.
4. Damm: thay phép cộng bằng một bảng
Verhoeff đã bỏ phép cộng. Bằng sáng chế US 3.675.202 (cấp năm 1972) của ông dùng nhóm nhị diện của ngũ giác đều, D5: 10 phép quay và lật, với phép nhân không giao hoán, kèm một ánh xạ chữ số đổi theo vị trí. Damm (2004) dùng một tựa nhóm (quasigroup): bảng 10 × 10 mà mỗi dòng và mỗi cột là một hoán vị của 0..9, không cần tính kết hợp như nhóm. Bảng dưới, x ∗ y ở dòng x, cột y, là bảng thường dùng cho thuật toán Damm. Nó suy ra từ tựa nhóm ở trang 111 luận án của Damm bằng cách đổi tên các phần tử và đổi thứ tự dòng cho đường chéo toàn 0 (tôi đã kiểm phép biến đổi này bằng code).
∗ | 0 1 2 3 4 5 6 7 8 9 t₀ = 0, tₖ = tₖ₋₁ ∗ dₖ
--+--------------------
0 | 0 3 1 7 5 9 8 6 4 2 chữ số kiểm tra c = tₙ
1 | 7 0 9 2 1 5 4 8 6 3 hợp lệ khi đi hết cả số, kể cả c,
2 | 4 2 0 6 8 7 1 3 5 9 trạng thái về 0 (vì c ∗ c = 0)
3 | 1 7 5 0 9 8 3 4 2 6
4 | 6 1 2 3 0 4 5 9 7 8
5 | 3 6 7 4 2 0 9 5 8 1
6 | 5 8 6 9 7 2 0 1 3 4
7 | 8 9 4 5 3 6 2 0 1 7
8 | 9 4 3 8 6 1 7 2 0 5
9 | 2 5 8 1 4 3 6 7 9 0
Số phiếu bảo hành ở đầu bài là PhieuBaoHanhId 260417 nối chữ số Damm. Đi từ trạng thái 0: 0 ∗ 2 = 1, 1 ∗ 6 = 4, 4 ∗ 0 = 6, 6 ∗ 4 = 7, 7 ∗ 1 = 9, 9 ∗ 7 = 7. Chữ số kiểm tra là 7, số in trên phiếu là 2604177, và bước cuối 7 ∗ 7 = 0.
Ba tính chất của bảng tạo ra khả năng bắt lỗi. Mỗi dòng là một hoán vị, nên thay một chữ số dₖ thì trạng thái tₖ đổi. Mỗi cột là một hoán vị, nên hai trạng thái khác nhau đi qua cùng một chữ số vẫn khác nhau tới cuối, và trạng thái cuối khác 0. Tính chất thứ ba, Damm gọi là phản đối xứng hoàn toàn yếu: (c ∗ x) ∗ y ≠ (c ∗ y) ∗ x với mọi c và mọi x ≠ y. Nó bảo đảm đảo hai chữ số kề luôn làm trạng thái đổi. Khách gõ 2640177 thay cho 2604177: sau 26 trạng thái là 4, (4 ∗ 0) ∗ 4 = 7 còn (4 ∗ 4) ∗ 0 = 0, và số gõ sai kết thúc ở 9 chứ không phải 0.
Damm chứng minh có tựa nhóm phản đối xứng hoàn toàn ở mọi cấp trừ 2 và 6. Bằng tìm kiếm trên máy tính, ông cũng chỉ ra rằng hệ chữ số kiểm tra dựng trên một tựa nhóm cấp 10 không bắt được hết lỗi đảo cách một hay hết lỗi chữ số đôi; hệ dựng trên nhóm cấp 10, như D5 của Verhoeff, cũng vậy. Mục 6 đếm phần lọt đó. Đoạn thứ hai của chu_so.py:
DAMM = ["0317598642", "7092154863", "4206871359", "1750983426", "6123045978",
"3674209581", "5869720134", "8945362017", "9438617205", "2581436790"]
def damm_tao(so: str) -> str: # trạng thái cuối khi đi hết chuỗi
t = 0
for c in so:
t = int(DAMM[t][int(c)])
return str(t)
def damm_dung(so: str) -> bool:
return damm_tao(so) == "0"
5. MOD 97-10: hai chữ số kiểm tra và IBAN
ISO/IEC 7064 đặt tên MOD 97-10 cho lược đồ hai chữ số kiểm tra theo modulo 97, số nguyên tố lớn nhất dưới 100. Với phần dữ liệu N, hai chữ số kiểm tra là c = 98 − (N × 100 mod 97), từ 02 đến 98, và số hợp lệ khi (N × 100 + c) mod 97 = 1. Với N = 260417: 26.041.700 = 97 × 268.471 + 13, nên c = 98 − 13 = 85, và số đầy đủ là 26041785. Kiểm lại: 13 + 85 = 98 ≡ 1 (mod 97).
Mỗi loại lỗi ở bảng mục 1 làm giá trị của số đổi một lượng d × 10ᵏ. Sai một chữ số cho d từ −9 đến 9, đảo kề cho d = 9(a − b), đảo cách một cho d = 99(a − b), chữ số đôi cho d = 11(b − a). Lỗi lọt khi lượng đó chia hết cho 97. 97 là số nguyên tố, không chia hết 10ᵏ, 9, 11 hay 99 (99 = 97 + 2), và |a − b| ≤ 9 < 97. Vì vậy không lỗi nào thuộc bốn loại đó lọt được. Muốn lọt, giá trị phải đổi đúng một bội của 97, tức sai ít nhất hai chữ số theo một cách cụ thể.
IBAN dùng đúng lược đồ này. Theo hướng dẫn EBS204 của Ủy ban Tiêu chuẩn Ngân hàng châu Âu, kiểm một IBAN gồm ba bước: chuyển 4 ký tự đầu (mã nước và hai chữ số kiểm tra) ra cuối, đổi chữ cái thành số với A = 10, B = 11 đến Z = 35, rồi lấy mod 97, đúng khi được 1. Ví dụ trong tài liệu đó: BE62 5100 0754 7061 thành 510007547061BE62, rồi 510007547061111462. Số 18 chữ số này vượt số nguyên 32 bit, nên EBS204 tính theo đoạn: 510007547 mod 97 = 74, ghép 74 với 7 chữ số tiếp được 740611114 mod 97 = 12, ghép 12 với phần còn lại được 1262 mod 97 = 1.
IBAN Registry release 102 (tháng 6/2026) của SWIFT, cơ quan đăng ký của ISO 13616, liệt kê định dạng IBAN của 89 mã nước và không có VN. Hàm iban_dung dưới đây trả True cho cả 89 IBAN mẫu trong registry đó. Đoạn cuối của chu_so.py kèm phần tự kiểm bảng Damm và hai ví dụ chính thức. Chạy python chu_so.py in 9 7 85, đúng ba phép tính tay ở trên:
def mod97_tao(than: str) -> str: # ISO/IEC 7064 MOD 97-10, hai chữ số
return f"{98 - int(than + '00') % 97:02d}"
def mod97_dung(so: str) -> bool:
return int(so) % 97 == 1
def iban_dung(iban: str) -> bool: # 4 ký tự đầu ra cuối, A = 10 ... Z = 35
s = iban.replace(" ", "").upper()
return s.isascii() and s.isalnum() and mod97_dung("".join(str(int(c, 36)) for c in s[4:] + s[:4]))
if __name__ == "__main__":
B = [[int(c) for c in r] for r in DAMM] # dòng, cột là hoán vị; đường chéo 0; phản đối xứng
assert all(len(set(r)) == 10 for r in B) and all(len({r[j] for r in B}) == 10 for j in range(10))
assert all(B[i][i] == 0 for i in range(10))
assert all(B[B[c][x]][y] != B[B[c][y]][x] for c in range(10) for x in range(10) for y in range(10) if x != y)
assert luhn_tao("26053179311383") == "7" # ví dụ ở phụ lục B, 3GPP TS 23.003
assert iban_dung("BE62 5100 0754 7061") # ví dụ trong ECBS EBS204
print(luhn_tao("86425130709461"), damm_tao("260417"), mod97_tao("260417"))
6. Liệt kê một triệu số
Với số 6 chữ số thì thử hết được, không cần mô phỏng ngẫu nhiên. Đoạn dưới lấy mọi số từ 000000 đến 999999, gắn chữ số kiểm tra theo từng thuật toán, sinh mọi lỗi của bốn loại đầu ở bảng mục 1 tại mọi vị trí, kể cả trên chữ số kiểm tra, rồi đếm số gõ sai vẫn qua kiểm tra. Cần numpy. Lưu thành liet_ke.py cạnh chu_so.py:
import numpy as np
from chu_so import GAP_DOI, DAMM
X = ((np.arange(10**6)[:, None] // 10 ** np.arange(5, -1, -1)) % 10).astype(np.int8) # mọi số 6 chữ số
GD, BD = np.array(GAP_DOI, np.int8), np.array([[int(c) for c in r] for r in DAMM], np.int8)
def luhn(M): # tổng Luhn mod 10 của từng dòng; cột cuối trọng số 1
gap = (M.shape[1] - 1 - np.arange(M.shape[1])) % 2 == 1
return np.where(gap, GD[M], M).sum(axis=1, dtype=np.int16) % 10
def damm(M): # trạng thái cuối của Damm
t = np.zeros(len(M), np.int8)
for j in range(M.shape[1]):
t = BD[t, M[:, j]]
return t
def mod97(M): # giá trị mod 97, Horner từng chữ số
r = np.zeros(len(M), np.int16)
for j in range(M.shape[1]):
r = (r * 10 + M[:, j]) % 97
return r
kt97 = 98 - (mod97(X) * 100) % 97
MA = {"Luhn": (np.c_[X, (-luhn(np.c_[X, np.zeros(len(X), np.int8)])) % 10], lambda M: luhn(M) == 0),
"Damm": (np.c_[X, damm(X)], lambda M: damm(M) == 0),
"MOD 97-10": (np.c_[X, kt97 // 10, kt97 % 10], lambda M: mod97(M) == 1)}
def cac_loi(M): # mọi cách gõ sai theo bốn loại
n = M.shape[1]
for p in range(n):
for k in range(1, 10):
S = M.copy(); S[:, p] = (S[:, p] + k) % 10
yield "một chữ số", S
for p, q, loai in [(p, p + 1, "đảo kề") for p in range(n - 1)] + [(p, p + 2, "đảo cách một") for p in range(n - 2)]:
S = M[M[:, p] != M[:, q]]; S[:, [p, q]] = S[:, [q, p]]
yield loai, S
for p in range(n - 1):
for k in range(1, 10):
S = M[M[:, p] == M[:, p + 1]]; S[:, [p, p + 1]] = (S[:, [p, p + 1]] + k) % 10
yield "chữ số đôi", S
print(f"{'loại lỗi':<14}" + "".join(f"{ten:>17}" for ten in MA))
kq = {}
for ten, (M, dung) in MA.items():
M = M.astype(np.int8)
assert dung(M).all()
for loai, S in cac_loi(M):
lot, tong = kq.get((loai, ten), (0, 0))
kq[loai, ten] = (lot + int(dung(S).sum()), tong + len(S))
for loai in dict.fromkeys(l for l, _ in kq):
print(f"{loai:<14}" + "".join(f"{'%d/%d' % kq[loai, ten]:>17}" for ten in MA))
Mỗi ô là số lỗi lọt trên tổng số lỗi đã sinh. Python 3.14.3, numpy 2.4.4, laptop Intel Core Ultra 5 125U, Windows 11. Ba lần chạy cho cùng các con số, hết 25 đến 48 giây, trung vị 28 giây; thời gian dao động vì máy đang chạy việc khác.
loại lỗi Luhn Damm MOD 97-10
một chữ số 0/63000000 0/63000000 0/72000000
đảo kề 120000/5400000 0/5400000 0/6317526
đảo cách một 4500000/4500000 441400/4500000 0/5399999
chữ số đôi 360000/5400000 504000/5400000 0/6142266
Damm bắt hết lỗi đảo kề với một chữ số, Luhn bỏ sót mọi lỗi đảo cách một
Bảng số liệu
| Luhn | Damm | MOD 97-10 | |
|---|---|---|---|
| Sai một chữ số | 100,00% | 100,00% | 100,00% |
| Đảo hai chữ số kề | 97,78% | 100,00% | 100,00% |
| Đảo cách một | 0,00% | 90,19% | 100,00% |
| Chữ số đôi aa → bb | 93,33% | 90,67% | 100,00% |
Hàng Luhn khớp mục 3 tới từng con số: lọt 2 trong 90 cách đảo kề (2,22%), 6 trong 90 cách thay chữ số đôi (6,67%), và mọi lỗi đảo cách một. Ba tỉ lệ này không phụ thuộc độ dài, nên đúng cho IMEI 15 chữ số. Damm lọt 0 lỗi một chữ số và 0 lỗi đảo kề như mục 4 chứng minh, nhưng lọt 9,81% lỗi đảo cách một và 9,33% lỗi chữ số đôi, đúng giới hạn Damm đã chỉ ra cho cấp 10. Hai tỉ lệ sau đổi chút ít theo độ dài số, vì lỗi chạm chữ số kiểm tra lọt theo tỉ lệ khác lỗi nằm giữa số: cùng cách đếm trên mọi số 5 chữ số cho 9,60% và 9,51%. MOD 97-10 lọt 0 ở cả bốn loại, như mục 5.
Ghép với tần suất của Verhoeff ở mục 1 (ước lượng): trong 1.000 lần gõ sai, Luhn để lọt 1.000 × (0,102 × 2/90 + 0,008 × 1 + 0,006 × 6/90) ≈ 10,7 lần thuộc bốn loại đã đo, phần lớn là đảo cách một. Damm lọt 1.000 × (0,008 × 0,0981 + 0,006 × 0,0933) ≈ 1,3 lần. MOD 97-10 lọt 0. 9,4% lỗi còn lại bài không đo; với chúng, cỡ 1/10 lọt qua một chữ số kiểm tra và cỡ 1/97 lọt qua hai chữ số, theo tỉ lệ chuỗi gõ bừa qua được.
7. Đặt vào form và database của BanHang
IMEI và số thẻ không cho chọn thuật toán: chuẩn đã chốt Luhn. Mã do BanHang tự cấp thì được chọn. Số phiếu bảo hành do khách gõ khi tra cứu, nên dùng Damm: một chữ số, bắt hết hai loại lỗi chiếm 89,2% trong thống kê của Verhoeff. Với mã mà gõ sai là chuyển nhầm tiền, như mã ví nhận khi chuyển P2P, chữ số thứ hai của MOD 97-10 đáng giá hơn. Kiểm ở form, lưu thành form.py:
from chu_so import luhn_dung, damm_dung
def kiem_imei(nhap: str) -> str | None: # None là hợp lệ
so = "".join(nhap.split()).replace("-", "") # bỏ dấu cách, gạch nối người dùng gõ kèm
if len(so) != 15 or not (so.isascii() and so.isdigit()):
return "IMEI phải có đúng 15 chữ số"
if not luhn_dung(so):
return "IMEI sai, kiểm tra lại từng chữ số"
return None
def so_phieu(nhap: str) -> int | None: # số phiếu in = PhieuBaoHanhId + chữ số Damm
so = "".join(nhap.split()).replace("-", "")
if len(so) < 2 or not (so.isascii() and so.isdigit()) or not damm_dung(so):
return None
return int(so[:-1])
for nhap in ["8642 5130 7094 619", "864215307094619", "864251307904619", "86425130709461٩"]:
print(f"{nhap!r:24} {kiem_imei(nhap)}")
for nhap in ["2604177", "2640177", "2604717"]:
print(f"{nhap!r:24} {so_phieu(nhap)}")
'8642 5130 7094 619' None
'864215307094619' IMEI sai, kiểm tra lại từng chữ số
'864251307904619' None
'86425130709461٩' IMEI phải có đúng 15 chữ số
'2604177' 260417
'2640177' None
'2604717' None
Dòng thứ ba là lỗi 09 ↔ 90 lọt qua Luhn. Dòng thứ tư cần isascii(): '٩'.isdigit() là True và int('٩') bằng 9, nên thiếu nó thì chuỗi có chữ số Ả Rập này qua cả Luhn. Số phiếu chỉ cần kiểm Damm rồi bỏ chữ số cuối để tra theo khóa chính, nên database không lưu chữ số kiểm tra.
Database giữ lớp chặn cuối cho dữ liệu đến từ đường khác form, như file import hay API đối tác. Bảng mới dbo.PhieuBaoHanh của BanHang, thử trên LocalDB SQL Server 2019 CU27 (15.0.4382) với database riêng Kumeo_chusokiemtra, collation Vietnamese_100_CI_AS:
CREATE OR ALTER FUNCTION dbo.fn_LuhnDung (@So varchar(19))
RETURNS bit WITH SCHEMABINDING
AS
BEGIN
DECLARE @i int = DATALENGTH(@So), @tong int = 0, @d int, @gap bit = 0;
IF ISNULL(@i, 0) = 0 RETURN 0;
WHILE @i > 0 -- đi từ phải sang trái
BEGIN
SET @d = ASCII(SUBSTRING(@So, @i, 1)) - 48; -- ký tự khác '0'..'9' ra ngoài 0..9
IF @d NOT BETWEEN 0 AND 9 RETURN 0;
IF @gap = 1 SET @d = 2 * @d - 9 * (@d / 5); -- gấp đôi, trừ 9 nếu quá 9
SELECT @tong += @d, @gap = 1 - @gap, @i -= 1;
END;
RETURN IIF(@tong % 10 = 0, 1, 0);
END;
GO
CREATE TABLE dbo.PhieuBaoHanh (
PhieuBaoHanhId int NOT NULL CONSTRAINT PK_PhieuBaoHanh PRIMARY KEY CLUSTERED, -- in kèm chữ số Damm
DonHangId bigint NOT NULL,
SanPhamId int NOT NULL,
IMEI char(15) NOT NULL CONSTRAINT CK_PhieuBaoHanh_IMEI CHECK (dbo.fn_LuhnDung(IMEI) = 1),
NgayBatDau date NOT NULL,
NgayHetHan AS DATEADD(MONTH, 12, NgayBatDau)
);
CREATE INDEX IX_PhieuBaoHanh_IMEI ON dbo.PhieuBaoHanh (IMEI);
GO
INSERT INTO dbo.PhieuBaoHanh VALUES (260417, 10097, 42, '864251307094619', '20260514'); -- đúng
INSERT INTO dbo.PhieuBaoHanh VALUES (260418, 10097, 42, '864215307094619', '20260514'); -- đảo 51 thành 15
INSERT INTO dbo.PhieuBaoHanh VALUES (260419, 10097, 42, '864251307904619', '20260514'); -- đảo 09 thành 90
INSERT INTO dbo.PhieuBaoHanh VALUES (260420, 10097, 42, '86425130709461', '20260514'); -- thiếu một chữ số
SELECT PhieuBaoHanhId, IMEI, NgayHetHan FROM dbo.PhieuBaoHanh;
Chạy bằng sqlcmd, lệnh INSERT thứ hai và thứ tư nhận lỗi 547, The INSERT statement conflicted with the CHECK constraint "CK_PhieuBaoHanh_IMEI". SELECT cuối trả hai dòng, 260417 và 260419, cùng NgayHetHan 2027-05-14. Chuỗi 14 chữ số vào cột char(15) được đệm một dấu cách, và hàm đọc theo DATALENGTH nên thấy dấu cách đó. Lần đảo 09 thành 90 vào được bảng: CHECK chỉ mạnh bằng thuật toán nó gọi. Ba điều kiện khi đưa lên BanHang:
- Theo tài liệu Scalar UDF Inlining, hàm vô hướng dùng trong
CHECKkhông được inline, nên chạy một lần cho mỗi dòng ghi. Vài nghìn phiếu mỗi ngày không đáng kể. Nạp lô lớn từ hệ thống cũ thì đo trước. - Bảng đã có dữ liệu thì chạy
SELECT PhieuBaoHanhId, IMEI FROM dbo.PhieuBaoHanh WHERE dbo.fn_LuhnDung(IMEI) = 0trước khi thêm ràng buộc. Mỗi dòng trả về là một phiếu gõ sai mà khách sẽ tra không ra. Thêm bằngWITH NOCHECKthì dòng cũ không được kiểm và ràng buộc không được trình tối ưu tin, cách siết lại ở bài migration không dừng hệ thống. - IMEI lấy từ mạng di động, không phải từ tem hộp, có thể không mang chữ số kiểm tra. TS 23.003 ghi chữ số kiểm tra không nằm trong các chữ số được truyền khi kiểm IMEI, và khi máy truyền thì chữ số cuối là chữ số dự phòng, đặt bằng 0. Nhập IMEI từ nguồn đó thì tính lại chữ số kiểm tra từ 14 chữ số đầu.
Chữ số kiểm tra không thay được xác minh
Thuật toán công khai. Ai sửa số thẻ trong một request cũng tính lại được chữ số cuối bằng một dòng code, nên chống sửa là việc của chữ ký có khóa bí mật, như HMAC trên webhook thanh toán. Số qua Luhn cũng chưa phải thẻ có thật, vì 1 trong 10 chuỗi bất kỳ qua Luhn. Lệnh rút của BHPay là một lệnh chuyển sang tài khoản đối ứng ngân hàng liên kết ViId = 1 trong sổ cái của ví, và vẫn chờ ngân hàng xác minh thẻ cùng chủ thẻ. Với IMEI, lớp chặn mạnh hơn là đối chiếu với IMEI đã ghi lúc nhập kho cho đúng đơn, hoặc quét mã vạch trên hộp thay vì gõ.
Những chỗ hay hiểu sai
- "Số qua Luhn là số thẻ có thật." Cứ 10 chuỗi bất kỳ thì 1 chuỗi qua Luhn. Chỉ ngân hàng biết thẻ có tồn tại hay không.
- "Luhn bắt mọi lỗi đảo hai chữ số." Nó lọt 09 ↔ 90 và mọi lỗi đảo hai chữ số cách một: 4.500.000 trên 4.500.000 ở mục 6.
- "Đổi bộ trọng số khác là vá được chỗ hở của Luhn." Mục 3 chứng minh mọi tổng có trọng số theo mod 10 đều lọt ít nhất một cặp đảo kề. Phải bỏ phép cộng như Damm, hoặc dùng hai chữ số như MOD 97-10.
- "Có
CHECKtrong database thì không cần kiểm ở form."CHECKchỉ báo lỗi 547 sau một vòng gọi API. Form báo ngay khi người gõ còn cầm hộp máy.
Đọc tiếp
- Xác thực webhook thanh toán: HMAC, thứ chống sửa số mà chữ số kiểm tra không làm được.
- Chuyển tiền giữa hai ví: sổ cái của
BHPay, nơi lệnh rút về ngân hàng liên kết được ghi và đối soát. - Migration không dừng hệ thống: thêm ràng buộc vào bảng lớn đang nhận ghi,
WITH NOCHECKrồiWITH CHECK. - Kiểu dữ liệu, collation và khóa chính: truyền IMEI dạng
nvarcharvào cộtcharlàm chính cột bị chuyển kiểu.
Nguồn
- 3GPP TS 23.003, Numbering, addressing and identification, bản ETSI TS 123 003 V15.4.0: mục 6.2.1 Composition of IMEI, phụ lục B IMEI Check Digit computation.
- Pay.UK, Issuer Identification Number: cấu trúc số thẻ theo ISO/IEC 7812:2017, chữ số kiểm tra tính bằng Luhn.
- H. P. Luhn, US 2.950.048, Computer for verifying numbers, nộp 1954-01-06, cấp 1960-08-23.
- J. Verhoeff, US 3.675.202, cấp 1972-07-04: thiết bị kiểm chữ số dùng nhóm nhị diện của ngũ giác.
- H. M. Damm, Total anti-symmetrische Quasigruppen, luận án, Philipps-Universität Marburg, 2004: tóm tắt, mục 1.1 (thống kê lỗi của Verhoeff và của Damm), chương 5 (định nghĩa), chương 6 và 7 (trang 89 đến 111).
- European Committee for Banking Standards, EBS204 V3.2, IBAN: International Bank Account Number, 2003: chương 6, tính và kiểm chữ số kiểm tra.
- SWIFT, IBAN Registry, release 102, tháng 6/2026.
- Microsoft Learn, Scalar UDF Inlining: hàm dùng trong cột tính toán hoặc
CHECKkhông được inline.