8.9 KiB
sub-invest-codebase-B-approach
HEADLINE: LANE C: Dedup = Code (DB-unique filtered, always-present, in-mã-HĐ) KHÔNG TaxCode (nullable+bẩn); parser = anchor r4 positional + fingerprint-guard, đọc cell.GetString(); import PHẢI fill-where-non-empty (KHÔNG route UpdateSupplierCommand = clobber #73); 12 edge-case có cách xử.
LANE C — DATA-INTEGRITY / EDGE-CASE (ground-truth verified)
0. Nền tảng đã verify (file:line)
- Excel read primitive CÓ SẴN:
XlsxRenderer.cs:18new XLWorkbook(path)+:22ws.CellsUsed()+:24cell.DataType+:25cell.GetString(). ClosedXML0.105.0(SolutionErp.Infrastructure.csproj:8).GetString()auto-decode shared-string → KHÔNG cần OpenXml raw. Đây là read-idiom chuẩn để mirror. - Code = DB natural key:
SupplierConfiguration.cs:44HasIndex(x=>x.Code).IsUnique().HasFilter("[IsDeleted]=0")(Mig 47). TaxCode KHÔNG có unique index (:17chỉHasMaxLength(20)). - Seed per-code idempotent
DbInitializer.cs:2786-2819: dedup bằngexistingByCode(StringComparer.Ordinal,:2787), FILL-NULLS-if-exists (:2796-2813chỉ set cột null, KHÔNG đè non-null). - Ground-truth 4 NCC:
TRUONGGIANGTaxCode="0312 251 859"(CÓ SPACE,:2775) ·DONGDUONGTaxCode=null (:2781) → chứng cả 2 vấn đề dedup+format. - Validation hiện tại
CreateSupplierCommand.cs:45-71: CodeNotEmpty.MaxLength(50)· NameNotEmpty.MaxLength(200)· TypeIsInEnum()· TaxCodeMaxLength(20)KHÔNG regex format · 18 field mớiMaxLength-only. EnumSupplierType{NhaCungCap=1,NhaThauPhu=2,ToDoi=3,DonViDichVu=4,ChuDauTu=5,CaHai=6}·SupplierStatus?{DangHoatDong=1,Blacklist=2,NgungHopTac=3}(nullable).
1. PARSER-STRATEGY (messy 4-dòng header)
Nguyên tắc: anchor r4 + đọc positional, KHÔNG name-lookup-tự-do, KHÔNG dùng r2.
IFormFile.OpenReadStream()→new XLWorkbook(stream)→wb.Worksheet(1)(single sheet).- BỎ HẲN r1(title)/r2(số-cột+#REF!)/r3(nhóm). Chỉ r4 = tên cột thật, data từ r5.
- Header-fingerprint guard (r4): normalize mỗi cell r4 =
GetString().Replace("\n"," ").Trim().ToUpperInvariant().Collapse-space. Ghép chuỗi 30 header → so với FINGERPRINT kỳ vọng hard-code. Khớp → map positional theo thứ tự r4 (col1→STT... col30→NGƯỜI CẬP NHẬT). LỆCH → reject cả file ("layout Excel đã đổi") thay vì mismap câm. → xử "cột đổi vị trí giữa các lần export". #REF!/ lỗi ô: coi literal#REF!|#N/A|#VALUE!|#DIV/0!|#NAME?= null (bỏ ô). #REF! nằm ở vùng header-merge r2 (đã bỏ) nhưng defensive-guard mọi ô data r5+.- NAS backslash path (
LinkGuq/Gpkd/Hsnl):GetString()trả literal single-\. Chỉ.Trim()ngoài — KHÔNG escape, KHÔNG normalize slash, KHÔNGPath.GetFullPath. Seed lưu đúng verbatim (:2776 @"01. GPKD_HSNL_GUQ\02.Trường giang\..."). - Composite cell (SỐ TÀI KHOẢN = "số TK + tên + CN.NH" 1 ô): đổ raw nguyên văn vào
BankAccount(MaxLength 500 đủ). KHÔNG tách sub-field. Tương tự NGƯỜI NHẬN THƯ/SĐT →MailRecipientraw. - Đọc ô:
cell.GetString()cho mọi cột text (auto shared-string). Số/ngày (NGÀY CẬP NHẬT CUỐI) — hiện entity KHÔNG có field ngày → drop hoặc gom Note (mapping-lane quyết). - Cột 21-31 #REF! trong r2-numbering: vì map theo r4-order + fingerprint nên padding cols này KHÔNG ảnh hưởng; nếu r4 có ô rỗng/#REF! giữa 30 tên → fingerprint tự bắt.
2. 🔴 DEDUP KEY = Code (quyết định, 5 chứng cứ)
| Tiêu chí | Code (TÊN VIẾT TẮT) | TaxCode (MST) |
|---|---|---|
| DB constraint | ✅ UNIQUE filtered SupplierConfiguration.cs:44 |
❌ không unique (:17) |
| Luôn có mặt | ✅ .IsRequired() :14 + cả 4 seed |
❌ null (DONGDUONG :2781) |
| Business identity | ✅ dùng trong mã HĐ SOL&{SupplierCode} (RG-001) |
— |
| Format sạch | ✅ token viết-tắt | ❌ "0312 251 859" có space vs "0305497393" |
| Seed đã dedup bằng | ✅ existingByCode Ordinal :2787 |
— |
Chốt: dedup = Code.Trim() (Ordinal, khớp StringComparer.Ordinal seed + filter DB). TaxCode chỉ làm SECONDARY soft-warn: 2 dòng cùng normalized-TaxCode khác Code → cảnh báo "nghi trùng NCC", KHÔNG block.
Upsert idempotent:
- Missing Code → INSERT (Code lấy TỪ Excel, KHÔNG codegen — khác Contract/PE sequence).
- Exist Code → UPDATE fill-where-non-empty (ô Excel rỗng → GIỮ giá trị DB cũ).
- ⚠️ TUYỆT ĐỐI KHÔNG route qua
UpdateSupplierCommand: handler:86-112blind absolute-set CẢ 27 field (inclentity.Note=request.Note :94,entity.TaxCode :89) → ô Excel thiếu = NULL-hóa data tốt (latent #73 clobber). Import handler tự viết entity trực tiếp, per-fieldif(!string.IsNullOrWhiteSpace(cell)) entity.X = cell. - Reuse guard conflict-check pattern
UpdateSupplierCommand.cs:82-84(Code trùng khác Id → Conflict) cho case trùng trong-batch.
3. VALIDATION (mirror CreateSupplierCommandValidator)
| Field | Rule | Fail → |
|---|---|---|
| Code | NotEmpty + Trim + MaxLength(50) | REJECT row (required + PK) |
| Name | NotEmpty + MaxLength(200) | REJECT row |
| Type | map "NTP"→2 / "NCC"→1 / "Cả hai"/"NTP/NCC"→CaHai=6; IsInEnum | REJECT row (required) |
| TaxCode | normalize strip space/dot → soft ^\d{10}(\d{3})?$ |
WARN-not-block (giữ "0312 251 859", null OK) |
| Status | strip emoji+trim → map text; unmapped | → NULL (nullable, đừng reject) |
EmailAddress().When(non-empty) (:50) |
WARN, giữ raw | |
| Mọi field text | MaxLength (Bank/AuthNote 500/1000...) | truncate+WARN (import-tolerance) |
Lưu ý MST: validator hiện KHÔNG có regex → nếu thêm format-check phải SOFT (warn), vì strict ^\d{10,13}$ sẽ reject chính data thật "0312 251 859" + block DONGDUONG(null). Normalize (strip space/dot) TRƯỚC, rồi mới check, để "0312 251 859"→"0312251859" pass 10-digit.
4. BẢNG EDGE-CASE (12 case × cách xử)
| # | Edge case | Cách xử |
|---|---|---|
| 1 | MST null (DONGDUONG :2781) |
Cho phép, TaxCode nullable; dedup=Code nên không cần MST |
| 2 | MST có space "0312 251 859" | Normalize strip space/dot trước store + soft-check; giữ nếu fail |
| 3 | Dòng trống giữa/cuối data | Skip nếu Code&Name đều rỗng (sentinel); KHÔNG tạo NCC rỗng |
| 4 | Emoji Status "✅ Đang hoạt động" | Strip ký-tự-đầu-không-phải-chữ + trim → map text; unmapped→null |
| 5 | Composite bank 1 ô (TK+tên+CN.NH) | Raw verbatim → BankAccount (MaxLen 500); KHÔNG tách |
| 6 | NAS backslash \...\..pdf |
Verbatim single-\, chỉ Trim ngoài; KHÔNG escape/normalize |
| 7 | Cột đổi vị trí giữa export | Header-fingerprint r4 lệch → REJECT cả file, không mismap câm |
| 8 | #REF! lan sang ô data | Coi error-literal (#REF!/#N/A/#VALUE!/#DIV/0!/#NAME?) = null/skip ô |
| 9 | Trùng Code trong CÙNG file | Report cả 2 dòng; last-wins + WARN (KHÔNG câm) |
| 10 | Code đã có trong DB (re-import) | Upsert fill-where-non-empty; KHÔNG qua UpdateCommand (clobber #73) |
| 11 | Multi-line \n trong ô (Name/Address) |
Collapse \n→space cho field 1-dòng; Address giữ (owner quyết) |
| 12 | Value > MaxLength (Bank/AuthNote) | Truncate + WARN (import-tolerance) hoặc reject-row (owner quyết) |
Atomicity (data-integrity): ~4-20 dòng thật → khuyến nghị 2-pass all-or-nothing: pass-1 parse+validate TẤT, gom List<RowError>; có hard-error (Code/Name/Type) → trả report KHÔNG commit; sạch → 1 SaveChanges atomic. Trả về {inserted, updated, skipped, warnings[], errors[]} cho FE hiển thị.
Token cost est: ~19K (6 tool calls). Verified file:line, no fabrication. Lane scope = parser+dedup+validation+edge; mapping-30-cột + approach-tradeoff = lane khác.
Open Questions
- Upsert khi re-import Code đã tồn tại: overwrite-with-non-empty (last-write-wins, đúng kỳ vọng "cập nhật NCC") HAY fill-nulls-only như seed (
:2796, không bao giờ đè)? Đề xuất overwrite-non-empty cho import (khác seed) — cần anh chốt. - Hard-error → all-or-nothing hay commit-valid-skip-bad? Đề xuất all-or-nothing (chỉ ~4-20 dòng).
- MST format: thêm soft-regex
^\d{10}(\d{3})?$(warn) hay bỏ hẳn format-check giữ MaxLength(20) như hiện tại? (strict sẽ reject data thật có space + null DONGDUONG). - NGÀY CẬP NHẬT CUỐI / NGƯỜI CẬP NHẬT (col 29-30): entity KHÔNG có field ngày-cập-nhật-nghiệp-vụ → drop, gom Note, hay thêm cột mới? (mapping-lane + anh quyết).
- Value quá dài (edge #12): truncate+warn hay reject-row?
- Trùng Code trong file (edge #9): last-wins hay reject-toàn-file?