# Session 18 — 2026-05-08 (16:56 → 19:45) — PE V2 polish + Clone B + Mig 25 IsUserSelectable + 4 bug fix UAT

**Dev:** Claude
**Duration:** ~2h49m
**Base commit:** `8680f4c` (Session 17 wrap-up)
**Final HEAD:** `32a8d4d`
**Commits:** 7

## Bối cảnh

Tiếp Session 17 (PE V2 schema end-to-end DONE). User UAT live trên prod `eoffice.solutions.com.vn` test phiếu A (DuyetNcc), feedback chuỗi polish nhỏ + clone V2 cho type B (DuyetNccPhuongAn). Áp memory `feedback_uat_skip_verify` (skip dotnet test mỗi chunk, push ngay) + lesson hotfix CI `0ae3fe2` (rename/remove → bắt buộc `npm run build`).

7 batch deliverable theo thứ tự user feedback:

## B1 (`aaa1c6c`) — Pe Duyệt filter cứng "Đã gửi duyệt"

User: "Duyệt bỏ cái trạng thái đi, chỉ load những trạng thái 'Đã gửi duyệt' là đc."

Screenshot UAT cho thấy leaf "Duyệt" hiển thị 3 phiếu: 1 phiếu Nháp + 2 phiếu Đã gửi duyệt. Phiếu Nháp KHÔNG nên hiện ở leaf "Duyệt".

**Root cause:** `/purchase-evaluations/inbox` BE endpoint hiện loose UAT (mọi authenticated user thấy tất cả phiếu, không filter theo actor là approver Cấp hiện tại). Phân quyền strict V2 đã pending Session 19+.

**Fix FE-only (workaround):**
- `PurchaseEvaluationsListPage.tsx` (cả fe-admin + fe-user):
  - Khi `pendingMe=true` → ẩn dropdown "Tất cả trạng thái", thay bằng hint amber "Lọc cố định: Đã gửi duyệt (phiếu đang chờ duyệt)"
  - Filter cứng client-side: `allRows.filter(p => getPeDisplayStatus(p.phase) === PeDisplayStatus.DaGuiDuyet)` — loại Nháp/Trả lại/Đã duyệt/Từ chối
  - Header count dùng `rows.length` khi `pendingMe` (inbox không paged, không phải `list.data?.total`)
- `PurchaseEvaluationsListPage.tsx` cả 2 app: KHÔNG đụng dropdown ở Danh sách (`pendingMe=false`) — vẫn cho user filter mọi trạng thái

Verify: `npm run build` × 2 pass · 0 TS error.

## B2 (`917446d`) — HistoryTab filter Trả lại / Gửi duyệt lại

User: "Lịch sử thay đổi: Chỉ bắt các dòng thay đổi khi trả lại và gửi duyệt lại thôi nhé, không cần bắt trạng thái duyệt và các thay đổi trước khi trả lại."

**Approach:** FE filter (BE giữ audit data đầy đủ — reversible nếu user đổi ý / cần audit trail compliance / legal).

Logic giữ rows match một trong:
- Workflow transition về TraLai: `entityType === 5 (Workflow) && phaseAtChange === 98 (TraLai)`
- Workflow transition từ TraLai: `entityType === 5 && summary?.includes('TraLai →')`
- Mọi thay đổi nội dung (Header/Detail/Supplier/Quote/Attachment) khi `phaseAtChange === 98`

Logic loại:
- Workflow Approve cùng cấp/phase (Cấp 1→2→DaDuyet): `entityType === 5 && phaseAtChange ≠ 98 && summary KHÔNG chứa 'TraLai →'`
- Sửa nội dung khi phase ≠ TraLai (lần soạn thảo gốc, ChoDuyet đầu)

Empty state: "Chưa có lịch sử trả lại / gửi duyệt lại."

Mirror `PeDetailTabs.tsx` HistoryTab fe-admin + fe-user.

## B3 (`937eb24`) — Clone V2 cho B (DuyetNccPhuongAn)

User: "OK đồng ý cứ giữ đúng thiết kế như thế này, hiện tại, tao chốt: Quy trình chọn thầu phụ - NCC → Duyệt NCC → Đã đúng chính xác với yêu cầu của tao. Plan cho tao kế hoạch clone toàn bộ các cập nhật từ lúc đổi lại quy trình → Đến thời điểm hiện tại và clone nó sang: Quy trình chọn thầu phụ - NCC → Duyệt NCC và Giải pháp."

**Audit reuse trước khi clone** — phát hiện 80% đã chung qua `ApplicableType` discriminator:

| Layer | Đã chung cho B? |
|---|---|
| Schema V2 (Mig 22-24) | ✅ `ApplicableType` enum 1=A / 2=B / 3=Contract |
| BE Service `ApproveV2Async` | ✅ không hardcode type |
| App CQRS `GetAwAdminOverview` / `CreateAwDefinition` / Validator | ✅ chung |
| API `/api/approval-workflows-v2?applicableType=N` | ✅ dynamic |
| FE Designer `ApprovalWorkflowsV2Page` | ✅ `TYPE_CODE_TO_INT` cả 3 type |
| FE Layout regex `^AwV2_(.+)$` | ✅ match dynamic typeCode |
| FE App.tsx route `:typeCode` | ✅ dynamic |
| FE Workspace + List + Pending | ✅ chung qua `?type=N` |
| Menu `Pe_DuyetNccPhuongAn_*` (List/Create/Pending) | ✅ đã seed Mig 12 |

Chỉ thiếu (3 file ~60 LOC):
- `Domain/Identity/MenuKeys.cs` +const `ApprovalWorkflowDuyetNccPhuongAnV2 = "AwV2_DuyetNccPhuongAn"` + add vào `All[]`
- `Infrastructure/Persistence/DbInitializer.cs`:
  - `SeedMenusAsync` +leaf "Duyệt NCC và Giải pháp (Mới)" (Order=2 cạnh leaf A Order=1) dưới root `ApprovalWorkflowsV2`
  - +method `SeedSampleApprovalWorkflowsV2Async` (idempotent — skip nếu admin đã tạo workflow B nào hoặc thiếu test user `nv.test@solutions.com.vn` / Phòng CCM):
    - Seed `QT-DN-PA-V2-001 v01` `IsActive=true`
    - 1 Bước "Phòng CCM" (DepartmentId resolve từ `Code="CCM"`)
    - 1 Cấp 1 NV: test user `nv.test@solutions.com.vn`
- `fe-admin/src/lib/menuKeys.ts` +`AwV2_DuyetNccPhuongAn`

KHÔNG cần migration / Service code mới / Designer page mới. Memory mới `feedback_audit_reuse_before_clone.md` capture pattern.

User feedback: "OK khá tốt, 1 phát chạy luôn :))" → confirm approach worked.

## B4 (`f77ea38`) — Fix silent 403 ApprovalWorkflowsV2Controller

**Triệu chứng:** UAT user `nv.test` (Drafter) login Workspace tạo phiếu B → dropdown "Quy trình duyệt" empty mặc dù Admin Designer cùng URL endpoint thấy v01 sample + v02 admin clone đầy đủ. KHÔNG có toast error / network panel hint.

**Root cause:** `ApprovalWorkflowsV2Controller` class-level `[Authorize(Policy = "Workflows.Read")]` → non-admin role (Drafter chỉ có `PurchaseEvaluations.Read`) bị 403 Forbidden khi GET `/api/approval-workflows-v2`. TanStack Query catch HTTP error trả `data=undefined`, FE component render dropdown empty không có "loading" / "error" state visible → silent fail.

**Fix:**
```csharp
[ApiController]
[Route("api/approval-workflows-v2")]
[Authorize]                                   // ← any authenticated, không hardcode policy
public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
{
    [HttpGet]
    public async Task<...> Overview(...) { ... }   // ← inherit class policy = any authenticated

    [HttpPost]
    [Authorize(Policy = "Workflows.Create")]  // ← admin Designer
    public async Task<...> Create(...) { ... }

    [HttpPatch("{id:guid}/user-selectable")]
    [Authorize(Policy = "Workflows.Create")]
    public async Task<...> SetUserSelectable(...) { ... }   // (added at B6)

    [HttpDelete("{id:guid}")]
    [Authorize(Policy = "Workflows.Create")]
    public async Task<...> Delete(...) { ... }
}
```

Pattern reusable cho Contract V2 Mig 26 sau (cùng controller). Add gotcha #44.

## B5 (`a9c0857`) — Fix sidebar highlight queryMatches transient keys

**Triệu chứng:** Ở leaf "Danh sách" `/purchase-evaluations?type=1`, click chọn 1 phiếu → URL thành `?type=1&id=abc` → leaf bị mất highlight box (gotcha #34 cũ tái phát theo cách khác).

**Root cause:** `queryMatches` exact-set equality:
```ts
if (aKeys.length !== bKeys.length) return false
```
target `{type}` (1 key) vs current `{type, id}` (2 keys) length mismatch → no match.

**Fix:**
```ts
const TRANSIENT_QUERY_KEYS = new Set(['id', 'q', 'editHeader', 'page', 'phase', 'awId'])

function queryMatches(current, target) {
  const a = new URLSearchParams(current)
  const b = new URLSearchParams(target)
  const aKeys = [...a.keys()].filter(k => !TRANSIENT_QUERY_KEYS.has(k)).sort()
  const bKeys = [...b.keys()].filter(k => !TRANSIENT_QUERY_KEYS.has(k)).sort()
  if (aKeys.length !== bKeys.length) return false
  return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k))
}
```

Strip transient state keys (selection / search / filter / pagination) → giữ navigation identity (`type`, `pendingMe`, `mode`) check exact-set như cũ.

Edge cases verified:

| URL hiện tại | Target leaf | Match |
|---|---|---|
| `?type=1&id=abc` | Danh sách `?type=1` | ✓ giữ highlight |
| `?type=1&pendingMe=1` | Danh sách `?type=1` | ✗ distinct (không cross-highlight Pending) |
| `?type=1&phase=10` | Danh sách `?type=1` | ✓ giữ highlight (filter dropdown) |
| `?type=1&pendingMe=1&awId=xyz` | Duyệt `?type=1&pendingMe=1` | ✓ giữ highlight |

Mirror fe-admin + fe-user `Layout.tsx`.

## B6 (`2a53107`) — Mig 25 IsUserSelectable + Designer pin toggle + bỏ "(clone)" + Workspace filter

User feedback Admin Designer (screenshot v01 sample + v02 + v03 admin clone): "Bỏ chữ Clone đi nhé, ghi v02, v03... là đủ rồi, khi tao kế thừa tạo quy trình mới. Thêm cho tao nút stick để chọn các quy trình nào mà User đc select bên ngoài khi tạo phiếu."

**Bỏ "(clone)" auto-suffix:**
- `fe-admin/ApprovalWorkflowsV2Page.tsx` Designer line 403: `name = cloneFrom.name` (giữ nguyên), không append " (clone)". Version số (v02, v03, ...) đã đủ phân biệt.

**Pin toggle "Cho user chọn":**

Decision approach: Schema field riêng `IsUserSelectable` thay vì reuse `IsActive`. Lý do:
- `IsActive` semantic = "đang áp dụng (default new version)" — max 1 per ApplicableType (atomic deactivate ở `CreateAwDefinitionCommand`)
- User yêu cầu "chọn các quy trình nào" — implies multiple → cần multi-select field độc lập
- Default behavior không break: backfill `IsUserSelectable=1 WHERE IsActive=1` giữ active workflows visible cho user

**Migration 25** `AddIsUserSelectableToApprovalWorkflows`:
```sql
ALTER TABLE ApprovalWorkflows ADD IsUserSelectable bit NOT NULL DEFAULT 0;
UPDATE ApprovalWorkflows SET IsUserSelectable = 1 WHERE IsActive = 1;  -- backfill
```

3-file rule commit đủ (.cs + Designer + Snapshot).

**BE:**
- Domain `ApprovalWorkflow.IsUserSelectable` property — independent với `IsActive`
- DTO `AwDefinitionDto` +field
- `CreateAwDefinitionCommand` Handler set default `IsUserSelectable = true` cho version mới (mirror IsActive default)
- New `SetAwUserSelectableCommand(Guid Id, bool IsUserSelectable)` + Handler — toggle stick/unstick
- API endpoint `PATCH /api/approval-workflows-v2/{id}/user-selectable` (policy `Workflows.Create` admin only)
- `DbInitializer.SeedSampleApprovalWorkflowsV2Async` set `IsUserSelectable = true` cho sample

**FE Designer (`fe-admin/ApprovalWorkflowsV2Page.tsx`):**
- `DefinitionDto` +field `isUserSelectable: boolean`
- `TypePanel` +mutation `toggleSelectable` call PATCH endpoint, invalidate query
- `DefinitionCard` props +`onToggleSelectable: () => void`
- Badge amber "📌 Cho user chọn" (lucide `Pin` icon) cạnh Đang áp dụng/Archived khi `def.isUserSelectable === true`
- Button "📌 Ghim cho user / 🚫 Bỏ ghim" (lucide `Pin` / `PinOff`) trong action group

**FE Workspace (cả fe-admin + fe-user `PeWorkspaceCreateView.tsx`):**
- `approvalWorkflows` useQuery filter `(typeBucket?.history ?? []).filter(w => w.isUserSelectable)` — chỉ workflows admin đã ghim hiển thị dropdown user

## B7 (`32a8d4d`) — Cleanup orphan zip files

`.claude.zip` + `docs.zip` từ Claude harness session start (orphan dump untracked). B6 commit `git add -A` lỡ tay cuốn cả 2 file.

Fix:
- `git rm --cached` 2 file
- `rm -f` xóa local
- Add `*.zip` rule vào `.gitignore`
- Commit cleanup

## E2E verified

- ✅ `dotnet build SolutionErp.slnx` 0 error
- ✅ `dotnet test SolutionErp.slnx` **81 pass** (58 Domain + 23 Infra) — no change S18 (feature mới UAT defer test §7)
- ✅ `dotnet ef migrations add AddIsUserSelectableToApprovalWorkflows` 3-file rule OK
- ✅ Migration 25 apply LocalDB OK qua `DbInitializer.MigrateAsync` startup
- ✅ `npm run build` × fe-admin + fe-user pass mỗi commit có FE changes
- ✅ CI deploy commit B6 `2a53107` success (Gitea Actions)
- ✅ User UAT confirm B chạy 1 phát đúng (commit `937eb24`)
- ✅ User UAT confirm Designer toggle pin work (manual test sau commit B6)

## Bug gặp + fix

| Bug | Fix |
|---|---|
| Drafter Workspace dropdown V2 empty silent (Workflows.Read 403) | Class-level `[Authorize]` only, GET cho any authenticated (B4) |
| Sidebar leaf mất highlight khi click row (URL có id transient) | Strip TRANSIENT_QUERY_KEYS trước queryMatches compare (B5) |
| Phiếu Nháp lọt vào leaf "Duyệt" (BE /inbox loose UAT) | FE filter cứng `getPeDisplayStatus === DaGuiDuyet` (B1, workaround) |
| Workspace dropdown hiện cả archived versions (UX không rõ admin pick gì cho user) | Mig 25 `IsUserSelectable` + Designer pin toggle + Workspace filter (B6) |
| Designer auto-fill "(clone)" suffix khi clone version (xấu, version đã đủ phân biệt) | `name = cloneFrom.name` (B6) |
| 2 file `.zip` orphan vào commit B6 (`git add -A`) | `git rm --cached` + `*.zip` .gitignore (B7) |

## Docs updates

- `docs/STATUS.md` — header 24→25 mig + 43→44 gotcha + 1 row Recently Done Session 18 (top)
- `docs/HANDOFF.md` — TL;DR Session 18 mới + cảnh báo Session 19+ (giữ TL;DR S17 nguyên văn theo §6.5 không cắt narrative)
- `CLAUDE.md` (root) — count 24→25 mig + Mig 25 description block
- `docs/database/schema-diagram.md §14` — heading "Migration 22-24" → "22-25" + +cột `IsUserSelectable` + section "IsUserSelectable filter logic" + Pending Session 19+ update Mig 26/27
- `docs/gotchas.md` — +#44 silent 403 from over-restrictive Authorize policy + checklist debug item 21
- `docs/changelog/migration-todos.md` — Phase 9 Session 18 done section đầy đủ + Defer Session 19+ checklist
- `docs/changelog/sessions/2026-05-08-1945-s18-pe-v2-polish-clone-b.md` — file này

KHÔNG đụng (per §6.5 không cố sửa khi không cần):
- `docs/rules.md` — không có rule mới
- `docs/architecture.md` — không có changes structural
- `docs/PROJECT-MAP.md` — không có structural changes
- `docs/workflow-contract.md` — Contract V2 chưa wire (S19+)
- `docs/forms-spec.md` — không liên quan
- `docs/database/database-guide.md` — count migration không hardcode trong header
- Skills (6) — không tạo mới, không refresh; drift ef-core-migration / dependency-audit-erp defer cron audit 2026-06-01

## Memory updates

- ✅ Add `feedback_audit_reuse_before_clone.md` (commit `937eb24` block context, B3 confirm pattern). MEMORY.md index +1 row.

## Handoff to Session 19+

Đọc `docs/HANDOFF.md` "## ⚠️ Điều quan trọng cho Session 19+" cho 6 cảnh báo đầy đủ.

Top priorities:
1. **Contract V2 wire (Mig 26)** — apply audit-reuse pattern, mirror PE
2. **Phân quyền strict V2** — list/inbox/detail filter theo actor role + approver scope
3. **Drop legacy V1 cleanup (Mig 27)** sau khi không còn phiếu pin V1

## Thông số cumulative

| | Trước S18 | Sau S18 | Δ |
|---|---:|---:|---:|
| Migrations | 24 | **25** | +1 |
| DB tables | 58 | 58 | 0 (Mig 25 chỉ ALTER cột) |
| API endpoints | ~140 | **~141** | +1 PATCH user-selectable |
| FE pages | 33 | 33 | 0 (modify existing) |
| Test pass | 81 | 81 | 0 (UAT defer test §7) |
| Gotchas | 43 | **44** | +1 #44 silent 403 |
| Memory entries | 13 | **14** | +1 audit reuse pattern |
| Skills | 6 | 6 | 0 |
| Commits | (after S17) | **+7** | aaa1c6c → 32a8d4d |
| BE LOC | ~16100 | ~16200 | +~100 (Mig 25 + Set command + endpoint + sample seed) |
| FE LOC | ~17500 | ~17600 | +~100 (Designer pin toggle + Workspace filter + filter helpers) |
