Files
solution-erp/.claude/workflows/runs/2026-07-12-S112-supplier-excel-import/sub-invest-codebase-B-approach.md
2026-07-12 13:38:40 +07:00

8.9 KiB
Raw Blame History

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:18 new XLWorkbook(path) + :22 ws.CellsUsed() + :24 cell.DataType + :25 cell.GetString(). ClosedXML 0.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:44 HasIndex(x=>x.Code).IsUnique().HasFilter("[IsDeleted]=0") (Mig 47). TaxCode KHÔNG có unique index (:17 chỉ HasMaxLength(20)).
  • Seed per-code idempotent DbInitializer.cs:2786-2819: dedup bằng existingByCode (StringComparer.Ordinal, :2787), FILL-NULLS-if-exists (:2796-2813 chỉ set cột null, KHÔNG đè non-null).
  • Ground-truth 4 NCC: TRUONGGIANG TaxCode="0312 251 859" (CÓ SPACE, :2775) · DONGDUONG TaxCode=null (:2781) → chứng cả 2 vấn đề dedup+format.
  • Validation hiện tại CreateSupplierCommand.cs:45-71: Code NotEmpty.MaxLength(50) · Name NotEmpty.MaxLength(200) · Type IsInEnum() · TaxCode MaxLength(20) KHÔNG regex format · 18 field mới MaxLength-only. Enum SupplierType{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.

  1. IFormFile.OpenReadStream()new XLWorkbook(stream)wb.Worksheet(1) (single sheet).
  2. BỎ HẲN r1(title)/r2(số-cột+#REF!)/r3(nhóm). Chỉ r4 = tên cột thật, data từ r5.
  3. 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".
  4. #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+.
  5. NAS backslash path (LinkGuq/Gpkd/Hsnl): GetString() trả literal single-\. Chỉ .Trim() ngoài — KHÔNG escape, KHÔNG normalize slash, KHÔNG Path.GetFullPath. Seed lưu đúng verbatim (:2776 @"01. GPKD_HSNL_GUQ\02.Trường giang\...").
  6. 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 → MailRecipient raw.
  7. Đọ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).
  8. 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-112 blind absolute-set CẢ 27 field (incl entity.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-field if(!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)
Email 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

  1. 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.
  2. Hard-error → all-or-nothing hay commit-valid-skip-bad? Đề xuất all-or-nothing (chỉ ~4-20 dòng).
  3. 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).
  4. 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).
  5. Value quá dài (edge #12): truncate+warn hay reject-row?
  6. Trùng Code trong file (edge #9): last-wins hay reject-toàn-file?