# 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?
