Đọc định dạng file không có tài liệu: nhập doanh thu từ máy tính tiền cũ
File doanh thu nhị phân của máy tính tiền cũ ở 12 cửa hàng BanHang không có tài liệu; hex dump, tự tương quan, so sánh vi sai và dò CRC tìm ra định dạng, rồi parser C# đọc 360 file, 68.036 dòng, khớp phiếu Z 360/360 và từ chối mọi file hỏng một byte.
Mục lục
- 1. Vấn đề: 360 file doanh thu không ai đọc được
- 2. Mục đích: 360 file khớp phiếu Z từng đồng, file hỏng bị từ chối
- 3. Cơ sở lý thuyết: đọc byte, tìm chu kỳ, so vi sai, khớp CRC
- 4. Cách giải quyết: giả thuyết nhỏ, kiểm trên đủ 360 file
- 5. Cách cài đặt: bốn file C# chạy bằng dotnet run
- 6. Chứng minh: 360/360 phiếu Z khớp, 0 lần lọt CRC
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: 12 cửa hàng
BanHangcòn máy tính tiền cũ ghi doanh thu ra file.DATnhị phân không có tài liệu, nhà cung cấp đã ngừng hỗ trợ. - Cách giải: Tự tương quan tìm độ dài bản ghi, hai file chỉ khác một giao dịch cho nghĩa từng trường, dò CRC trong 78 tổ hợp, kiểm mọi giả thuyết trên đủ 360 file.
- Chứng minh: 68.036 dòng khớp phiếu Z 360/360, lệch 0 đồng; trong 100.000 lần hỏng một byte, có CRC 0 lần lọt, tắt CRC 12.390 lần thành dữ liệu sai im lặng.
- Trong .NET:
BinaryPrimitivestrênReadOnlySpan<byte>,CodePagesEncodingProvidercho Windows-1258,Normalize(NormalizationForm.FormC),SqlBulkCopyvào bảng tạm.
1. Vấn đề: 360 file doanh thu không ai đọc được
12 trong 120 cửa hàng của BanHang còn dùng một đời máy tính tiền cũ. Mỗi tối lúc chốt ca, máy in phiếu Z, tờ tổng kết doanh thu trong ngày, và ghi một file như Z260930.DAT lên thẻ nhớ. Nhân viên gõ tay các con số trên phiếu Z vào BanHang.
Phiếu Z chỉ có tổng. BanHang biết cửa hàng 23 bán 110.362.000 đ ngày 30/09 nhưng không biết bán những mã hàng nào, nên tồn kho của 12 cửa hàng chưa từng được trừ theo từng dòng bán. Chi tiết từng dòng nằm trong file .DAT, nhưng nhà cung cấp máy đã ngừng hỗ trợ và không để lại tài liệu định dạng. Máy sẽ bị thay trong tháng 10; kế toán cần dữ liệu tháng 9 trước khi khóa sổ: 12 cửa hàng × 30 ngày là 360 file.
Máy và định dạng trong bài là hư cấu
Máy tính tiền, định dạng KTZ 2.3, phiếu Z và mọi file trong bài do một bộ sinh viết riêng cho bài (mục 5), không ứng với máy hay hãng có thật. Chỉ làm việc này trên phần mềm và dữ liệu bạn có quyền; ở đây là doanh thu của chính BanHang.
2. Mục đích: 360 file khớp phiếu Z từng đồng, file hỏng bị từ chối
- 360/360 file cho đúng 8 con số in trên phiếu Z: số dòng bán, số dòng trả, tiền bán, tiền trả, doanh thu, và doanh thu theo tiền mặt, thẻ,
BHPay. Lệch 0 đồng. - 100% dòng có mã hàng trong
dbo.SanPham, tên và đơn giá trùng danh mục khi so nhị phân. - File hỏng một byte bị từ chối: 0 lần lọt.
- Giả thuyết về một trường chỉ vào parser khi đúng trên đủ 360 file.
- Ngoài phạm vi: ghi dữ liệu ngược vào máy, đọc firmware.
3. Cơ sở lý thuyết: đọc byte, tìm chu kỳ, so vi sai, khớp CRC
Hex dump, endianness và BCD
Hex dump in mỗi dòng 16 byte: offset, giá trị hex, rồi cột ASCII. Đây là file B.DAT của máy thử (mục 4) sau bốn giao dịch, bỏ bảy dòng giữa:
00000000 4b 54 5a 1a 02 03 00 00 01 00 00 00 a8 87 bf 6a KTZ............j
00000010 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000020 01 00 01 00 23 81 bf 6a 07 00 00 00 01 00 00 00 ....#..j........
00000030 09 90 00 00 00 00 09 90 00 43 e1 70 20 73 61 f2 .........C.p sa.
000000b0 01 00 04 00 24 84 bf 6a 2a 00 00 00 03 00 00 00 ....$..j*.......
000000c0 12 90 00 00 00 00 38 70 00 d4 ec 70 20 6c fd 6e ......8p...p l.n
000000d0 67 20 73 69 6c 69 63 6f 6e 20 20 20 20 20 20 20 g silicon
000000e0 ca fa fe 76 ...v
Bốn byte đầu, KTZ và 1a, là magic number, chữ ký nhận loại file; 02 03 có thể là phiên bản 2.3. Ba nhóm 23 81 bf 6a, 24 84 bf 6a, a8 87 bf 6a khác nhau ở byte trái, giống nhau ở byte phải: dấu hiệu số nguyên little-endian, byte thấp ghi trước. Đọc ngược, 0x6ABF87A8 là 1.790.937.000, cỡ số giây Unix năm 2026. Số 42 kiểu uint32 little-endian là 2a 00 00 00, big-endian là 00 00 00 2a.
Ở offset 0x30 có 09 90 00; ở bản ghi cuối có 12 90 00 rồi 38 70 00. Đọc mỗi nửa byte như một chữ số thì được 99.000, 129.000 và 387.000 = 3 × 129.000. Đó là BCD (binary-coded decimal): mỗi nửa byte một chữ số 0–9, nên nửa byte a đến f là dấu hiệu dữ liệu hỏng.
Tự tương quan tìm độ dài bản ghi
File gồm bản ghi cỡ cố định L thì cấu trúc lặp lại sau mỗi L byte: byte loại giao dịch hầu hết là 01, byte cao của mã hàng luôn 00, tên đệm bằng 20. Tự tương quan (autocorrelation) đo sự lặp đó mà không cần biết trường nào ở đâu:
r(k) = (số vị trí i có b[i] = b[i + k]) / (n − k)
Byte ngẫu nhiên cho r(k) quanh 1/256 ≈ 0,4%. Bản ghi cỡ L cho đỉnh ở k = L, 2L, 3L.
So sánh vi sai
So sánh vi sai (differential analysis): tạo hai file chỉ khác nhau một thay đổi biết trước, rồi xem byte nào đổi. Đổi số lượng 3 thành 4 mà một byte đổi 03 → 04 thì đó là số lượng. BanHang còn một máy cùng đời ở văn phòng; trước mỗi lần thử, máy được xóa bộ nhớ và đặt đồng hồ về cùng một giờ, nên hai file chỉ khác ở chỗ đã đổi.
CRC: sáu tham số, và vì sao bắt mọi lỗi một byte
Bốn byte cuối đổi mỗi khi bất kỳ byte nào đổi và trông như ngẫu nhiên: dấu hiệu của checksum. CRC (cyclic redundancy check) coi dữ liệu là đa thức hệ số 0/1 và lấy phần dư khi chia cho đa thức sinh G(x). Theo mô hình Rocksoft, một CRC được xác định bằng sáu tham số: width, poly, init, refin, refout, xorout. Catalogue CRC của Greg Cook liệt kê các bộ tham số đang dùng, mỗi bộ kèm check, CRC của chuỗi "123456789", để kiểm code của mình.
Lỗi một byte ở vị trí i là đa thức lỗi E(x) = x^i · B(x), B khác 0, bậc dưới 8. CRC chỉ bỏ sót khi G(x) chia hết E(x). 12 CRC-32 ở mục 4 đều có poly lẻ, tức G(x) có hệ số tự do 1, nên G nguyên tố cùng nhau với x^i và phải chia hết B: không thể, vì bậc B dưới 8 còn bậc G là 32. refin chỉ đảo bit trong byte; init và xorout góp như nhau vào CRC của file đúng và file hỏng; kết luận không đổi. Cùng ý tưởng với chữ số kiểm tra của IMEI: chọn phép tính để loại lỗi hay gặp nhất luôn bị bắt.
Windows-1258: dấu thanh là ký tự riêng
Ốp lưng ra d4 ec 70 20 6c fd 6e 67, 8 byte cho 7 chữ. Trong Windows-1258, d4 là Ô, fd là ư, còn phần lớn dấu thanh là ký tự kết hợp đứng sau chữ: cc huyền U+0300, ec sắc U+0301, d2 hỏi U+0309, de ngã U+0303, f2 nặng U+0323. Vài chữ như á, à, é có sẵn một byte: Cáp sạc ra 43 e1 70 20 73 61 f2 63.
.NET 10 mặc định chỉ có ASCII, ISO-8859-1 và các dạng UTF; Encoding.GetEncoding(1258) ném NotSupportedException cho tới khi gọi Encoding.RegisterProvider(CodePagesEncodingProvider.Instance). Provider có 109 bảng mã, Windows-1258 là bảng tiếng Việt duy nhất. Giải d4 ec ra Ô cộng U+0301, hai char, nên so ordinal với "Ốp" trong dbo.SanPham là khác; Normalize(NormalizationForm.FormC) gộp về dạng NFC, một char.
4. Cách giải quyết: giả thuyết nhỏ, kiểm trên đủ 360 file
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| Đoán tay trên hex dump | Một file là đủ; thấy nhanh magic, chữ, BCD | Dễ tin vào trùng hợp; không biết số nào là gì | Bước đầu, để có giả thuyết |
| Công cụ tự động: tự tương quan, dò CRC, chấm bảng mã | Thử hàng chục phương án trên 360 file trong vài giây | Thấy cấu trúc, không thấy nghĩa | Độ dài bản ghi, checksum, bảng mã |
| So sánh vi sai trên máy thử | Gán nghĩa chắc chắn | Cần máy và người bấm; mỗi cặp file trả lời một câu | Nghĩa của trường, endianness |
BanHang dùng cả ba, theo một luật: giả thuyết chỉ được tin khi một bất biến đúng trên đủ 360 file. Sáu bước:
- Độ dài bản ghi. Tự tương quan. Kiểm: kích thước file = 32 + 48 × n + 4, với n là
uint16ở offset 16 do bước 2 tìm ra. - Nghĩa của trường. Máy thử ghi
A.DATsau ba giao dịch,B.DATthêm giao dịch thứ tư: 3 ốp lưng, tiền mặt.C,D,ElàBvới đúng một khác biệt. - Endianness và kiểu số. Kiểm: mã hàng có trong danh mục; số lượng × đơn giá bằng thành tiền.
- Thời điểm. Kiểm: giờ chốt trong header trùng giờ in trên phiếu Z.
- Bảng mã. Kiểm: tên giải ra trùng tên trong
dbo.SanPham. - Checksum. 12 CRC-32 trong catalogue và phép cộng byte, trên ba phạm vi (cả file trừ 4 byte cuối, bỏ magic, chỉ phần bản ghi), hai thứ tự byte: 78 tổ hợp. Kiểm: khớp cả 360 file.
Tự tương quan vọt lên ở k = 48, 96, 144: bản ghi dài 48 byte
độ lệch k (byte)
Bảng số liệu
| độ lệch k (byte) | r(k) |
|---|---|
| 12 | 9,5% |
| 24 | 0,3% |
| 36 | 9,4% |
| 48 | 51,9% |
| 60 | 9,4% |
| 72 | 0,3% |
| 84 | 9,4% |
| 96 | 49,8% |
| 108 | 9,4% |
| 120 | 0,3% |
| 132 | 9,4% |
| 144 | 48,9% |
| 156 | 9,5% |
B.DAT dài hơn A.DAT đúng 48 byte, và byte 16 đổi 03 → 04: số bản ghi. So B với C, nơi số lượng đổi từ 3 thành 4:
a 228 byte, b 228 byte
offset 188 (0x0bc), 1 byte: 03 → 04
offset 198 (0x0c6), 2 byte: 3870 → 5160
offset 224 (0x0e0), 4 byte: CAFAFE76 → AF63771F
Bản ghi thứ tư bắt đầu ở 32 + 3 × 48 = 176. Số lượng ở 176 + 12, byte thấp trước; thành tiền BCD đổi 387.000 thành 516.000; bốn byte cuối là checksum. D.DAT (trả bằng BHPay) chỉ đổi byte 177 00 → 02, E.DAT (trả hàng) chỉ đổi byte 176 01 → 02, cộng checksum. Bố cục sau sáu bước:
Header: magic, phiên bản, mã cửa hàng uint16 ở offset 6, số phiếu Z uint32 ở 8, giờ chốt uint32 ở 12, số bản ghi uint16 ở 16, 14 byte còn lại luôn bằng 0. Đuôi là CRC-32/MPEG-2 của mọi byte phía trước, ghi little-endian.
5. Cách cài đặt: bốn file C# chạy bằng dotnet run
Môi trường: .NET SDK 10.0.401 (runtime 10.0.12), Windows 11, Microsoft.Data.SqlClient 7.1.1, System.IO.Hashing 10.0.12, LocalDB 15.0.4382 (SQL Server 2019 CU27). CodePagesEncodingProvider có sẵn trong .NET 10, không cần gói NuGet. Bốn file-based app chạy trong cùng một thư mục:
dotnet run SinhZ.cs # 360 file, phiếu Z, máy thử A–E, danh mục
dotnet run SoiFile.cs -- chuky du-lieu # hoặc: hex <file>, diff <a> <b>, crc du-lieu
dotnet run KiemGiaThuyet.cs # chấm từng giả thuyết, mục 6
dotnet run NhapZ.cs # thêm -- thu-hong để thử file hỏng
SinhZ.cs giả lập máy: mỗi cửa hàng mỗi ngày 60–149 hóa đơn từ 08:00 đến 21:55, 1–4 dòng mỗi hóa đơn, 2% hóa đơn là trả hàng, 30 mã hàng, hạt giống 2026. Nó ghi file .DAT và phiếu Z dạng .txt từ cùng danh sách giao dịch; phiếu Z là sự thật để đối chiếu, và parser không dùng chung code với bộ sinh.
SinhZ.cs: bộ sinh file Z và phiếu Z của máy hư cấu, chạy bằng dotnet run SinhZ.cs
// SinhZ.cs: giả lập máy tính tiền đời cũ HƯ CẤU, định dạng KTZ 2.3 dựng riêng cho bài.
// dotnet run SinhZ.cs [-- thư-mục] → du-lieu\CHxxx\Z26MMdd.DAT, phiếu Z .txt, máy thử, danh mục.
using System.Buffers.Binary;
using System.Globalization;
using System.Text;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp1258 = Encoding.GetEncoding(1258, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
string goc = args.Length > 0 ? args[0] : "du-lieu";
var rng = new Random(2026);
var vn = CultureInfo.GetCultureInfo("vi-VN");
// Danh mục con của dbo.SanPham: máy lưu mã hàng là phần số của MaSanPham (SP-00042 → 42).
(int Ma, string Ten, long Gia)[] danhMuc =
[
(7, "Cáp sạc USB-C 1m", 99_000), (12, "Cáp sạc micro USB", 69_000), (15, "Củ sạc nhanh 20W", 290_000),
(21, "Củ sạc nhanh 65W", 690_000), (28, "Sạc không dây 15W", 390_000), (33, "Pin dự phòng 10000mAh", 450_000),
(42, "Ốp lưng silicon", 129_000), (47, "Kính cường lực", 120_000), (51, "Tai nghe có dây", 190_000),
(58, "Tai nghe Bluetooth", 590_000), (64, "Loa Bluetooth mini", 450_000), (73, "Thẻ nhớ 64GB", 210_000),
(80, "USB 32GB", 150_000), (86, "Giá đỡ điện thoại", 89_000), (95, "Bút cảm ứng", 250_000),
(102, "Chuột không dây", 220_000), (118, "Bàn phím Bluetooth", 490_000), (125, "Đồng hồ thông minh", 1_990_000),
(131, "Vòng đeo sức khỏe", 790_000), (140, "Túi chống sốc 13 inch", 180_000), (152, "Bao da máy tính bảng", 290_000),
(167, "Điện thoại 4G cơ bản", 690_000), (175, "Máy tính bảng 8 inch", 3_490_000), (188, "Router WiFi 6", 890_000),
(203, "Quạt mini cầm tay", 159_000), (216, "Đèn bàn LED", 320_000), (230, "Nồi cơm điện 1,8L", 1_290_000),
(245, "Ấm siêu tốc 1,7L", 450_000), (260, "Máy sấy tóc", 390_000), (288, "Ổ cắm 6 lỗ", 160_000),
];
int[] cuaHang = [7, 14, 23, 31, 38, 46, 52, 67, 75, 88, 96, 104];
Directory.CreateDirectory(goc);
File.WriteAllLines(Path.Combine(goc, "danh-muc.csv"),
danhMuc.Select(p => $"SP-{p.Ma:00000};{p.Ten};{p.Gia}"), new UTF8Encoding(false));
int soFile = 0, soDong = 0;
foreach (int ch in cuaHang)
{
string thuMuc = Path.Combine(goc, $"CH{ch:000}");
Directory.CreateDirectory(thuMuc);
uint soZ = (uint)(300 + rng.Next(500));
int soHoaDon = 1000 + rng.Next(5000);
for (var ngay = new DateTime(2026, 9, 1); ngay.Month == 9; ngay = ngay.AddDays(1), soZ++)
{
var dong = new List<Dong>();
int soHd = rng.Next(60, 150);
var gioHd = Enumerable.Range(0, soHd).Select(_ => ngay.AddHours(8).AddSeconds(rng.Next(14 * 3600 - 300))).Order().ToList();
foreach (var gio in gioHd)
{
soHoaDon++;
bool tra = rng.NextDouble() < 0.02;
byte tt = (byte)(rng.NextDouble() switch { < 0.55 => 0, < 0.80 => 1, _ => 2 }); // tiền mặt, thẻ, BHPay
int soDongHd = tra ? 1 : rng.Next(10) switch { < 5 => 1, < 8 => 2, < 9 => 3, _ => 4 };
for (int k = 0; k < soDongHd; k++)
{
var sp = danhMuc[rng.Next(danhMuc.Length)];
int sl = sp.Gia < 300_000 && rng.Next(4) == 0 ? rng.Next(2, 6) : 1;
dong.Add(new Dong((byte)(tra ? 2 : 1), tt, (ushort)soHoaDon, gio, sp.Ma, (ushort)sl, sp.Gia, sp.Ten));
}
}
var gioChot = ngay.AddHours(22).AddSeconds(rng.Next(900));
string ten = $"Z{ngay:yyMMdd}";
File.WriteAllBytes(Path.Combine(thuMuc, ten + ".DAT"), GhiFile((ushort)ch, soZ, gioChot, dong));
File.WriteAllText(Path.Combine(thuMuc, ten + ".txt"), PhieuZ(ch, soZ, gioChot, dong), new UTF8Encoding(false));
soFile++; soDong += dong.Count;
}
}
Console.WriteLine($"{soFile} file Z, {soDong} dòng, {cuaHang.Length} cửa hàng, tháng 9/2026");
// Máy thử ở văn phòng: xóa bộ nhớ, đặt đồng hồ 2026-10-02 10:00, bấm vài giao dịch rồi in Z.
string thu = Path.Combine(goc, "may-thu");
Directory.CreateDirectory(thu);
var t0 = new DateTime(2026, 10, 2, 10, 0, 0);
Dong[] goc3 =
[
new(1, 0, 1, t0.AddSeconds(131), 7, 1, 99_000, "Cáp sạc USB-C 1m"),
new(1, 1, 2, t0.AddSeconds(340), 125, 1, 1_990_000, "Đồng hồ thông minh"),
new(1, 2, 3, t0.AddSeconds(545), 58, 1, 590_000, "Tai nghe Bluetooth"),
];
var them = new Dong(1, 0, 4, t0.AddMinutes(15), 42, 3, 129_000, "Ốp lưng silicon");
var chot = t0.AddMinutes(30);
var bienThe = new Dictionary<string, Dong[]>
{
["A"] = goc3, // 3 giao dịch
["B"] = [.. goc3, them], // thêm 3 ốp lưng, tiền mặt
["C"] = [.. goc3, them with { SoLuong = 4 }], // như B, 4 cái
["D"] = [.. goc3, them with { ThanhToan = 2 }], // như B, trả bằng BHPay
["E"] = [.. goc3, them with { Loai = 2 }], // như B, nhưng là trả hàng
};
foreach (var (k, ds) in bienThe)
{
File.WriteAllBytes(Path.Combine(thu, k + ".DAT"), GhiFile(0, 1, chot, ds.ToList()));
File.WriteAllText(Path.Combine(thu, k + ".txt"), PhieuZ(0, 1, chot, ds.ToList()), new UTF8Encoding(false));
}
Console.WriteLine($"máy thử: {string.Join(", ", bienThe.Keys.Select(k => k + ".DAT"))}");
// ---------- Định dạng KTZ 2.3 (hư cấu) ----------
// Header 32 byte, n bản ghi 48 byte, CRC-32/MPEG-2 của mọi byte phía trước, ghi little-endian.
byte[] GhiFile(ushort ch, uint soZ, DateTime gioChot, List<Dong> ds)
{
var f = new byte[32 + 48 * ds.Count + 4];
var s = f.AsSpan();
"KTZ\x1A"u8.CopyTo(s);
s[4] = 2; s[5] = 3;
BinaryPrimitives.WriteUInt16LittleEndian(s[6..], ch);
BinaryPrimitives.WriteUInt32LittleEndian(s[8..], soZ);
BinaryPrimitives.WriteUInt32LittleEndian(s[12..], GiayMay(gioChot));
BinaryPrimitives.WriteUInt16LittleEndian(s[16..], (ushort)ds.Count);
for (int i = 0; i < ds.Count; i++)
{
var r = s.Slice(32 + 48 * i, 48);
var d = ds[i];
r[0] = d.Loai; r[1] = d.ThanhToan;
BinaryPrimitives.WriteUInt16LittleEndian(r[2..], d.SoHoaDon);
BinaryPrimitives.WriteUInt32LittleEndian(r[4..], GiayMay(d.ThoiDiem));
BinaryPrimitives.WriteUInt32LittleEndian(r[8..], (uint)d.MaHang);
BinaryPrimitives.WriteUInt16LittleEndian(r[12..], d.SoLuong);
GhiBcd(r.Slice(14, 5), d.DonGia);
GhiBcd(r.Slice(19, 6), d.DonGia * d.SoLuong);
var ten = Ma1258(d.Ten);
if (ten.Length > 23) throw new InvalidOperationException($"Tên quá 23 byte: {d.Ten} ({ten.Length})");
r.Slice(25, 23).Fill(0x20);
ten.CopyTo(r[25..]);
}
BinaryPrimitives.WriteUInt32LittleEndian(s[^4..], Crc32Mpeg2(s[..^4]));
return f;
}
// Đồng hồ máy chạy theo giờ Việt Nam và không biết múi giờ: giây kể từ 1970-01-01 00:00 theo giờ treo tường.
static uint GiayMay(DateTime gioTreoTuong) => (uint)(gioTreoTuong - DateTime.UnixEpoch).TotalSeconds;
static void GhiBcd(Span<byte> dich, long giaTri)
{
for (int i = dich.Length - 1; i >= 0; i--, giaTri /= 100)
dich[i] = (byte)((giaTri % 100 / 10) << 4 | giaTri % 10);
if (giaTri != 0) throw new OverflowException();
}
// Windows-1258 như máy ghi: chữ có sẵn trong bảng thì dùng một byte, không có thì tách dấu thanh
// thành ký tự kết hợp (Ố → Ô + U+0301). Encoding.GetBytes trên chuỗi NFC ra '?' cho các chữ đó.
byte[] Ma1258(string s)
{
var kq = new List<byte>();
foreach (char c in s.Normalize(NormalizationForm.FormC))
{
try { kq.AddRange(cp1258.GetBytes(c.ToString())); continue; } catch (EncoderFallbackException) { }
string nfd = c.ToString().Normalize(NormalizationForm.FormD);
string thanh = string.Concat(nfd.Where(m => m is '\u0300' or '\u0301' or '\u0303' or '\u0309' or '\u0323'));
string than = string.Concat(nfd.Where(m => !thanh.Contains(m))).Normalize(NormalizationForm.FormC);
kq.AddRange(cp1258.GetBytes(than + thanh));
}
return [.. kq];
}
static uint Crc32Mpeg2(ReadOnlySpan<byte> data)
{
uint crc = 0xFFFF_FFFF;
foreach (byte b in data)
{
crc ^= (uint)b << 24;
for (int k = 0; k < 8; k++) crc = (crc & 0x8000_0000) != 0 ? (crc << 1) ^ 0x04C1_1DB7 : crc << 1;
}
return crc;
}
string PhieuZ(int ch, uint soZ, DateTime gioChot, List<Dong> ds)
{
long Tong(Func<Dong, bool> loc) => ds.Where(loc).Sum(d => d.DonGia * d.SoLuong);
long Thuan(int tt) => Tong(d => d.Loai == 1 && d.ThanhToan == tt) - Tong(d => d.Loai == 2 && d.ThanhToan == tt);
string N(long v) => v.ToString("#,0", vn).PadLeft(14);
var sb = new StringBuilder();
sb.AppendLine($"PHIẾU Z SỐ {soZ:0000}");
sb.AppendLine($"CỬA HÀNG {ch:000} NGÀY {gioChot:dd/MM/yyyy} CHỐT {gioChot:HH:mm:ss}");
sb.AppendLine($"SỐ DÒNG BÁN {ds.Count(d => d.Loai == 1),14}");
sb.AppendLine($"SỐ DÒNG TRẢ {ds.Count(d => d.Loai == 2),14}");
sb.AppendLine($"TIỀN BÁN {N(Tong(d => d.Loai == 1))}");
sb.AppendLine($"TIỀN TRẢ {N(Tong(d => d.Loai == 2))}");
sb.AppendLine($"DOANH THU {N(Tong(d => d.Loai == 1) - Tong(d => d.Loai == 2))}");
sb.AppendLine($" TIỀN MẶT {N(Thuan(0))}");
sb.AppendLine($" THẺ {N(Thuan(1))}");
sb.AppendLine($" BHPAY {N(Thuan(2))}");
return sb.ToString();
}
record Dong(byte Loai, byte ThanhToan, ushort SoHoaDon, DateTime ThoiDiem, int MaHang, ushort SoLuong, long DonGia, string Ten);
SoiFile.cs in hex dump, tính r(k), so hai file và dò checksum. Code CRC tổng quát được kiểm bằng chuỗi "123456789" trước khi dò: cả 12 thuật toán ra đúng giá trị check của catalogue.
SoiFile.cs: hex dump, tự tương quan, so vi sai, dò 78 tổ hợp checksum
// SoiFile.cs: soi file nhị phân không có tài liệu.
// dotnet run SoiFile.cs -- hex <file> [từ] [số-byte] in hex dump
// dotnet run SoiFile.cs -- chuky <thư-mục> tự tương quan theo độ lệch, tìm độ dài bản ghi
// dotnet run SoiFile.cs -- diff <a> <b> các đoạn byte khác nhau giữa hai file
// dotnet run SoiFile.cs -- crc <thư-mục> thử thuật toán CRC × phạm vi × thứ tự byte
using System.Buffers.Binary;
using System.Text;
string lenh = args[0];
if (lenh == "hex") Hex(File.ReadAllBytes(args[1]), args.Length > 2 ? int.Parse(args[2]) : 0, args.Length > 3 ? int.Parse(args[3]) : 256);
else if (lenh == "chuky") ChuKy(TatCaFile(args[1]));
else if (lenh == "diff") Diff(File.ReadAllBytes(args[1]), File.ReadAllBytes(args[2]));
else if (lenh == "crc") TimCrc(TatCaFile(args[1]));
static byte[][] TatCaFile(string thuMuc) =>
Directory.GetFiles(thuMuc, "Z*.DAT", SearchOption.AllDirectories).Order().Select(File.ReadAllBytes).ToArray();
static void Hex(byte[] f, int tu, int soByte)
{
for (int o = tu; o < Math.Min(f.Length, tu + soByte); o += 16)
{
var dong = f.AsSpan(o, Math.Min(16, f.Length - o));
string hex = string.Join(" ", dong.ToArray().Select(b => b.ToString("x2")));
string ascii = new(dong.ToArray().Select(b => b is >= 0x20 and < 0x7F ? (char)b : '.').ToArray());
Console.WriteLine($"{o:x8} {hex,-47} {ascii}");
}
}
// r(k) = tỉ lệ vị trí i có f[i] == f[i + k]. Bản ghi cỡ cố định L cho đỉnh ở k = L, 2L, 3L...
static void ChuKy(byte[][] files)
{
var r = new double[161];
for (int k = 1; k <= 160; k++)
r[k] = files.Average(f => Enumerable.Range(0, f.Length - k).Count(i => f[i] == f[i + k]) / (double)(f.Length - k));
Console.WriteLine($"{files.Length} file, trung bình r(k) với k = 1..160: {r.Skip(1).Average():P1}");
foreach (int k in Enumerable.Range(1, 160).OrderByDescending(k => r[k]).Take(6))
Console.WriteLine($" k = {k,3}: r = {r[k]:P1}");
Console.WriteLine("csv: " + string.Join(" ", Enumerable.Range(1, 160).Select(k => $"{k}:{r[k] * 100:0.0}")));
}
static void Diff(byte[] a, byte[] b)
{
Console.WriteLine($"a {a.Length} byte, b {b.Length} byte");
int n = Math.Min(a.Length, b.Length);
for (int i = 0; i < n; i++)
{
if (a[i] == b[i]) continue;
int j = i;
while (j < n && a[j] != b[j]) j++;
Console.WriteLine($" offset {i,4} (0x{i:x3}), {j - i} byte: {Convert.ToHexString(a, i, j - i)} → {Convert.ToHexString(b, i, j - i)}");
i = j;
}
if (a.Length != b.Length) Console.WriteLine($" b dài hơn {b.Length - a.Length} byte, từ offset {n}");
}
// Mười hai CRC-32 trong catalogue của Greg Cook (reveng), cộng một phép cộng byte để so sánh.
static void TimCrc(byte[][] files)
{
(string Ten, uint Poly, uint Init, bool RefIn, bool RefOut, uint XorOut, uint Check)[] ds =
[
("CRC-32/ISO-HDLC", 0x04C11DB7, 0xFFFFFFFF, true, true, 0xFFFFFFFF, 0xCBF43926),
("CRC-32/BZIP2", 0x04C11DB7, 0xFFFFFFFF, false, false, 0xFFFFFFFF, 0xFC891918),
("CRC-32/MPEG-2", 0x04C11DB7, 0xFFFFFFFF, false, false, 0x00000000, 0x0376E6E7),
("CRC-32/CKSUM", 0x04C11DB7, 0x00000000, false, false, 0xFFFFFFFF, 0x765E7680),
("CRC-32/JAMCRC", 0x04C11DB7, 0xFFFFFFFF, true, true, 0x00000000, 0x340BC6D9),
("CRC-32/ISCSI", 0x1EDC6F41, 0xFFFFFFFF, true, true, 0xFFFFFFFF, 0xE3069283),
("CRC-32/AUTOSAR", 0xF4ACFB13, 0xFFFFFFFF, true, true, 0xFFFFFFFF, 0x1697D06A),
("CRC-32/BASE91-D", 0xA833982B, 0xFFFFFFFF, true, true, 0xFFFFFFFF, 0x87315576),
("CRC-32/AIXM", 0x814141AB, 0x00000000, false, false, 0x00000000, 0x3010BF7F),
("CRC-32/CD-ROM-EDC", 0x8001801B, 0x00000000, true, true, 0x00000000, 0x6EC2EDC4),
("CRC-32/MEF", 0x741B8CD7, 0xFFFFFFFF, true, true, 0x00000000, 0xD2C22F51),
("CRC-32/XFER", 0x000000AF, 0x00000000, false, false, 0x00000000, 0xBD0BE338),
];
int dungCheck = ds.Count(a => Crc32(a.Poly, a.Init, a.RefIn, a.RefOut, a.XorOut, "123456789"u8) == a.Check);
Console.WriteLine($"kiểm cài đặt: {dungCheck}/{ds.Length} thuật toán ra đúng giá trị check của catalogue");
var thuatToan = ds.Select(a => (a.Ten, Tinh: (Func<ReadOnlySpan<byte>, uint>)(s => Crc32(a.Poly, a.Init, a.RefIn, a.RefOut, a.XorOut, s))))
.Append(("tổng byte 32-bit", s => { uint t = 0; foreach (byte b in s) t += b; return t; })).ToArray();
(string Ten, int Tu)[] phamVi = [("[0, n-4)", 0), ("[4, n-4)", 4), ("[32, n-4)", 32)];
int toHop = 0, khop = 0;
foreach (var (ten, tinh) in thuatToan)
foreach (var (tenPv, tu) in phamVi)
foreach (bool le in new[] { true, false })
{
toHop++;
int soFile = files.Count(f =>
{
uint ghi = le ? BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(^4)) : BinaryPrimitives.ReadUInt32BigEndian(f.AsSpan(^4));
return tinh(f.AsSpan(tu, f.Length - 4 - tu)) == ghi;
});
if (soFile > 0) { khop++; Console.WriteLine($" KHỚP {ten} {tenPv} {(le ? "LE" : "BE")}: {soFile}/{files.Length} file"); }
}
Console.WriteLine($"{toHop} tổ hợp đã thử, {khop} tổ hợp khớp ít nhất một file");
}
static uint Crc32(uint poly, uint init, bool refIn, bool refOut, uint xorOut, ReadOnlySpan<byte> data)
{
uint crc = init;
foreach (byte b in data)
{
crc ^= (uint)(refIn ? DaoBit(b, 8) : b) << 24;
for (int k = 0; k < 8; k++) crc = (crc & 0x8000_0000) != 0 ? (crc << 1) ^ poly : crc << 1;
}
return (refOut ? DaoBit(crc, 32) : crc) ^ xorOut;
}
static uint DaoBit(uint v, int soBit)
{
uint kq = 0;
for (int i = 0; i < soBit; i++, v >>= 1) kq = (kq << 1) | (v & 1);
return kq;
}
Parser là lớp KTZ trong NhapZ.cs. Nó kiểm magic, phiên bản, độ dài và CRC trước khi đọc bản ghi nào, rồi đọc từng trường bằng BinaryPrimitives trên ReadOnlySpan<byte>, không chép mảng:
public static FileZ Doc(ReadOnlySpan<byte> f, Encoding cp1258, bool kiemCrc = true)
{
if (f.Length < Header + 4 || !f.StartsWith("KTZ\x1A"u8) || f[4] != 2 || f[5] != 3)
throw new InvalidDataException("không phải file KTZ 2.3");
int soBanGhi = BinaryPrimitives.ReadUInt16LittleEndian(f[16..]);
if (f.Length != Header + CoBanGhi * soBanGhi + 4)
throw new InvalidDataException($"dài {f.Length} byte, header báo {soBanGhi} bản ghi");
if (kiemCrc && Crc32Mpeg2(f[..^4]) != BinaryPrimitives.ReadUInt32LittleEndian(f[^4..]))
throw new InvalidDataException("sai CRC");
var ds = new List<DongMay>(soBanGhi);
for (int i = 0; i < soBanGhi; i++)
ds.Add(DocBanGhi(f.Slice(Header + CoBanGhi * i, CoBanGhi), cp1258));
return new FileZ(BinaryPrimitives.ReadUInt16LittleEndian(f[6..]), BinaryPrimitives.ReadUInt32LittleEndian(f[8..]),
GioMay(BinaryPrimitives.ReadUInt32LittleEndian(f[12..])), ds);
}
static DongMay DocBanGhi(ReadOnlySpan<byte> r, Encoding cp1258)
{
byte loai = r[0], thanhToan = r[1];
if (loai is not (1 or 2) || thanhToan > 2) throw new InvalidDataException("loại hoặc hình thức thanh toán lạ");
ushort soLuong = BinaryPrimitives.ReadUInt16LittleEndian(r[12..]);
long donGia = Bcd(r.Slice(14, 5)), thanhTien = Bcd(r.Slice(19, 6));
if (soLuong * donGia != thanhTien) throw new InvalidDataException("số lượng × đơn giá ≠ thành tiền");
string ten = cp1258.GetString(r.Slice(25, 23)).TrimEnd(' ').Normalize(NormalizationForm.FormC);
return new DongMay(loai, thanhToan, BinaryPrimitives.ReadUInt16LittleEndian(r[2..]),
GioMay(BinaryPrimitives.ReadUInt32LittleEndian(r[4..])), BinaryPrimitives.ReadUInt32LittleEndian(r[8..]),
soLuong, donGia, thanhTien, ten);
}
Bảng tạm dbo.NhapZ_Dong trong database Kumeo_re3 có khóa chính (CuaHangId, SoPhieuZ, DongSo), ThoiDiem datetime2(0) theo giờ Việt Nam như đồng hồ máy, tiền decimal(18, 2), tên nvarchar(30). SqlBulkCopy với SqlBulkCopyOptions.TableLock (khóa bulk update cả bảng thay vì khóa từng dòng) nạp 68.036 dòng từ một DataTable. Một câu GROUP BY đọc lại 8 con số của từng phiếu từ database rồi so với phiếu Z. Chạy xong, chương trình xóa Kumeo_re3. Trong BanHang, lớp KTZ chạy trong worker nhận file mỗi tối cho tới khi máy được thay, và dòng chỉ sang bảng bán hàng chính khi phiếu Z của ngày đó khớp.
NhapZ.cs: parser, SqlBulkCopy vào Kumeo_re3, đối chiếu phiếu Z, thử file hỏng
// NhapZ.cs: đọc file Z của máy tính tiền cũ (định dạng KTZ 2.3, hư cấu), kiểm CRC, nạp vào bảng tạm
// bằng SqlBulkCopy trên LocalDB, đối chiếu từng phiếu Z, rồi xóa database thử.
// dotnet run NhapZ.cs nạp và đối chiếu thư mục du-lieu
// dotnet run NhapZ.cs -- thu-hong làm hỏng một byte, đếm số lần bị bắt
#:package Microsoft.Data.SqlClient@7.1.1
using System.Buffers.Binary;
using System.Data;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // không đăng ký thì GetEncoding(1258) ném NotSupportedException
var cp1258 = Encoding.GetEncoding(1258);
string goc = "du-lieu";
var tep = Directory.GetFiles(goc, "Z*.DAT", SearchOption.AllDirectories).Order().ToArray();
Console.WriteLine($".NET {Environment.Version}, {tep.Length} file");
if (args.Length > 0 && args[0] == "thu-hong") { ThuHong(); return; }
// ---------- 1. Đọc 360 file ----------
var sw = Stopwatch.StartNew();
var phieu = tep.Select(t => (Tep: t, Z: KTZ.Doc(File.ReadAllBytes(t), cp1258))).ToList();
Console.WriteLine($"đọc {phieu.Sum(p => p.Z.Dong.Count)} dòng trong {sw.ElapsedMilliseconds} ms");
// Giờ chốt trong header so với giờ in trên phiếu Z giấy.
int gioKhop = phieu.Count(p => PhieuZGiay(p.Tep).GioChot == p.Z.GioChot);
Console.WriteLine($"giờ chốt khớp phiếu Z: {gioKhop}/{phieu.Count}");
// ---------- 2. Nạp vào bảng tạm ----------
const string Master = @"Server=(localdb)\MSSQLLocalDB;Database=master;Integrated Security=true;Encrypt=false";
const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_re3;Integrated Security=true;Encrypt=false";
await using (var cn = new SqlConnection(Master))
{
await cn.OpenAsync();
await Exec(cn, "IF DB_ID('Kumeo_re3') IS NOT NULL BEGIN ALTER DATABASE Kumeo_re3 SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE Kumeo_re3; END");
await Exec(cn, "CREATE DATABASE Kumeo_re3 COLLATE Vietnamese_100_CI_AS");
}
await using (var cn = new SqlConnection(Cs))
{
await cn.OpenAsync();
await Exec(cn, """
CREATE TABLE dbo.SanPham (
SanPhamId int NOT NULL CONSTRAINT PK_SanPham PRIMARY KEY CLUSTERED,
MaSanPham varchar(20) NOT NULL CONSTRAINT UQ_SanPham_Ma UNIQUE,
Ten nvarchar(200) NOT NULL,
DonGia decimal(18, 2) NOT NULL,
TonKho int NOT NULL CONSTRAINT CK_SanPham_TonKho CHECK (TonKho >= 0)
);
CREATE TABLE dbo.NhapZ_Dong (
CuaHangId smallint NOT NULL,
SoPhieuZ int NOT NULL,
DongSo smallint NOT NULL,
Loai tinyint NOT NULL, -- 1 bán, 2 trả hàng
ThanhToan tinyint NOT NULL, -- 0 tiền mặt, 1 thẻ, 2 BHPay
SoHoaDonMay int NOT NULL,
ThoiDiem datetime2(0) NOT NULL, -- giờ Việt Nam, đúng như đồng hồ máy
MaSanPham varchar(20) NOT NULL,
SoLuong int NOT NULL,
DonGia decimal(18, 2) NOT NULL,
ThanhTien decimal(18, 2) NOT NULL,
TenTrenMay nvarchar(30) NOT NULL,
CONSTRAINT PK_NhapZ_Dong PRIMARY KEY CLUSTERED (CuaHangId, SoPhieuZ, DongSo)
);
""");
// Danh mục con của dbo.SanPham mà 12 cửa hàng bán.
foreach (var dongCsv in File.ReadAllLines(Path.Combine(goc, "danh-muc.csv"), Encoding.UTF8))
{
var c = dongCsv.Split(';');
await Exec(cn, "INSERT dbo.SanPham VALUES (@Id, @Ma, @Ten, @Gia, 0)",
("@Id", int.Parse(c[0][3..])), ("@Ma", c[0]), ("@Ten", c[1]), ("@Gia", decimal.Parse(c[2])));
}
var bang = new DataTable();
foreach (var (ten, kieu) in new (string, Type)[] { ("CuaHangId", typeof(short)), ("SoPhieuZ", typeof(int)), ("DongSo", typeof(short)),
("Loai", typeof(byte)), ("ThanhToan", typeof(byte)), ("SoHoaDonMay", typeof(int)), ("ThoiDiem", typeof(DateTime)),
("MaSanPham", typeof(string)), ("SoLuong", typeof(int)), ("DonGia", typeof(decimal)), ("ThanhTien", typeof(decimal)), ("TenTrenMay", typeof(string)) })
bang.Columns.Add(ten, kieu);
foreach (var (_, z) in phieu)
for (int i = 0; i < z.Dong.Count; i++)
{
var d = z.Dong[i];
bang.Rows.Add((short)z.CuaHang, (int)z.SoZ, (short)(i + 1), d.Loai, d.ThanhToan, (int)d.SoHoaDon, d.ThoiDiem,
$"SP-{d.MaHang:00000}", (int)d.SoLuong, (decimal)d.DonGia, (decimal)d.ThanhTien, d.Ten);
}
sw.Restart();
using (var bulk = new SqlBulkCopy(cn, SqlBulkCopyOptions.TableLock, null) { DestinationTableName = "dbo.NhapZ_Dong", BatchSize = 10_000 })
{
foreach (DataColumn c in bang.Columns) bulk.ColumnMappings.Add(c.ColumnName, c.ColumnName);
await bulk.WriteToServerAsync(bang);
}
Console.WriteLine($"SqlBulkCopy {bang.Rows.Count} dòng trong {sw.ElapsedMilliseconds} ms");
// ---------- 3. Đối chiếu từng phiếu Z bằng số đọc lại từ database ----------
var tong = new Dictionary<(int, int), long[]>();
await using (var cmd = new SqlCommand("""
SELECT CuaHangId, SoPhieuZ,
SUM(IIF(Loai = 1, 1, 0)), SUM(IIF(Loai = 2, 1, 0)),
SUM(IIF(Loai = 1, ThanhTien, 0)), SUM(IIF(Loai = 2, ThanhTien, 0)),
SUM(IIF(Loai = 1, ThanhTien, -ThanhTien)),
SUM(IIF(ThanhToan = 0, IIF(Loai = 1, ThanhTien, -ThanhTien), 0)),
SUM(IIF(ThanhToan = 1, IIF(Loai = 1, ThanhTien, -ThanhTien), 0)),
SUM(IIF(ThanhToan = 2, IIF(Loai = 1, ThanhTien, -ThanhTien), 0))
FROM dbo.NhapZ_Dong GROUP BY CuaHangId, SoPhieuZ;
""", cn))
await using (var r = await cmd.ExecuteReaderAsync())
while (await r.ReadAsync())
tong[(r.GetInt16(0), r.GetInt32(1))] = Enumerable.Range(2, 8).Select(i => (long)Convert.ToDecimal(r.GetValue(i))).ToArray();
int khop = 0; long lechTien = 0;
foreach (var (t, z) in phieu)
{
var giay = PhieuZGiay(t);
var db = tong[(z.CuaHang, (int)z.SoZ)];
if (giay.SoZ == z.SoZ && giay.CuaHang == z.CuaHang && giay.So.SequenceEqual(db)) khop++;
lechTien += Math.Abs(giay.So[4] - db[4]);
}
Console.WriteLine($"khớp đủ 8 con số của phiếu Z: {khop}/{phieu.Count}, tổng lệch doanh thu {lechTien} đ");
Console.WriteLine($"doanh thu tháng 9 của 12 cửa hàng: {tong.Values.Sum(v => v[4]).ToString("#,0", CultureInfo.GetCultureInfo("vi-VN"))} đ");
var kt = await Scalar(cn, """
SELECT CONCAT(
(SELECT COUNT(*) FROM dbo.NhapZ_Dong AS d WHERE NOT EXISTS (SELECT 1 FROM dbo.SanPham AS s WHERE s.MaSanPham = d.MaSanPham)), N' mã lạ, ',
(SELECT COUNT(*) FROM dbo.NhapZ_Dong AS d JOIN dbo.SanPham AS s ON s.MaSanPham = d.MaSanPham
WHERE d.TenTrenMay COLLATE Latin1_General_100_BIN2 <> s.Ten), N' tên khác danh mục (so nhị phân), ',
(SELECT COUNT(*) FROM dbo.NhapZ_Dong AS d JOIN dbo.SanPham AS s ON s.MaSanPham = d.MaSanPham
WHERE d.DonGia <> s.DonGia), N' đơn giá khác danh mục')
""");
Console.WriteLine(kt);
}
await using (var cn = new SqlConnection(Master))
{
await cn.OpenAsync();
await Exec(cn, "ALTER DATABASE Kumeo_re3 SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE Kumeo_re3;");
Console.WriteLine($"đã xóa Kumeo_re3: {await Scalar(cn, "SELECT IIF(DB_ID('Kumeo_re3') IS NULL, 'ok', 'còn')")}");
}
// ---------- Thử làm hỏng một byte ----------
void ThuHong()
{
// a. Vét cạn trên một file thật: mọi vị trí, mọi giá trị khác giá trị cũ.
var f = File.ReadAllBytes(Path.Combine(goc, "CH023", "Z260930.DAT"));
long thu = 0, bat = 0;
var sw2 = Stopwatch.StartNew();
Parallel.For(0, f.Length, () => (byte[])f.Clone(), (i, _, g) => // mỗi luồng một bản sao file
{
byte cu = g[i];
for (int v = 0; v < 256; v++)
{
if (v == cu) continue;
g[i] = (byte)v;
Interlocked.Increment(ref thu);
if (KTZ.Crc32Mpeg2(g.AsSpan(0, g.Length - 4)) != BinaryPrimitives.ReadUInt32LittleEndian(g.AsSpan(^4))) Interlocked.Increment(ref bat);
}
g[i] = cu;
return g;
}, _ => { });
Console.WriteLine($"vét cạn Z260930.DAT của cửa hàng 23 ({f.Length} byte): CRC bắt {bat}/{thu} lần hỏng một byte, {sw2.Elapsed.TotalSeconds:0.0} s");
// b. Ngẫu nhiên trên 360 file: một byte hỏng; đọc có CRC và đọc khi tắt CRC.
var tatCa = tep.Select(File.ReadAllBytes).ToArray();
var rng = new Random(7);
var danhMuc = File.ReadAllLines(Path.Combine(goc, "danh-muc.csv"), Encoding.UTF8).Select(l => l.Split(';'))
.ToDictionary(c => uint.Parse(c[0][3..]), c => (Ten: c[1], Gia: long.Parse(c[2])));
int n = 100_000, biBat = 0, lot = 0, voHai = 0, phieuZBat = 0, danhMucBat = 0;
for (int k = 0; k < n; k++)
{
var g = tatCa[rng.Next(tatCa.Length)];
int i = rng.Next(g.Length);
byte cu = g[i];
g[i] = (byte)((cu + rng.Next(1, 256)) & 0xFF);
try { KTZ.Doc(g, cp1258); } catch (InvalidDataException) { biBat++; }
FileZ? hong = null;
try { hong = KTZ.Doc(g, cp1258, kiemCrc: false); } catch (InvalidDataException) { }
g[i] = cu;
if (hong is null) continue;
lot++; // tắt CRC: lọt kiểm tra cấu trúc, BCD, SL × giá
var dung = KTZ.Doc(g, cp1258);
bool cungHeader = (hong.CuaHang, hong.SoZ, hong.GioChot) == (dung.CuaHang, dung.SoZ, dung.GioChot);
if (cungHeader && hong.Dong.SequenceEqual(dung.Dong)) voHai++; // byte dự trữ: không đổi dữ liệu
else if (!cungHeader || !TamSo(hong).SequenceEqual(TamSo(dung))) phieuZBat++;
else if (hong.Dong.Any(d => !danhMuc.TryGetValue(d.MaHang, out var sp) || sp.Ten != d.Ten || sp.Gia != d.DonGia)) danhMucBat++;
}
Console.WriteLine($"ngẫu nhiên {n} lần trên {tatCa.Length} file: có CRC bắt {biBat}. Tắt CRC: {lot} lần lọt kiểm tra cấu trúc; " +
$"{voHai} lần không đổi dữ liệu, {phieuZBat} lần đối chiếu phiếu Z bắt được, {danhMucBat} lần đối chiếu danh mục bắt được, " +
$"{lot - voHai - phieuZBat - danhMucBat} lần sai im lặng");
// Tám con số của phiếu Z tính từ file.
static long[] TamSo(FileZ z)
{
long T(Func<DongMay, bool> loc) => z.Dong.Where(loc).Sum(d => d.ThanhTien);
long Thuan(int tt) => T(d => d.Loai == 1 && d.ThanhToan == tt) - T(d => d.Loai == 2 && d.ThanhToan == tt);
return [z.Dong.Count(d => d.Loai == 1), z.Dong.Count(d => d.Loai == 2), T(d => d.Loai == 1), T(d => d.Loai == 2),
T(d => d.Loai == 1) - T(d => d.Loai == 2), Thuan(0), Thuan(1), Thuan(2)];
}
}
static async Task Exec(SqlConnection cn, string sql, params (string, object)[] p)
{
await using var cmd = new SqlCommand(sql, cn);
foreach (var (ten, v) in p) cmd.Parameters.AddWithValue(ten, v);
await cmd.ExecuteNonQueryAsync();
}
static async Task<object?> Scalar(SqlConnection cn, string sql) { await using var cmd = new SqlCommand(sql, cn); return await cmd.ExecuteScalarAsync(); }
// Phiếu Z giấy (ở đây là bản chép ra .txt cạnh file .DAT): số Z, cửa hàng, giờ chốt, 8 con số.
static (uint SoZ, int CuaHang, DateTime GioChot, long[] So) PhieuZGiay(string tepDat)
{
var dong = File.ReadAllLines(Path.ChangeExtension(tepDat, ".txt"), Encoding.UTF8);
var m = Regex.Match(dong[1], @"CỬA HÀNG (\d+) NGÀY (\S+) CHỐT (\S+)");
long So(string s) => long.Parse(Regex.Replace(s, @"[^\d-]", ""));
return (uint.Parse(dong[0][^4..]), int.Parse(m.Groups[1].Value),
DateTime.ParseExact(m.Groups[2].Value + " " + m.Groups[3].Value, "dd/MM/yyyy HH:mm:ss", null),
dong[2..10].Select(So).ToArray());
}
// ---------- Bộ đọc định dạng KTZ 2.3 ----------
record DongMay(byte Loai, byte ThanhToan, ushort SoHoaDon, DateTime ThoiDiem, uint MaHang, ushort SoLuong, long DonGia, long ThanhTien, string Ten);
record FileZ(ushort CuaHang, uint SoZ, DateTime GioChot, List<DongMay> Dong);
static class KTZ
{
const int Header = 32, CoBanGhi = 48;
public static FileZ Doc(ReadOnlySpan<byte> f, Encoding cp1258, bool kiemCrc = true)
{
if (f.Length < Header + 4 || !f.StartsWith("KTZ\x1A"u8) || f[4] != 2 || f[5] != 3)
throw new InvalidDataException("không phải file KTZ 2.3");
int soBanGhi = BinaryPrimitives.ReadUInt16LittleEndian(f[16..]);
if (f.Length != Header + CoBanGhi * soBanGhi + 4)
throw new InvalidDataException($"dài {f.Length} byte, header báo {soBanGhi} bản ghi");
if (kiemCrc && Crc32Mpeg2(f[..^4]) != BinaryPrimitives.ReadUInt32LittleEndian(f[^4..]))
throw new InvalidDataException("sai CRC");
var ds = new List<DongMay>(soBanGhi);
for (int i = 0; i < soBanGhi; i++)
ds.Add(DocBanGhi(f.Slice(Header + CoBanGhi * i, CoBanGhi), cp1258));
return new FileZ(BinaryPrimitives.ReadUInt16LittleEndian(f[6..]), BinaryPrimitives.ReadUInt32LittleEndian(f[8..]),
GioMay(BinaryPrimitives.ReadUInt32LittleEndian(f[12..])), ds);
}
static DongMay DocBanGhi(ReadOnlySpan<byte> r, Encoding cp1258)
{
byte loai = r[0], thanhToan = r[1];
if (loai is not (1 or 2) || thanhToan > 2) throw new InvalidDataException("loại hoặc hình thức thanh toán lạ");
ushort soLuong = BinaryPrimitives.ReadUInt16LittleEndian(r[12..]);
long donGia = Bcd(r.Slice(14, 5)), thanhTien = Bcd(r.Slice(19, 6));
if (soLuong * donGia != thanhTien) throw new InvalidDataException("số lượng × đơn giá ≠ thành tiền");
string ten = cp1258.GetString(r.Slice(25, 23)).TrimEnd(' ').Normalize(NormalizationForm.FormC);
return new DongMay(loai, thanhToan, BinaryPrimitives.ReadUInt16LittleEndian(r[2..]),
GioMay(BinaryPrimitives.ReadUInt32LittleEndian(r[4..])), BinaryPrimitives.ReadUInt32LittleEndian(r[8..]),
soLuong, donGia, thanhTien, ten);
}
// Đồng hồ máy chạy theo giờ treo tường Việt Nam: giây kể từ 1970-01-01 00:00, không cộng 7 giờ.
static DateTime GioMay(uint giay) => new DateTime(1970, 1, 1).AddSeconds(giay);
static long Bcd(ReadOnlySpan<byte> s)
{
long v = 0;
foreach (byte b in s)
{
int cao = b >> 4, thap = b & 0xF;
if (cao > 9 || thap > 9) throw new InvalidDataException($"BCD sai: {b:X2}");
v = v * 100 + cao * 10 + thap;
}
return v;
}
static readonly uint[] Bang = Enumerable.Range(0, 256).Select(i =>
{
uint c = (uint)i << 24;
for (int k = 0; k < 8; k++) c = (c & 0x8000_0000) != 0 ? (c << 1) ^ 0x04C1_1DB7 : c << 1;
return c;
}).ToArray();
// CRC-32/MPEG-2: poly 0x04C11DB7, init 0xFFFFFFFF, không đảo bit, không XOR cuối.
public static uint Crc32Mpeg2(ReadOnlySpan<byte> data)
{
uint crc = 0xFFFF_FFFF;
foreach (byte b in data) crc = (crc << 8) ^ Bang[(crc >> 24) ^ b];
return crc;
}
}
6. Chứng minh: 360/360 phiếu Z khớp, 0 lần lọt CRC
Đo trên laptop Intel Core Ultra 5 125U, Windows 11, .NET SDK 10.0.401, runtime 10.0.12, LocalDB 15.0.4382.
Mỗi giả thuyết trên 360 file
KiemGiaThuyet.cs chấm từng giả thuyết bằng phép kiểm của bước tương ứng ở mục 4, trên 360 file và 68.036 bản ghi.
KiemGiaThuyet.cs: chấm 16 giả thuyết trên 360 file
// KiemGiaThuyet.cs: kiểm từng giả thuyết về định dạng KTZ (hư cấu) trên cả 360 file, không chỉ một file.
// dotnet run KiemGiaThuyet.cs (sau khi chạy SinhZ.cs)
#:package System.IO.Hashing@10.0.12
using System.Buffers.Binary;
using System.IO.Hashing;
using System.Text;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var tep = Directory.GetFiles("du-lieu", "Z*.DAT", SearchOption.AllDirectories).Order().ToArray();
var files = tep.Select(File.ReadAllBytes).ToArray();
var danhMuc = File.ReadAllLines("du-lieu/danh-muc.csv", Encoding.UTF8).Select(l => l.Split(';'))
.ToDictionary(c => uint.Parse(c[0][3..]), c => c[1]);
// Bản ghi theo giả thuyết đã chốt ở bước 1: header 32 byte, bản ghi 48 byte.
var banGhi = files.SelectMany(f => Enumerable.Range(0, (f.Length - 36) / 48).Select(i => f.AsMemory(32 + 48 * i, 48))).ToArray();
Console.WriteLine($".NET {Environment.Version}: {files.Length} file, {banGhi.Length} bản ghi");
void InFile(string buoc, string gt, Func<byte[], bool> dat) => In(buoc, gt, files.Count(dat), files.Length, "file");
void InBanGhi(string buoc, string gt, Func<ReadOnlySpan<byte>, bool> dat) => In(buoc, gt, banGhi.Count(m => dat(m.Span)), banGhi.Length, "bản ghi");
static void In(string buoc, string gt, int dat, int tong, string donVi) =>
Console.WriteLine($"{buoc,-10} {gt,-34} {dat,6}/{tong} {donVi,-7} {100.0 * dat / tong,6:0.0}%");
// 1. Độ dài bản ghi: kích thước file = 32 + L × (uint16 LE ở offset 16) + 4.
foreach (int L in new[] { 24, 48, 96 })
InFile("độ dài", $"L = {L}", f => f.Length == 32 + L * BinaryPrimitives.ReadUInt16LittleEndian(f.AsSpan(16)) + 4);
// 2. Mã hàng ở offset 8: số đọc ra phải là mã có trong danh mục.
InBanGhi("mã hàng", "uint32 big-endian", r => danhMuc.ContainsKey(BinaryPrimitives.ReadUInt32BigEndian(r[8..])));
InBanGhi("mã hàng", "uint32 little-endian", r => danhMuc.ContainsKey(BinaryPrimitives.ReadUInt32LittleEndian(r[8..])));
// 3. Số lượng ở 12, đơn giá ở 14, thành tiền ở 19: phải có SL × đơn giá = thành tiền.
InBanGhi("tiền", "SL big-endian, giá BCD", r => BinaryPrimitives.ReadUInt16BigEndian(r[12..]) * Bcd(r.Slice(14, 5)) == Bcd(r.Slice(19, 6)));
InBanGhi("tiền", "SL little-endian, giá uint32 LE", r =>
(long)BinaryPrimitives.ReadUInt16LittleEndian(r[12..]) * BinaryPrimitives.ReadUInt32LittleEndian(r[14..]) == BinaryPrimitives.ReadUInt32LittleEndian(r[19..]));
InBanGhi("tiền", "SL little-endian, giá BCD", r => BinaryPrimitives.ReadUInt16LittleEndian(r[12..]) * Bcd(r.Slice(14, 5)) == Bcd(r.Slice(19, 6)));
// 4. Giờ chốt ở offset 12 của header so với giờ in trên phiếu Z.
var gioPhieu = tep.Select(t => File.ReadAllLines(Path.ChangeExtension(t, ".txt"), Encoding.UTF8)[1]).ToArray();
bool KhopGio(byte[] f, Func<uint, DateTime> doi) =>
gioPhieu[Array.IndexOf(files, f)].Contains($"NGÀY {doi(BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(12))):dd'/'MM'/'yyyy' CHỐT 'HH:mm:ss}");
InFile("thời điểm", "Unix UTC, đổi sang giờ VN (+7)", f => KhopGio(f, s => DateTime.UnixEpoch.AddSeconds(s).AddHours(7)));
InFile("thời điểm", "giây theo đồng hồ máy", f => KhopGio(f, s => DateTime.UnixEpoch.AddSeconds(s)));
// 5. Tên hàng ở offset 25, 23 byte: so ordinal với tên trong danh mục.
(string Ten, Func<byte[], string> Giai)[] bangMa =
[
("Windows-1252", b => Encoding.GetEncoding(1252).GetString(b)),
("UTF-8", b => Encoding.UTF8.GetString(b)),
("Windows-1258", b => Encoding.GetEncoding(1258).GetString(b)),
("Windows-1258, rồi chuẩn hóa NFC", b => Encoding.GetEncoding(1258).GetString(b).Normalize(NormalizationForm.FormC)),
];
foreach (var (ten, giai) in bangMa)
InBanGhi("tên hàng", ten, r => danhMuc.TryGetValue(BinaryPrimitives.ReadUInt32LittleEndian(r[8..]), out var dm)
&& giai(r.Slice(25, 23).ToArray()).TrimEnd(' ') == dm);
// 6. Checksum 4 byte cuối, ghi little-endian.
Console.WriteLine($"System.IO.Hashing.Crc32(\"123456789\") = 0x{Crc32.HashToUInt32("123456789"u8):X8} (CRC-32/ISO-HDLC)");
InFile("CRC", "System.IO.Hashing.Crc32, [0, n-4)", f => Crc32.HashToUInt32(f.AsSpan(..^4)) == BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(^4)));
InFile("CRC", "CRC-32/MPEG-2, [0, n-4)", f => Crc32Mpeg2(f.AsSpan(..^4)) == BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(^4)));
static long Bcd(ReadOnlySpan<byte> s)
{
long v = 0;
foreach (byte b in s) v = v * 100 + (b >> 4) * 10 + (b & 0xF);
return v;
}
static uint Crc32Mpeg2(ReadOnlySpan<byte> data)
{
uint crc = 0xFFFF_FFFF;
foreach (byte b in data)
{
crc ^= (uint)b << 24;
for (int k = 0; k < 8; k++) crc = (crc & 0x8000_0000) != 0 ? (crc << 1) ^ 0x04C1_1DB7 : crc << 1;
}
return crc;
}
Giả thuyết sai về số rơi hẳn về 0%. Tên hàng thì không: Windows-1252 giải đúng 20,1% dòng, UTF-8 13,4%, Windows-1258 chưa chuẩn hóa 23,3%, vì tên toàn chữ ASCII như Tai nghe Bluetooth đúng với mọi bảng mã. Nhìn vài dòng đầu dễ kết luận sai; đếm trên 360 file thì không. Dò checksum: 1 trong 78 tổ hợp khớp 360/360 file, CRC-32/MPEG-2 trên mọi byte trừ 4 byte cuối, ghi little-endian. System.IO.Hashing.Crc32 ra 0xCBF43926 với "123456789", tức CRC-32/ISO-HDLC, và khớp 0/360; gói 10.0.12 chỉ có Crc32 và Crc64 không nhận tham số.
Khớp phiếu Z
NhapZ.cs đọc 360 file, nạp bảng tạm, đọc lại từ database và so từng phiếu:
.NET 10.0.12, 360 file
đọc 68036 dòng trong 161 ms
giờ chốt khớp phiếu Z: 360/360
SqlBulkCopy 68036 dòng trong 1535 ms
khớp đủ 8 con số của phiếu Z: 360/360, tổng lệch doanh thu 0 đ
doanh thu tháng 9 của 12 cửa hàng: 38.839.273.000 đ
0 mã lạ, 0 tên khác danh mục (so nhị phân), 0 đơn giá khác danh mục
đã xóa Kumeo_re3: ok
Cả 8 con số, số phiếu Z, mã cửa hàng và giờ chốt khớp 360/360, ở cả sáu lần chạy. Thời gian dao động vì máy chạy song song việc khác (đọc 133–312 ms kể cả JIT, SqlBulkCopy 0,9–2,4 giây), nên chỉ cho bậc độ lớn.
File hỏng một byte
| Phép thử | Số lần | CRC bắt |
|---|---|---|
Vét cạn Z260930.DAT của cửa hàng 23: 9.588 vị trí × 255 giá trị khác |
2.444.940 | 2.444.940 |
| Ngẫu nhiên trên 360 file, mỗi lần một byte, hạt giống 7 | 100.000 | 100.000 |
Lần vét cạn mất khoảng 50 giây trên 14 luồng. Cùng 100.000 lần hỏng, đọc lại với CRC tắt:
| Khi tắt CRC | Số lần |
|---|---|
| Kiểm tra cấu trúc trong parser bắt (độ dài, BCD, số lượng × đơn giá…) | 31.450 |
| Lọt, không đổi dữ liệu: byte dự trữ của header hoặc byte CRC | 227 |
| Lọt, đối chiếu phiếu Z bắt | 156 |
| Lọt, đối chiếu mã, tên, giá với danh mục bắt | 55.777 |
| Lọt mọi lớp, sai im lặng | 12.390 |
Phiếu Z chỉ bắt 156 lần, vì byte tiền hỏng đã bị BCD và phép kiểm số lượng × đơn giá chặn trước. 12.390 lần sai im lặng đều rơi vào offset 2–7 của bản ghi, nơi giữ số hóa đơn và thời điểm; ngoài CRC, không lớp nào thấy chúng.
Điều chưa chứng minh
- 360 file do bộ sinh của bài tạo. Máy thật có thể ghi thứ bộ sinh không có: dòng hủy, số hóa đơn
uint16quay vòng sau 65.535, file ghi dở khi mất điện. Parser từ chối file sai độ dài hay CRC thay vì đoán, nên các ca đó dừng lại chờ người xem. - Mã hàng dài 2 hay 4 byte: hai byte cao luôn là
00trên 68.036 bản ghi. Parser đọc 4 byte vì offset 12 đã là số lượng. - Nghĩa của 14 byte luôn bằng 0 trong header.
7. Kết luận
Định dạng không tài liệu đọc được bằng chuỗi giả thuyết nhỏ, mỗi giả thuyết có một bất biến kiểm trên toàn bộ dữ liệu. Checksum dò ra là lớp duy nhất bắt byte hỏng làm sai dữ liệu im lặng.
Trong dự án .NET của bạn:
- Đọc trường bằng
BinaryPrimitives.ReadUInt32LittleEndianvà họ hàng trênReadOnlySpan<byte>, ghi rõ endianness từng trường thay vì phụ thuộc CPU. - Kiểm magic, phiên bản, độ dài và CRC trước khi đọc bản ghi đầu tiên; sai thì ném
InvalidDataException. - Gọi
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)một lần lúc khởi động, vàNormalize(NormalizationForm.FormC)mọi chuỗi giải từ bảng mã cũ. - Nạp bảng tạm bằng
SqlBulkCopy, đối chiếu với một nguồn sự thật độc lập như phiếu Z, chỉ chuyển sang bảng chính khi khớp.
Những chỗ hay hiểu sai
- "Tên giải ra đúng vài dòng là đúng bảng mã." Windows-1252 cũng đúng 20,1% tên ở đây.
- "CRC-32 là một thuật toán." Mục 4 đã thử 12 bộ tham số CRC-32;
System.IO.Hashing.Crc32chỉ là CRC-32/ISO-HDLC. - "
Vietnamese_100_CI_AScoiÔ+ U+0301 bằngỐ, nên khỏi chuẩn hóa." Collation đó coi là bằng, nhưngLENcủaỐpra 3 thay vì 2, cònLatin1_General_100_BIN2vàDictionary<string, …>của C# coi là khác. - "Có phiếu Z thì không cần CRC." Phiếu Z chỉ bắt 156 trong 68.550 lần hỏng lọt kiểm tra cấu trúc.
Đọc tiếp
- Chữ số kiểm tra: ý tưởng của CRC ở quy mô một chữ số.
- Tìm "nguyen" ra "Nguyễn" mà vẫn Index Seek: collation tiếng Việt quyết định chữ nào bằng nhau.
- Kiểu dữ liệu và khóa chính: vì sao cột tên dùng
nvarchar.
Nguồn
- Greg Cook, Catalogue of parametrised CRC algorithms và chú giải các tham số, đọc ngày 2026-10-04.
- Wikipedia, Cyclic redundancy check, phần khả năng phát hiện lỗi chùm, đọc ngày 2026-10-04.
- Unicode Consortium, CP1258.TXT, bảng ánh xạ Windows-1258 sang Unicode, phiên bản bảng 2.01, đọc ngày 2026-10-04.
- Microsoft Learn, CodePagesEncodingProvider Class và String.Normalize, đọc ngày 2026-10-04.
- Microsoft Learn, BinaryPrimitives Class và SqlBulkCopyOptions Enum, đọc ngày 2026-10-04.
- Tài liệu XML đi kèm gói NuGet
System.IO.Hashing10.0.12: danh sách kiểu và thành viên củaCrc32,Crc64, đọc ngày 2026-10-04.