# SPEC — Supplier import v2 (12-07-2026, S113)

- **run:** `wf_e7896ca6-901` (fable-clone ensemble 5 lane investigator-codebase; 4 structured + 1 garble#53 [visibility] recover em-main-solo)
- **engine:** `fable-clone-ensemble · Opus-S113` · **owner:** anh Kiệt · **lead:** Opus 4.8 (1M) Max
- **source file:** `C:\Users\pqhuy\Downloads\Data base - 1_7_2027.xlsx` (Sheet1, header ROW 4, 30 cột, 4 data row)
- **downstream:** HMW Opus 4.8 MAX implement THEO spec này (sau khi anh Kiệt chốt §Open-Decisions).

---

## ① TÍNH NĂNG / ĐỀ BÀI

Nâng cấp Supplier Excel-import (S112 Phase B) thành v2:
- **R1** Import đọc ĐÚNG cấu trúc file thật (header ROW 4, 30 cột).
- **R2** Nút "Tải file mẫu" — download template xlsx trống đúng layout.
- **R3** Sau upload: bước nhập **Mã NCC** thủ công per-dòng (file KHÔNG có cột này).
- **R4** Thiếu Mã NCC → **draft/ẩn**, không public (nháp trước khi public).
- **R5** Dedup theo **MST** (Mã số thuế, cột 12).
- **R6** Không MST **VÀ** không Mã NCC → không được public.
- **R7** List NCC hiện trạng thái **Public / đang-ẩn(draft)**.

**PHÁT HIỆN GỐC (mọi lane đồng thuận):** `Supplier.Code` (comment "Mã NCC") nhưng S112 import NHỒI c4 "TÊN VIẾT TẮT" vào Code. Field mang nhãn "Mã NCC" đang chứa viết-tắt → đây chính là chỗ R3 muốn tách.

---

## ★ DECISIONS — anh Kiệt ĐÃ CHỐT (S113)

> ✅ **D1 = A** (Mã NCC = `Code`) · ✅ **D2** (Code = cổng public; MST = cảnh-báo-mềm; existing-public giữ nguyên; logic import-scope) · ✅ **D3** (nới quyền: mọi user có quyền cập-nhật danh-mục, KHÔNG admin-only). Chi tiết áp + API-contract ở cuối mục. Phần option gốc bên dưới giữ để tra cứu.

### CHỐT áp dụng:
- **D1=A:** Mã NCC = `Supplier.Code`. KHÔNG field mới, KHÔNG ShortName. Import pre-fill Code từ c4 (editable grid). Draft-row Code="" → filtered-unique đổi `WHERE IsDeleted=0 AND Code<>''`. Code non-empty = publish-able, "" = draft.
- **D2:** `IsPublic` tại confirm = `!IsNullOrWhiteSpace(Code)` [R4]. MST thiếu → KHÔNG chặn, WARNING preview "⚠️ Chưa có MST — không kiểm tra được trùng, có thể bị trùng" [R6→mềm]. 22 prod public giữ (backfill=1). List toggle "Công bố/Ẩn" (PublishSupplierCommand, gate Code-non-empty).
- **D3:** BE import preview/confirm + template + publish: `[Authorize(Roles="Admin,CatalogManager")]` → policy quyền **Suppliers Update/Create** (danh mục). FE guard = PermissionGuard Suppliers Update/Create (khớp BE, hết 403).

### API CONTRACT (chốt cho BE⟂FE parallel):
- `SupplierDto` +`bool IsPublic` (append CUỐI positional record → sửa 2 site projection khít: `ListSuppliersQuery:48-54` + `GetSupplierQuery:21-26`).
- `GET /api/suppliers?published={true|false}` — ListSuppliersQuery +`bool? Published`; null=all (admin quản lý).
- `POST /api/suppliers/{id}/publish` body `{ maNcc?: string, publish: boolean }` → PublishSupplierCommand (nếu maNcc gửi: set Code + unique-CI-check; publish=true gate Code-non-empty; KHÔNG dùng UpdateSupplier).
- `GET /api/suppliers/import/template` → .xlsx (ClosedXML, 30 token row-4, sheet "Sheet1", freeze row-4).
- Import confirm rows: Code editable per-row; confirm set `IsPublic=has-Code`; preview row DTO +`bool MstMissing` (FE render warning).

---

## (option gốc — tra cứu) OPEN-DECISIONS ban đầu

**D1 (GỐC — chi phối toàn bộ) — "Mã NCC" là field nào?**
- **Opt A** — Mã NCC = `Supplier.Code` hiện có; c4 "TÊN VIẾT TẮT" chuyển sang field MỚI `ShortName`. Import thôi bắt buộc Code; draft-row Code="". Đổi filtered-unique `WHERE IsDeleted=0 AND Code<>''`. Contract-number `SOL&{Code}` giữ đúng nghĩa. *(schema-lens đề xuất; churn cao hơn + cần guard "contract phải có Code")*
- **Opt B** — Mã NCC = field MỚI `PublicCode`; `Code` giữ = c4 viết-tắt (required/unique/dedup-backstop/codegen giữ nguyên). *(import-service-lens đề xuất — go-live rủi-ro-thấp nhất; nhược: Code mang nhãn "Mã NCC" thành sai)*
- **Lead lean:** **Opt B** cho go-live an toàn (codegen + dedup-backstop nguyên vẹn), NHƯNG cách anh diễn đạt nghiêng A → **cần anh chốt rõ**.

**D2 — Cổng public (R4 vs R6 mâu thuẫn thật):** R4="không Mã NCC→ẩn" (Mã NCC bắt buộc) vs R6="không MST VÀ không Mã NCC→ẩn" (MST thay được).
- **Opt 1 (R6-literal):** public ⟺ có Mã NCC **HOẶC** có MST.
- **Opt 2 (R4-strict):** public ⟺ **bắt buộc** có Mã NCC (MST không mở khóa).
- **Lead lean:** **Opt 2** (khớp R3/R4 "user tự nhập Mã NCC mới hiện"); import luôn tạo draft, publish là bước người làm tay. Enforce server-side trong `PublishSupplierCommand`.

**D3 — Authz nút Import/Template:** FE gate `Suppliers/Create` (mở mọi user từ S59) ⧣ BE import `[Authorize(Roles="Admin,CatalogManager")]` → non-admin bấm bị 403.
- **Lead lean:** siết FE nút Import+Template về role `Admin/CatalogManager` cho khớp BE (đây cũng là follow-up S112 "FE-Import role-gate"). *(Hoặc nới BE — anh chọn.)*

**D-auto (lead tự quyết, đồng thuận 4 lane — anh phủ quyết nếu muốn):**
- Trạng thái = **`bool IsPublic` default false** (KHÔNG đụng enum `SupplierStatus` = business-status c27).
- Dedup = **MST-primary + Code-backstop** (chống 500 unique-violation), normalize MST (Trim+bỏ space nội, CI); MST rỗng → KHÔNG coi trùng nhau.
- Backfill **22 NCC prod → IsPublic=true** (đang live, PE/Contract tham chiếu — để draft sẽ ẩn NCC đang dùng + vỡ picker).
- Publish qua **`PublishSupplierCommand` riêng** (chỉ set Mã NCC + IsPublic), KHÔNG dùng `UpdateSupplierCommand` (blind absolute-set → #73 clobber data draft).
- Template R2 = **BE endpoint** `GET /suppliers/import/template` (ClosedXML từ token single-source, 0 drift).

---

## ② CÁCH IMPLEMENT

### BE — Migration 64 `AddSupplierPublishState` (3-file rule)
- `Supplier.cs` +`bool IsPublic` (default false). [D1=A] thêm `string? ShortName` + đổi comment Code. [D1=B] thêm `string? PublicCode`.
- `SupplierConfiguration.cs` +`Property(IsPublic)`; [D1=A] đổi unique-filter `[Code]<>''` (DropIndex→CreateIndex); [D1=B] index giữ nguyên + (option) config PublicCode.
- Migration Up: `AddColumn<bool>("IsPublic", nullable:false, defaultValue:false)` + `Sql("UPDATE Suppliers SET IsPublic=1")` backfill (idempotent). Down: DropColumn. **No new table (89 giữ).**
- `DbInitializer.cs` seed IsPublic + [D1] ShortName/PublicCode cho 4 real + 16 sample (4 real: IsPublic=true).

### BE — `SupplierExcelImportService.cs`
- **R1 LayoutValid (CRITICAL):** re-bake `ExpectedHeaderTokens` BYTE-EXACT theo 30 token THẬT dưới đây; re-run test Case 9. Sửa test drift `NormalizeJoin.Norm` thiếu `.Normalize(FormC)` (khớp service :313).
- **R5 dedup→MST:** build `existingByTaxCode` (bỏ blank) + GIỮ `existingByCode` backstop. `NormalizeMst`=Trim+strip inner ws, CI. Precedence: MST-match→Update · else Code-match→Update · else New. In-batch: `batchByMst`+`batchByCode`. MST rỗng → rơi Code-path, KHÔNG merge 2 blank-MST. Update = FillNulls (#73-safe).
- **R3 Mã NCC per-row:** [D1=A] preview để Code editable, confirm round-trip `row.code`. [D1=B] +`string? ManualCode` vào `SupplierImportRowDto` round-trip → NewSupplier ghi vào `PublicCode`.
- **R4 draft:** confirm set `IsPublic=false` cho MỌI new-row (file không có Mã NCC → tất cả nháp). Update-path KHÔNG demote NCC đang public. +`DraftCount` vào ResultDto.
- Endpoint preview/confirm giữ chữ ký (rows mang thêm field). **NEW** `GET /suppliers/import/template` (ClosedXML, sheet "Sheet1", 30 token vào ROW 4, freeze row 4) via `BuildTemplate()` trên `ISupplierExcelImportService`.

### BE — Publish + List filter
- **NEW** `PublishSupplierCommand(Guid Id, string MaNcc, bool publish)`: load entity → nếu publish: enforce D2-gate (Opt2: Mã NCC non-empty; Opt1: Mã NCC OR MST) + unique-CI → set Mã-NCC-field + IsPublic → save. Endpoint `POST /suppliers/{id}/publish`.
- `ListSuppliersQuery` +`bool? Published` → `Where(x=>x.IsPublic==published)` khi có. `SupplierDto` +`IsPublic` (sửa CẢ 2 site projection: `ListSuppliersQuery:48-54` + `GetSupplierQuery:21-26` — positional record khít thứ tự).
- **R4/R6 VISIBILITY (recover lane):** consumer pass `published=true` → ẩn draft. Consumers cần lọc: PE picker (`PeWorkspaceCreateView.tsx`, `PeDetailTabs.tsx`), `ContractCreatePage.tsx`, `CreateContractFromEvaluationFeatures.cs`, `PurchaseEvaluationSupplierFeatures.cs`, dashboard. **HMW PHẢI grep MỌI consumer** (bài học cardinality S87/S88) + quyết nơi nào lọc public-only, nơi admin-quản-lý thấy cả draft.

### FE — 2 app SHA-mirror (Pattern 16-bis)
- `types/master.ts` (2 app): `Supplier` +`isPublic: boolean` (+[D1=B] `publicCode`).
- `SuppliersPage.tsx` (2 app): +cột "Trạng thái" badge Public(emerald)/Nháp-ẩn(slate) [Pattern 14 full-class-literal] + nút "Công bố" (guard Suppliers/Update, mở dialog nhập Mã NCC→POST publish) + filter select + [D3] siết guard nút Import.
- `SupplierImportDialog.tsx` (2 app): +nút "Tải file mẫu" (blob-download mẫu `PeDetailTabs.tsx:3273-3279`) + cột "Mã" editable (`updateRow` patch state) + helper "NCC chưa nhập Mã sẽ lưu nháp (ẩn)" + nới `disabled` chỉ chặn lỗi THẬT (layout/dup), KHÔNG chặn vì thiếu Mã NCC.

### 30 TOKEN HEADER THẬT (row 4 — bake byte-exact vào ExpectedHeaderTokens)
```
c1 STT · c2 GÓI THẦU · c3 "PHÂN LOẠI\n(NTP/NCC/Cả hai)" · c4 "TÊN VIẾT TẮT\n(Dùng trong HĐ)" · c5 "TÊN CÔNG TY\n(Đầy đủ, đúng pháp lý)" · c6 "ĐỊA CHỈ XUẤT HÓA ĐƠN\n(Địa chỉ đăng ký kinh doanh)" · c7 "ĐỊA CHỈ VĂN PHÒNG \n(nếu có)" · c8 "SỐ ĐIỆN THOẠI\nCÔNG TY" · c9 FAX · c10 "SỐ TÀI KHOẢN+ TÊN+CN. NGÂN HÀNG\n(Đầy đủ, đúng pháp lý)" · c11 "SỐ TK PHỤ\n(nếu có)" · c12 MÃ SỐ THUẾ · c13 "NGƯỜI ĐẠI DIỆN\nPHÁP LUẬT" · c14 "CHỨC VỤ\nĐẠI DIỆN" · c15 "GIẤY ỦY QUYỀN\n(số, ngày, người ủy quyền)" · c16 Link GUQ · c17 Link GPKD · c18 Link HSNL · c19 "NGƯỜI LIÊN HỆ\nCHÍNH" · c20 "CHỨC VỤ\nNGƯỜI LH" · c21 SĐT CHÍNH · c22 EMAIL · c23 ĐỊA CHỈ GỬI THƯ · c24 "NGƯỜI NHẬN THƯ/\nSDT" · c25 "NGUỒN\nGIỚI THIỆU" · c26 "NGƯỜI PHỤ TRÁCH\n(PMH)" · c27 "TÌNH TRẠNG\nHIỆN TẠI" · c28 "GHI CHÚ / LÝ DO\nBLACKLIST" · c29 "NGÀY CẬP NHẬT\nCUỐI" · c30 NGƯỜI CẬP NHẬT
```
(NormalizeHeader = upper + collapse-ws + FormC → `\n`/space bị chuẩn hóa, nhưng text phải khớp. c21 "SĐT" + c24 "SDT" = shorthand cần khớp đúng.)

---

## ③ CHECKLIST (vai + deliverable + acceptance ĐO ĐƯỢC)

| Vai | Deliverable | Acceptance đo được |
|---|---|---|
| 🟨 implementer-backend | Mig 64 `AddSupplierPublishState` (3-file) + Supplier/config/DbInit + re-bake tokens + dedup-MST + PublishSupplierCommand + import/template endpoint + ListSuppliersQuery filter + SupplierDto | `dotnet build slnx` 0/0; Mig Up/Down reversible; sys.tables=**89** (no new table); backfill Sql idempotent |
| 🟧 implementer-frontend | 2-app mirror: types +isPublic · SuppliersPage badge+nút Công bố+filter+guard · ImportDialog nút mẫu+cột Mã editable+helper | `npm run build` ×2 PASS; SHA-identical 2 dialog + 2 page (Pattern 16-bis); badge full-class-literal |
| 🟪 test-specialist | dedup T1-T7 (MST/Code/blank/conflict) · publish T8-T11 (draft-on-import/gate/unpublish) · list T12-T13 filter · reconcile T14 backfill · LayoutValid Case9 real-token · all-or-nothing T15 | ≥15 test mới; 0 fail/skip; test-before cho dedup+publish-guard (critical/security) |
| 🟥 reviewer | Adversarial pre-commit: dedup edge (null/dup MST), publish-gate D2, visibility grep-all-consumer, #73 clobber check | GO/GO-WITH-ADJ verdict; 0 must-fix |
| 🟩 cicd-monitor | post-deploy verify Run # | bundle rotate ×2 + Mig 64 prod applied + smoke 200 + byte-verify nút "Tải file mẫu"/badge |

**Ràng buộc:** UI 100% tiếng Việt · commit `[CLAUDE] Supplier: ...` · KHÔNG đụng SupplierStatus enum · publish KHÔNG qua UpdateSupplierCommand (#73).

---

## 🔻 #53-garble note
Lane 3 `public-visibility-consumers` (agentId af249d48) completed without StructuredOutput (#53 tái diễn — ×4 phiên gần đây). Recover: em-main-solo grep consumers (BE 16 file + FE fe-user 11 file) → consumers chính = PE picker + Contract create + dashboard, đều qua `ListSuppliersQuery` → filter `Published` giải quyết. KHÔNG re-spawn.
