# S147 — PE đính-kèm sau Trả-lại + gom dự án trùng (SHIP PROD)

**2026-07-23** · product/UAT session (khác chuỗi governance S135–S146) · lead Opus 4.8 · `/tiep` (WAL trống, S146 đóng sạch) → 2 việc UAT anh giao.

## Bối cảnh vào phiên
`/tiep`: WAL chain rỗng nhưng chạy đủ Sàn-3 (5 tín-hiệu MẠNH). Tín-hiệu ⑤ fire (unpushed `wal:` chạm `.claude/sessions/session-1/closed.md`) NHƯNG ORPHAN-L cân sổ (pause=1/resume=1/closed=1) ⇒ L1(S146) đóng sạch, **không có mạch dở**. Rồi anh giao 2 việc + chỉ đạo **"invest + review chạy → sau đó fix"**.

## Việc ① — Bug đính kèm "Bảng so sánh" không sửa được khi phiếu bị Trả lại
**Triệu chứng (UAT, @Kenny/C Trà):** phiếu Duyệt NCC bị **Trả lại (TraLai=98)** → người soạn KHÔNG xóa file đính kèm sai + KHÔNG tải file mới được.

**Gốc (investigator + reviewer xác nhận):** KHÔNG phải phân quyền. Nút xóa/tải của `GeneralAttachmentsSection` (khối "Bảng so sánh giá", `purpose=ComparisonTable`) gate theo **`readOnly` của MÀN HÌNH**, không theo *trạng-thái + người-soạn*. Màn **"Danh sách"** mở phiếu `readOnly=true` cứng (`PurchaseEvaluationsListPage.tsx`) → ẩn nút ở **MỌI** phase, kể cả TraLai vốn ĐÃ nằm trong `isEditablePhase = {DangSoanThao, TraLai}`. Màn "Thao tác" (workspace) vốn chạy được (đó là cách gỡ tạm). **BE không hề chặn theo phase** — cố ý (S78 cần approver upload `ApprovalAttachment` lúc ChoDuyet).

**Fix (surgical, cả 2 app):** trong `ThongTinChonThauSection` (`PeDetailTabs.tsx`) tính `attachEditable = (isAdmin || isDrafter) && isEditablePhase(ev.phase)`, truyền `readOnly={!attachEditable}` **CHỈ** vào `GeneralAttachmentsSection` (`:2013`). **KHÔNG** đụng `readOnly` cấp `:421` (kẻo mở nhầm `HoSoLinkRow` + winner). Blast-radius = 1 hàm, 0 signature-change.

**3 bẫy reviewer bắt (đã né):** (1) `:421` bleed → HoSoLink; (2) `SupplierAttachmentsCell` gate `readOnly && !approverEditMode` — áp công-thức naive sẽ **vỡ Mig 28 F3** (approver sửa QuoteDocument lúc ChoDuyet) → **descope** (bug chỉ về comparison-doc); (3) BE guard naive vỡ **cả S78 lẫn Mig 28 F3** → **không thêm BE guard**. Test: FE-only, không có .NET test home → verify bằng `tsc -b` + `vite build` ×2 sạch.

## Việc ② — Gom dự án trùng (khác dấu-cách trong mã) + vá gốc
**Yêu cầu (UAT):** `FLOCK 01`→`FLOCK01`, `FLOCK 03`→`FLOCK03`, `CAL 01`→`CAL01` (cây "Duyệt NCC" hiện trùng do mã lệch 1 dấu cách).

**Gốc:** Project Create/Update lưu `Code` **thô** + kiểm-trùng **so-khớp-đúng-nguyên-văn** (`ProjectFeatures.cs` `x.Code == request.Code`) → "FLOCK 01" ≠ "FLOCK01" đều lọt. Seed `SeedRealMasterDataAsync` chỉ sinh mã **không-dấu-cách** (idempotency key exact-Code, ungated) ⇒ nguồn dấu-cách = nhập tay. Reviewer sửa investigator: refs là **loose-Guid (0 FK vật lý tới Projects)** — trỏ lại an toàn thứ tự nhưng **DB không có lưới an toàn** → phải verify 0-ref trước ẩn.

**Vá gốc (code, anh chốt "chỉ cắt đầu/cuối"):** LƯU `Code = request.Code.Trim()` (giữ mã như gõ), nhưng KIỂM-TRÙNG so trên `Code.Replace(" ","")` (bỏ-dấu-cách) → chặn tạo/sửa thành bản trùng chỉ khác khoảng trắng. **+4 test guard** `ProjectCodeNormalizationTests` (EF dịch `Replace` trên SQLite OK).

**Gom dữ liệu — Dev trước, prod sau:**
- Script `docs/changelog/data-fixes/2026-07-23-merge-duplicate-projects.sql` — idempotent, mỗi cặp 1 transaction, ABORT nếu >1 bản-giữ sống (reseed cycle), budget UNIQUE-collision → soft-delete, VERIFY 0 live-ref → else ROLLBACK. `SET QUOTED_IDENTIFIER ON; GO` baked-in (filtered-index DML bắt buộc, sqlcmd default OFF).
- **Dev:** cặp `FLOCK 01` (rỗng) ẩn mềm, idempotent re-run = SKIP.
- **PROD (qua `ssh vietreport-vps`, integrated auth `-E` → KHÔNG chạm password vrapp):** đo trước (read-only) = cả 3 cặp **live 2 bên, 0 HĐ, 1 bản-giữ/mã, 0 budget-collision** → chạy gộp: **5 phiếu + 5 ngân sách trỏ lại, 0 số bỏ, 3 bản dấu-cách ẩn mềm**. App-visible = 1 live row/mã. Đảo lại được (soft-delete). Verify em-main + kết-quả script **2-nguồn-khớp**.

| Mã giữ | Trước | Sau |
|---|---|---|
| CAL01 | 4 phiếu/2 NS | 5 phiếu/3 NS |
| FLOCK01 | 2 phiếu/2 NS | 4 phiếu/4 NS |
| FLOCK03 | 1 phiếu/1 NS | 3 phiếu/3 NS |

> ⚠️ Anh dặn: "sau này sẽ chạy tối ưu kỹ lại chỗ này" — đây là gom-sạch first-pass, chưa phải tối ưu cuối.

## Deploy
2 commit tách việc: `7d39d8d` (① FE 2-app) + `4510cea` (② BE + test). Squash 5 `wal:` local trước push (reset `origin/main`, 0 `wal:` lọt origin). **CI Run #407 SUCCESS 5m36s.** Verify (cicd-monitor + em-main curl **2-nguồn-khớp**): test gate 45D + **487I = 532** (was 483I → **+4**); bundle **fe-admin `DGPE6Nkb`→`B2j1LQ-v`** + **fe-user `DlTwAgJj`→`C3wbJegk`** (CSS frozen = logic-only); smoke api/admin/eoffice + /health = 200; **0 migration** (code-only, prod giữ Mig 67).

## Lessons
- **#53 return-garble ×3** (investigator ①-attachment + reviewer ②-merge + cicd-monitor) — tất cả memory-curation-fragment, tất cả recover **SendMessage-resume in-session** 3/3 sạch. → [[feedback_agent_return_garble_recover]] (tally ×25→×28).
- **Prod access:** S134 SSH flaky (reset trên reconnect nhanh) nhưng **one-shot non-interactive OK** + retry-loop; sqlcmd over SSH KHÔNG chết (S134 chỉ interactive); integrated auth `-E` (Administrator) né password; **QUOTED_IDENTIFIER ON bắt buộc cho DML trên bảng có filtered-index** (sqlcmd default OFF ≠ SSMS).
- **Reviewer-adversarial đáng giá:** self-review sẽ ship 3 regression (bleed + Mig28 + S78). Reviewer ② sửa cả claim "FK" của investigator (thật là loose-Guid).

## State
**Mig 67 · 89 bảng · 532 test (45D+487I) · gotcha 82 · menu 54 · bundle admin `B2j1LQ-v`/user `C3wbJegk` · prod dự án hết trùng (5 phiếu repoint, 3 ẩn mềm).**
