Files
solution-erp/docs/gotchas.md
pqhuy1987 cc8a7d34b3 [CLAUDE] Docs: S22 chốt cuối — gotcha #47 + 4 agent MEMORY flush + session log cumulative
Session 22 chốt cuối — bro confirm sub-agent solution OK.

Highlights cumulative S21 chốt → S22 chốt:
- 11 commits S22 pushed remote `3d725c4..b04a11a`
- Plan G S22 evidence: 4 sub-agents (3 seeds-only + 1 CICD Monitor Run #188 PASS)
- Plan C + D + E done · Plan F ABORTED pre-flight blocker
- 5 turn S22+ feedback iteration (disable 3 button + seed 20 user + rename role-based + attachment view + Mig 30 per-NV opt-in)

Docs updates:
- STATUS Last updated S22 chốt + S22 prev row preserved (§6.5 KEEP narrative)
- HANDOFF Last updated S22 chốt + S22 prev row preserved
- Session log mới `2026-05-13-2200-s22-chot-cuoi.md` (~12KB narrative + 11 commit table + 7 lessons learned + handoff S23)
- Gotcha #47 mới `.claude/agent-memory/** thiếu paths-ignore filter` (CICD waste 3.5min per MEMORY flush) — PENDING bro fix `.gitea/workflows/deploy.yml`

4 agent MEMORY.md flushed S22:
- Investigator: 30 mig + 104 test + S22 context essentials + Mig 30 entry + cross-ref `feedback_per_nv_permission_scope` 2× reinforced
- Implementer: +6 patterns (7-12 per-NV opt-in / tách endpoint narrow scope / defense-in-depth FE+BE / reflection regression / cookie-cutter test infra / InternalsVisibleTo) + S22 activity (REFUSED 100% cross-stack)
- Reviewer: +Gotcha #47 + Mig 30 + 104 test baseline + S22 self-review narrative + Identity password ≥12 chars note
- CICD Monitor: refresh test 84 → 104 + Mig 29 → 30 (Run #188 PASS preserved)

User memory reinforcement:
- `feedback_per_nv_permission_scope.md` +Section "Reinforcement S22+5" — pattern proven 2× với Mig 30 F4. Anti-pattern default scope expansion. Decision tree thêm scope khi feedback ambiguous → admin opt-in flag per slot
- `MEMORY.md` index entry updated cross-ref S22+5 reinforcement

Stats final:
- 30 migrations (+1 Mig 30)
- 104 tests PASS (+20 S22)
- 47 gotchas (+1 #47 pending fix)
- ~146 endpoints (+3)
- 33 active prod users (rename role-based)
- 6 skills · 4 sub-agents unchanged

KHÔNG cắt narrative cũ — Edit specific lines + Append new entries per §6.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:25:37 +07:00

799 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gotchas — SOLUTION_ERP
> Bẫy/pitfall đã gặp + cách xử lý. Đọc trước khi debug tương tự để không mất thời gian. Cập nhật liên tục khi gặp bug mới.
## Tech stack constraints (.NET 10 + TS 6 + Vite 8)
### 1. MediatR 14.x không tương thích → pin 12.4.1
**Triệu chứng:** `Unable to resolve service for type 'MediatR.IMediator'``AddMediatR` vẫn chạy nhưng không register IMediator.
**Fix:** Pin `MediatR 12.4.1`. Khi đó `RequestHandlerDelegate<TResponse>` là delegate không tham số (v14 có thêm CancellationToken).
### 2. Swashbuckle 10.x + Microsoft.OpenApi 2.x breaking change
**Triệu chứng:** Build fail `The type or namespace 'Models' does not exist in 'Microsoft.OpenApi'`. Swagger 404.
**Fix:**
- Remove `Microsoft.AspNetCore.OpenApi` khỏi Api
- Downgrade Swashbuckle về `6.9.0`
### 3. TypeScript 6 `erasableSyntaxOnly` cấm `enum`
**Fix:** Dùng `const + as const + typeof[keyof]` pattern:
```ts
export const SupplierType = { NhaCungCap: 1 } as const
export type SupplierType = typeof SupplierType[keyof typeof SupplierType]
```
### 4. TypeScript 6 deprecate `baseUrl`
**Fix:** Bỏ `baseUrl` trong tsconfig, chỉ giữ `paths`. Paths resolve relative tsconfig location.
### 5. Node 22 local vs CI pin 20
**Bài học NamGroup:** CI build fail trên Node latest.
**Fix:**
- `package.json` engines: `">=20"` (min, không upper)
- `.nvmrc` = `20` cho CI
- GitHub/Gitea Actions: `actions/setup-node@v4` với `node-version: '20.x'`
## EF Core 10
### 6. Expression tree không support switch expression
**Triệu chứng:** `CS8514: An expression tree may not contain a switch expression`.
**Fix:** Tách switch ra ngoài LINQ:
```csharp
var hasPermission = action switch
{
"Read" => await query.AnyAsync(p => p.CanRead),
"Create" => await query.AnyAsync(p => p.CanCreate),
_ => false,
};
```
### 7. Design-time DbContext resolve fail
**Triệu chứng:** `dotnet ef migrations add``Unable to resolve service for type 'DbContextOptions<T>'`.
**Fix:** Tạo `IDesignTimeDbContextFactory<ApplicationDbContext>` trong Infrastructure.
### 8. `AddDefaultTokenProviders()` không có trong `AddIdentityCore`
**Fix:** Bỏ call nếu chưa cần password reset. Khi cần, chuyển `AddIdentity` hoặc add package `Microsoft.AspNetCore.Identity.UI`.
## OpenXml / ClosedXML
### 9. `SpaceProcessingModeValues` namespace
**Fix:** Full path + wrap `EnumValue<>`:
```csharp
textElement.Space = new DocumentFormat.OpenXml.EnumValue<
DocumentFormat.OpenXml.SpaceProcessingModeValues>(
DocumentFormat.OpenXml.SpaceProcessingModeValues.Preserve);
```
### 10. Placeholder `{{field}}` bị split runs
**Vấn đề:** Word hay split text thành nhiều `<w:t>` — placeholder miss khi regex replace.
**Fix:** Iterate Paragraph, gom text tất cả `<w:t>` → replace → gán lại text đầu + clear rest. Đã implement trong `DocxRenderer`.
### 11. Word COM `SaveAs` PowerShell type conversion
**Fix:** Dùng `SaveAs2`:
```powershell
$doc.SaveAs2($outPath, 16) # 16 = wdFormatDocumentDefault
```
### 12. Word COM stuck
**Fix:**
- `$word.DisplayAlerts = 0`
- Nếu stuck → `Get-Process WINWORD | Stop-Process -Force`
- Fallback: LibreOffice headless `soffice --headless --convert-to docx`
## System.Text.Json
### 13. Record deserialization fail với Unicode qua CLI
**Triệu chứng:** POST JSON tiếng Việt từ Windows bash/curl → 400 "JSON value could not be converted".
**Fix:** Dùng `curl --data-binary @file.json` (file UTF-8). API handle đúng qua axios/Swagger.
## File operations
### 14. Dropbox sync có thể revert file đang edit
**Triệu chứng:** Write thành công, build pass, runtime chạy code cũ.
**Fix:** Sau Write quan trọng → Read lại verify. Nếu revert → Write lại.
### 15. `.gitignore` wwwroot rules
- `wwwroot/uploads/`**ignore** (user files)
- `wwwroot/templates/`**commit** (source of truth)
- `wwwroot/exports/` → ignore (temp)
## Dev workflow
### 16. Port conflict khi restart dev server
**Fix:** `TaskStop` task cũ, hoặc `netstat -ano | findstr :8082``taskkill /F /PID <pid>`.
### 17. EF migration 3-file rule
Mỗi migration tạo: `{name}.cs` + `{name}.Designer.cs` + `ApplicationDbContextModelSnapshot.cs`. Commit đủ 3.
## Claude Code harness quirks
### 18. Edit tool "File not read" sau system-reminder
**Triệu chứng:** Edit file vừa Read, lỗi "File has not been read yet".
**Nguyên nhân:** System reminder interrupt reset read-cache.
**Fix:** Read lại file rồi Write/Edit. Hoặc dùng Write (ghi đè full) thay Edit.
### 19. Build pass nhưng DI thiếu registration
**Triệu chứng:** `dotnet build` → 0 errors nhưng runtime throw `Unable to resolve service`.
**Nguyên nhân:** C# compiler chỉ check type, không check DI graph.
**Fix:** Sau thêm interface mới + impl → luôn add `services.AddScoped<IX, X>()` trong `DependencyInjection.cs`. Test API start up là OK check.
## Contract workflow
### 20. Mã HĐ gen 2 lần sau reject → approve lại
**Fix:** Check `if (contract.MaHopDong is null)` trước khi gen. Đã implement trong `ContractWorkflowService.TransitionAsync`.
### 21. ~~BE adjacency vs FE NEXT_PHASES sync~~ (RESOLVED)
**Đã xử lý:** FE không còn hardcode `NEXT_PHASES` nữa. BE expose `contract.workflow.nextPhases` trong `ContractDetailDto` từ `WorkflowPolicyRegistry.ForContract(contract)`. FE render dynamic từ đó — single source of truth.
Nếu đổi policy BE: chỉ cần update `WorkflowPolicies.Standard` hoặc `WorkflowPolicies.SkipCcm` trong `Domain/Contracts/WorkflowPolicy.cs`. FE tự reflect.
### 22. Race condition gen mã HĐ khi 2 user cùng transition tới DangDongDau
**Fix:** `IsolationLevel.Serializable` transaction trong `ContractCodeGenerator`. Không skip.
### 42. Dual schema workflow V1 vs V2 — Service phải branch theo pin field (Session 17)
**Symptom:** Phiếu PE pin V2 (`ApprovalWorkflowId` set qua workspace Select) nhưng Service vẫn match approver theo schema cũ (`Dept+PositionLevel`). Approver V2 không duyệt được, button Duyệt báo Forbidden.
**Root cause:** Sau Mig 23-24 entity PE có 2 field workflow pin:
- `WorkflowDefinitionId` (Mig 21 V1 legacy) — pin schema flat cũ
- `ApprovalWorkflowId` (Mig 23 V2 mới) — pin schema 3-table mới
Service trước đó chỉ đọc `WorkflowDefinitionId` → bỏ qua V2.
**Fix (`b41484b`):** `PurchaseEvaluationWorkflowService.TransitionAsync` branch:
```csharp
if (evaluation.ApprovalWorkflowId is Guid awId)
await ApproveV2Async(evaluation, awId, ...); // iterate ApprovalWorkflowSteps + Levels match ApproverUserId
else
await ApproveV1LegacyAsync(evaluation, ...); // iterate WorkflowSteps match Dept+PositionLevel
```
**Pattern reusable** khi wire schema mới song song schema cũ (Contract V2 sắp tới): pin field flag để rẽ logic, KHÔNG drop legacy ngay (giữ backward compat phiếu cũ).
### 43. Step.Order ≠ index 0-based — không thể EF query trực tiếp (Session 17)
**Symptom:** Implement `ResolveV2InboxIdsAsync` (V2-aware Inbox) bằng EF query thẳng:
```csharp
.Where(s => s.Order == e.CurrentWorkflowStepIndex.Value + 1) // FAIL — Step.Order là logical, không phải position
```
Logic sai: `WorkflowStep.Order` không phải position 0-based mà là số sort thứ tự (vd 5, 10, 20). `Steps.OrderBy(s => s.Order).ToList()[idx]` mới đúng.
**Fix:** Precompute candidates EF query → in-memory sort by Order → array index access:
```csharp
var candidates = await db.PE.Where(...).ToListAsync(ct); // Step 1: EF lấy phiếu V2 pending
var workflows = await db.AW.Include(...).ToDictionaryAsync(w => w.Id, ct);
foreach (var c in candidates) {
var steps = wf.Steps.OrderBy(s => s.Order).ToList(); // Step 2: in-memory sort
var step = steps[c.CurrentWorkflowStepIndex.Value]; // Step 3: array index
...
}
```
Trade-off: scalable đến vài trăm phiếu pending, không ngon cho >10k. Optimize sau nếu cần.
## Permission matrix
### 23. Permission update không real-time
**Triệu chứng:** Admin tick permission cho role X → user X vẫn thấy menu cũ.
**Nguyên nhân:** FE cache menu trong `localStorage`, không auto refetch.
**Fix:** User phải logout/login. Phase 3 iteration 2 có thể thêm SignalR push "permission-changed" → FE tự refetch `/menus/me`.
### 24. MenuKey typo — không check type
**Fix:** Luôn dùng `MenuKeys.Contracts` const (BE) + `MenuKeys.Contracts` (FE `menuKeys.ts`). Không hardcode string.
## IIS / Windows Server
### 25. `Install-WindowsFeature Web-WebSockets` khóa section `<webSocket>` ở applicationHost
**Triệu chứng:** Sau khi install WebSocket feature → TẤT CẢ IIS site có `<webSocket enabled="true" />` trong web.config trả về HTTP 500.19 với error code `0x80070021` "configuration section cannot be used at this path" — kể cả site khác project không liên quan.
**Nguyên nhân:** Feature install thêm `<webSocket>` section vào `applicationHost.config` với `overrideModeDefault="Deny"`. Site web.config override section đó → fail.
**Fix:** Unlock section ở server level:
```powershell
& "$env:SystemRoot\system32\inetsrv\appcmd.exe" unlock config -section:system.webServer/webSocket
```
Tương tự khi dùng URL Rewrite `<serverVariables>` cần unlock `system.webServer/rewrite/allowedServerVariables`.
**Cảnh báo co-existence:** Trên VPS shared với project khác, enable feature mới qua `Install-WindowsFeature` có thể làm sập site project khác. Luôn test all site sau mỗi enable.
## SignalR / Realtime
### 26. SignalR WebSocket không cho custom Authorization header
**Triệu chứng:** `new HubConnectionBuilder().withUrl('/hubs/x', { headers: { Authorization: ... } })` — WebSocket transport vẫn 401.
**Nguyên nhân:** Browser WebSocket API không cho set custom headers cho handshake. Chỉ 2 transport khác (SSE / LongPolling) mới dùng headers.
**Fix:**
- FE: dùng `accessTokenFactory: () => token` — SignalR client tự append `?access_token=` query cho WebSocket
- BE: Wire JWT bearer `OnMessageReceived` để đọc token từ query khi path matches `/hubs/*`:
```csharp
options.Events = new JwtBearerEvents {
OnMessageReceived = ctx => {
var accessToken = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
ctx.Token = accessToken;
return Task.CompletedTask;
}
};
```
### 27. SignalR SaveChangesInterceptor — capture Added ở SavingChanges, push ở SavedChanges
**Lý do:** SavedChanges chỉ có entries sau commit thành công. Nhưng ở SavedChanges thì `EntityEntry.State` đã về `Unchanged` → không thể filter `Added`.
**Fix:** 2-phase pattern:
```csharp
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(...) {
_pending = eventData.Context.ChangeTracker.Entries<Notification>()
.Where(e => e.State == EntityState.Added)
.Select(e => e.Entity).ToList();
return base.SavingChangesAsync(...);
}
public override async ValueTask<int> SavedChangesAsync(..., int result, ...) {
foreach (var n in _pending) await _realtimeNotifier.PushAsync(n);
_pending.Clear();
return result;
}
```
## DevOps / CI/CD
### 28. LibreOffice download URL 404 khi pin wrong version
**Triệu chứng:** `Invoke-WebRequest https://download.documentfoundation.org/libreoffice/stable/25.2.7/...` → 404.
**Nguyên nhân:** LibreOffice mirror chỉ giữ vài version mới nhất. 25.2.7, 24.8.7 không có. Chỉ 25.8.6 tồn tại tại thời điểm cài.
**Fix:** Check mirror URL trước khi pin. Dùng `Invoke-WebRequest -Method Head` verify trước download thật.
### 29. PowerShell 5.1 `>> $GITHUB_PATH` ghi UTF-16 → NUL byte crash Gitea Actions
**Triệu chứng:** Gitea Actions job fail với "NUL byte in PATH". `echo "C:\\dotnet" >> $env:GITHUB_PATH`.
**Nguyên nhân:** PS 5.1 default encoding UTF-16 LE BOM khi redirect `>>`. Gitea reads PATH as UTF-8 → NUL byte xuất hiện sau mỗi ASCII char.
**Fix:** Dùng `Out-File -Encoding utf8 -Append`:
```powershell
"C:\dotnet" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
```
Hoặc drop step GITHUB_PATH hoàn toàn nếu NSSM PATH đã có sẵn dotnet+node.
### 30. PS 5.1 scripts với Vietnamese diacritics → parser error
**Triệu chứng:** `Cannot parse script: Unexpected character` khi chạy PS script có text tiếng Việt inline.
**Nguyên nhân:** PS 5.1 đọc file script với ANSI codepage (Windows-1258 hoặc default 1252), không phải UTF-8.
**Fix (1):** Save script với BOM UTF-8 (Write-Host có dấu vẫn work):
```powershell
[System.IO.File]::WriteAllText($path, $content, [System.Text.Encoding]::UTF8)
```
**Fix (2, safer):** Rewrite script ASCII-only. Text tiếng Việt nằm trong log messages thay dùng code:
```powershell
Write-Host "Setup IIS sites done" # thay vi "Hoan tat"
```
## TypeScript / FE
### 31. Dialog `size="xl"` TS2322 nếu variant không khai báo
**Triệu chứng:** `<Dialog size="xl">``Type '"xl"' is not assignable to type '"sm" | "md" | "lg"'`.
**Fix:** Sửa usage về `"lg"`, hoặc add `"xl"` vào `DialogSize` type union trong `components/ui/Dialog.tsx`. Đừng lazy `as any`.
## FE architecture
### 32. NavLink `end` prop cho query-param URL variants
**Triệu chứng:** `/contracts?type=1` highlight cả `/contracts` lẫn `/contracts?type=2` cùng lúc.
**Nguyên nhân:** Default NavLink `startsWith` match. Query string không parse distinct paths.
**Fix:** `end={path.includes('?')}` trong resolvePath để query-variants match exact:
```tsx
<NavLink to={path} end={path.includes('?')}>
```
## IIS / Windows Server (continued)
### 33. IPv4/IPv6 port hijack trên VPS shared (G-084)
**Triệu chứng:** `git.baocaogiaoduc.vn` trả về homepage Next.js của VietReport
thay vì Gitea UI. Headers lộ `x-nextjs-cache: HIT` + `X-Powered-By: ARR/3.0`
(request đã qua IIS ARR proxy rồi mới hit Next.js).
**Root cause:** Next.js app (NSSM service) được deploy lên VPS shared với
Gitea, ignore env `PORT=3001 HOSTNAME=127.0.0.1` và bind `0.0.0.0:3000`.
Gitea bind `0.0.0.0:3000` trước đó bị Windows fallback xuống IPv6-only
`[::]:3000` (default `IPV6_V6ONLY=1`). IIS ARR rewrite `http://localhost:3000`
→ Windows DNS resolve IPv4 first → hit Next.js → leak homepage cho TẤT CẢ
subdomain có ARR proxy về `:3000`.
**Fix (VietReport applied):**
1. Next.js NSSM env `PORT=3001 HOSTNAME=127.0.0.1` — bind loopback IPv4
2. Gitea `HTTP_ADDR=127.0.0.1` — bind loopback IPv4 explicit
3. IIS `web.config` rewrite URL dùng `127.0.0.1` thay `localhost`
4. NSSM `DependOnService=gitea` — boot order tránh race
**3 rules rút ra — áp dụng mọi service trên VPS shared:**
- Reverse-proxy luôn **IP literal `127.0.0.1`**, KHÔNG dùng `localhost`
- Backend services bind **loopback IPv4 explicit**, KHÔNG `0.0.0.0`
- Service dependency cho boot order khi nhiều service cùng port family
**SOLUTION_ERP relevance:**
- API host trong IIS app pool out-of-process (ANCM tự quản lý port Kestrel ephemeral) → risk THẤP
- FE gọi trực tiếp `https://api.solutions.com.vn` (không ARR proxy) → risk THẤP
- **NHƯNG** nếu tương lai thêm ARR reverse proxy (fe-admin/user `/api` proxy) hoặc
deploy Kestrel standalone qua NSSM → PHẢI apply 3 rules trên
- Scripts + skill doc đã update `localhost``127.0.0.1` để đồng bộ
## FE routing + state (Phase 6)
### 34. React Router NavLink `isActive` chỉ match pathname, không query string
**Triệu chứng:** 2 NavLink cùng pathname (`/purchase-evaluations?type=2` vs
`/purchase-evaluations?type=2&pendingMe=1`) cùng highlight khi URL là một
trong 2. User thấy menu "Danh sách" + "Duyệt" active đồng thời.
**Nguyên nhân:** React Router v6 `NavLink`'s built-in `isActive` chỉ so
pathname. `end` prop chỉ thêm exact-match cho pathname segment, không check
query string.
**Fix:** Custom `isActive` với `queryMatches` helper (URLSearchParams set
equality). Xem `Layout.tsx` cả 2 FE:
```tsx
function queryMatches(current: string, target: string): boolean {
const a = new URLSearchParams(current)
const b = new URLSearchParams(target)
const aKeys = [...a.keys()].sort()
const bKeys = [...b.keys()].sort()
if (aKeys.length !== bKeys.length) return false
return aKeys.every((k, i) => bKeys[i] === k && a.get(k) === b.get(k))
}
function MenuLeaf({ node }: { node: MenuNode }) {
const location = useLocation()
const path = resolvePath(node.key)
const [targetPath, targetQuery = ''] = path.split('?')
const isActive = location.pathname === targetPath
&& queryMatches(location.search.replace(/^\?/, ''), targetQuery)
return <NavLink to={path} className={isActive ? 'active' : ''}>...</NavLink>
}
```
### 35. Menu tree inheritance phải extend khi thêm root mới
**Triệu chứng:** Admin/role đã grant `PurchaseEvaluations.Read` (inherit parent)
nhưng menu children `Pe_DuyetNcc_List` / `Pe_DuyetNcc_Create` không hiển thị.
Chỉ thấy root `PurchaseEvaluations` ở Layout sidebar.
**Nguyên nhân:** `GetMyMenuTreeQuery` hardcode 2 inherit root: `Contracts`
`Workflows`. Descendant Ct_*/Wf_* auto-inherit CRUD flags từ parent qua switch
statement. Khi thêm root mới (`PurchaseEvaluations`, `PeWorkflows`) — không có
trong switch → children mặc định (false,false,false,false) → filter
`HasAccess` hide children.
**Fix:** Extend switch + `nextInherit` propagation:
```csharp
var contractsFlags = GetFlags(MenuKeys.Contracts);
var workflowsFlags = GetFlags(MenuKeys.Workflows);
var peFlags = GetFlags(MenuKeys.PurchaseEvaluations); // NEW
var peWorkflowsFlags = GetFlags(MenuKeys.PeWorkflows); // NEW
// Trong BuildChildren:
if (inheritFromKey is not null && !resolved.ContainsKey(m.Key))
{
flags = inheritFromKey switch
{
var k when k == MenuKeys.Contracts => contractsFlags,
var k when k == MenuKeys.Workflows => workflowsFlags,
var k when k == MenuKeys.PurchaseEvaluations => peFlags, // NEW
var k when k == MenuKeys.PeWorkflows => peWorkflowsFlags, // NEW
_ => flags,
};
}
var nextInherit = inheritFromKey
?? (m.Key == MenuKeys.Contracts ? MenuKeys.Contracts
: m.Key == MenuKeys.Workflows ? MenuKeys.Workflows
: m.Key == MenuKeys.PurchaseEvaluations ? MenuKeys.PurchaseEvaluations
: m.Key == MenuKeys.PeWorkflows ? MenuKeys.PeWorkflows
: null);
```
**Rule:** Khi thêm 1 root mới có child leaves (vd `PeWorkflows``PeWf_*`) —
PHẢI update cả 3 chỗ: (1) MenuKeys.All, (2) GetMyMenuTreeQuery GetFlags + switch,
(3) nextInherit propagation.
### 36. Vite env var embed compile-time — đổi `.env.production` phải rebuild FE
**Triệu chứng:** Đổi `VITE_API_BASE_URL=...` trong `.env.production` nhưng FE
vẫn gọi URL cũ. Hot reload không giúp.
**Nguyên nhân:** Vite inline `import.meta.env.VITE_*` tại build time vào JS
bundle (minified). File `.env*` chỉ đọc khi `vite build` — không runtime.
**Fix:** Sau đổi env:
1. Rebuild: `cd fe-admin ; npm run build`
2. Deploy dist mới lên IIS
3. Clear CDN/browser cache (Ctrl+Shift+R)
Verify bundle có URL mới: `curl dist/assets/index-*.js | grep -oE 'https://[^"]+api'`.
## Deploy / Production (continued)
### 37. PowerShell 5.1 diacritics trong script — gotcha #30 tái phát
**Triệu chứng (bis):** `migrate-domains.ps1` viết với "Phương Án", "→",
em-dash → PS 5.1 parser fail `Missing closing ''`, `Unexpected character`.
**Fix:** Luôn ASCII-only cho .ps1 — rule lặp lại gotcha #30. Cách phát hiện:
grep file cho UTF-8 multi-byte chars trước khi deploy:
```bash
grep -P '[\x80-\xff]' scripts/*.ps1
# Nếu có match → rewrite ASCII-only
```
## Email / Users
### 38. Email rename Identity user — 4 field cần update đồng thời
**Triệu chứng:** Đổi `user.Email` xong login với email mới vẫn 401. Hoặc
UserManager.FindByEmail trả null.
**Nguyên nhân:** Identity lookup qua `NormalizedEmail` (uppercase), không
`Email`. Username cũng dùng email. 4 field phải sync:
```csharp
u.Email = newEmail;
u.NormalizedEmail = newEmail.ToUpperInvariant();
u.UserName = newEmail;
u.NormalizedUserName = newEmail.ToUpperInvariant();
await userManager.UpdateAsync(u);
```
**Bonus:** Check conflict trước khi rename (user khác đã có email mới) →
skip để tránh duplicate.
### 39. act_runner v0.2.13 fetch `actions/checkout` từ github.com timeout 21s
**Triệu chứng:** Run #108/#109 fail trong **22s** với:
```
Get "https://github.com/actions/checkout/info/refs?service=git-upload-pack":
dial tcp 20.205.243.166:443: connectex: A connection attempt failed
because the connected party did not properly respond...
```
Test gate (Domain + Infra) chưa kịp chạy. Build/deploy không tới.
**Nguyên nhân:** act_runner mỗi run đều `git fetch` action source code từ
github.com (kiểm tra update `actions/checkout@v4`). Khi VPS → github.com
TCP có vấn đề (intermittent firewall/network), 21s timeout → toàn job fail
TRƯỚC step nào của workflow chạy.
**Fix:** Thay `uses: actions/checkout@v4` bằng manual git checkout từ Gitea
internal — bypass github.com hoàn toàn.
```yaml
- name: Checkout (manual git, bypass github.com)
shell: powershell
run: |
git config --global --add safe.directory '*'
git init -q
git remote add origin "https://gitea-actions:${{ github.token }}@git.baocaogiaoduc.vn/${{ github.repository }}.git"
$ref = "${{ github.ref }}"
if ($ref -like "refs/heads/*") { $ref = $ref.Substring(11) }
git fetch --depth=30 origin $ref
git checkout --quiet "${{ github.sha }}"
```
Tương tự với `actions/upload-artifact@v4` — bỏ vì cũng phụ thuộc github.com.
TRX file vẫn save local trong `test-results/` cho debug.
**Long-term option:** config `github_mirror` trong gitea-runner config.yaml
mirror github.com → Gitea internal repo. Hoặc pre-cache `.cache/act/<hash>/`
manually 1 lần.
**Reference:** Run #108 commit `52999f3` fail, run #110 commit `14b7d18` fix pass.
### 40. npm junction cache `tsc not found` sau Move-Item — chưa xác định root cause
**Triệu chứng:** Implement npm cache strategy bằng junction (mklink /J) +
Move-Item node_modules → cache dir → fail `'tsc' is not recognized` ở step
`npm run build`. Log NO Write-Host "cache MISS" output, NO npm install
output. Timing 1.6s từ end-of-BE-build → start-of-fe-admin npm run build
(impossible cho npm install 49s).
**Hypothesis:**
- (A) Move-Item của `node_modules` chứa nested junctions/symlinks → .bin/
relative paths broken sau move
- (B) act_runner PowerShell stream capture có quirk với cache MISS branch →
output bị silenced
- (C) `Test-Path` trả về stale TRUE từ một state khác
**Workaround tạm:** Rollback về fresh `npm install` mỗi run (49s + 33s = 82s).
Path filter docs-only skip CI là alternative win lớn hơn.
**TODO khi debug session sau:**
- Thử `robocopy /MIR` thay `Move-Item` (handle symlinks tốt hơn)
- Hoặc Copy-Item với `-Force -Recurse` (slower nhưng safer)
- Hoặc dùng act_runner built-in `cache.host` server (có sẵn trong config.yaml)
**Reference:** Run #111 commit `29eb5d9` fail, rollback ở `a21790d`.
### 41. Gitea Actions `paths-ignore` — workflow file change vẫn trigger
**Triệu chứng:** Setup `paths-ignore: ['docs/**', '**/*.md']` để skip CI
khi commit MD-only. Tự nhiên commit `.gitea/workflows/deploy.yml` (chính
workflow file) cũng bị skip → không thể test workflow change.
**Nguyên nhân:** `paths-ignore` evaluate set của file thay đổi. Nếu TẤT CẢ
file thay đổi match patterns → skip. Workflow file `.gitea/workflows/**`
không trong list ignore → trigger normal. **OK behavior.**
**Edge case ngược:** commit thay đổi cả `docs/STATUS.md` + `src/Backend/...cs`
→ NOT skip vì có file ngoài ignore patterns. **Cũng OK.**
**Verify:** Commit chỉ touch `docs/STATUS.md` → check Gitea Actions UI →
phải KHÔNG có run mới trigger. Test với `curl /api/v1/.../runs/<id>`
trả `Not found` cho run-id tiếp theo.
**Pattern hiện áp dụng:**
```yaml
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- '.claude/skills/**'
- '.gitignore'
- 'scripts/**.md'
```
KHÔNG ignore: `.gitea/workflows/**`, `*.cs`, `*.tsx`, `*.ts`, `*.csproj`,
`*.json`, `*.slnx`, `tests/**`.
**Saving:** ~196s/commit cho ~30% commit thuộc loại docs-only (chốt MD,
session log, etc).
**Reference:** Commit `29eb5d9` add filter, verify ở commit `512880c`
(docs-only) → Gitea NO trigger run #113.
### 44. Silent 403 từ class-level `[Authorize(Policy = ...)]` quá strict (Session 18)
**Triệu chứng:** UAT 2026-05-08 — Drafter `nv.test` Workspace tạo phiếu B, dropdown "Quy trình duyệt" empty silent. Admin Designer cùng URL endpoint thấy data đầ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.
**Fix (`f77ea38`):** Tách policy theo action — class-level `[Authorize]` only (any authenticated), action-level chỉ POST/DELETE giữ `[Authorize(Policy = "Workflows.Create")]`:
```csharp
[ApiController]
[Route("api/approval-workflows-v2")]
[Authorize] // ← any authenticated, không hardcode policy
public class ApprovalWorkflowsV2Controller(IMediator mediator) : ControllerBase
{
[HttpGet] // ← Drafter pick workflow lúc create — read-only OK
public async Task<...> Overview(...) { ... }
[HttpPost]
[Authorize(Policy = "Workflows.Create")] // ← admin Designer
public async Task<...> Create(...) { ... }
}
```
**Pattern reusable:** Endpoint dùng cho nhiều use case (admin Designer + user list-pick) — split policy per action thay vì class-level uniform. Read-only list workflow KHÔNG nhạy cảm (chỉ là cấu hình quy trình, không expose business data).
**Phòng tránh tương lai:** Khi controller class-level `[Authorize(Policy)]`, audit role nào cần access từng action. Nếu GET cần broader role hơn POST → split policy. Default `[Authorize]` (any authenticated) cho list-pick endpoint.
**FE diagnostic improvement:** TanStack Query error nên hiển thị warning UI (toast hoặc banner) thay vì silent. Hiện tại `useQuery` catch silent → debug khó. Future: wire `onError` handler global show generic error toast.
### 45. PE "Trả về nhưng hệ thống vẫn duyệt" — FE button label vs decision payload mismatch (Session 21 turn 3)
**Triệu chứng:** UAT 2026-05-12 — User bro screenshot button labeled `← Trả lại` trong PE Workflow Panel (menu "Duyệt"), nhấn vào nhưng phiếu KHÔNG về phase TraLai — ngược lại tiến qua Cấp tiếp theo (hệ thống ghi nhận approve). User mô tả hành vi: "Trả về nhưng hệ thống vẫn duyệt".
**Root cause:** `PeWorkflowPanel.tsx` có 3 chỗ check transition type với logic KHÔNG sync giữa nhau:
- **L205-207** `isSendBack` (button label color): include cả `DangSoanThao` lẫn `TraLai` từ phase trung gian → label hiển thị `← Trả lại` đúng.
- **L64-66** `isReject` (payload `decision` gửi BE): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → khi target=TraLai (98), `isReject=false` → payload `decision: 1` (Approve) thay vì `2` (Reject).
- **L247-248** dialog `isSendBack` (title + warning): CHỈ check `DangSoanThao`, **thiếu `TraLai`** → dialog title fallback `'✓ Duyệt → Trả lại'` (sai semantic) + KHÔNG hiển thị amber warning "Phiếu sẽ về Đang soạn thảo".
BE `PurchaseEvaluationWorkflowService.TransitionAsync`:
- L51 `if (decision == Reject)` branch → set Phase=TraLai correctly khi decision=Reject.
- L97 `APPROVE STEP` branch khi decision=Approve + fromPhase=ChoDuyet → `ApproveV2Async` UPSERT opinion = "đã duyệt" + advance Cấp.
- → Khi FE gửi `decision=1` (do bug `isReject`), BE đi vào nhánh APPROVE thay vì REJECT → phiếu được ghi nhận approve dù user định trả lại.
**Severity:** 🔴 CRITICAL — data integrity issue. NV nhấn "Trả lại" sẽ vô tình "duyệt" phiếu sang Cấp tiếp theo + UPSERT opinion vĩnh viễn vào `PurchaseEvaluationLevelOpinions` (Mig 26). Khó rollback vì BE đã `SaveChangesAsync`.
**Fix Chunk A (`de00887` BE defense-in-depth):**
```csharp
// PurchaseEvaluationWorkflowService.cs sau set isAdmin/isSystem (L48), trước REJECT branch (L51)
if ((targetPhase == PurchaseEvaluationPhase.TraLai
|| targetPhase == PurchaseEvaluationPhase.TuChoi)
&& decision != ApprovalDecision.Reject)
{
throw new ConflictException(
$"Transition tới {targetPhase} BẮT BUỘC decision=Reject (nhận {decision}). " +
"Báo lỗi caller — payload mismatch giữa target phase và decision.");
}
```
Boundary protection cho mọi caller tương lai (API client / mobile / cron retry). 3 regression test:
- `TransitionAsync_TargetTraLai_WithApproveDecision_Throws_AndDoesNotMutateState` (bug reproduce)
- `TransitionAsync_TargetTuChoi_WithApproveDecision_Throws_AndDoesNotMutateState` (consistency cover)
- `TransitionAsync_TargetTraLai_WithRejectDecision_SetsPhaseTraLai` (happy path control)
**Fix Chunk B (`4b29d00` FE mirror 2 app):**
```typescript
// PeWorkflowPanel.tsx (fe-user + fe-admin) — 3 chỗ × 2 app
// Chỗ 1: isReject payload (line 64-66)
const isReject = target === PurchaseEvaluationPhase.TuChoi
|| (target === PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao)
|| (target === PurchaseEvaluationPhase.TraLai // ← THÊM
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai)
// Chỗ 2: dialog isSendBack (line 247-248)
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|| target === PurchaseEvaluationPhase.TraLai) // ← THÊM
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai // ← THÊM
```
Chỗ 3 (button label `isSendBack` L205-207) đã đúng từ S17, KHÔNG đụng.
**Pattern reusable — invariant check khi viết FE workflow transition:**
1. Button label condition (visual) phải SYNC với payload decision (semantic).
2. Dialog title/warning condition phải SYNC với button label + payload.
3. Tốt nhất: extract `isReject(target, currentPhase)` thành 1 helper FE + BE share semantic — KHÔNG duplicate logic giữa 3 chỗ.
**Phòng tránh tương lai:**
- Khi spec mới có thêm phase terminal/intermediate (vd Session 17 thêm TraLai làm Phase RIÊNG thay vì DangSoanThao revert), audit grep TOÀN BỘ logic check `=== DangSoanThao` để xem chỗ nào cần thêm `|| === NewPhase`.
- BE guard early invariant `(targetPhase ∈ terminalSet) ⇔ (decision == Reject)` thay vì trust FE payload.
- Test-before bug fix BẮT BUỘC §7 — 3 test cover bug reproduce + consistency + happy path.
**References:**
- Commit fix: `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B)
- Spec Session 17: `feedback_n_stage_workflow_pattern` DEPRECATED + spec mới trong `PurchaseEvaluationWorkflowService.cs` comment L15-19
- State machine 5 trạng thái: Nháp / Đã gửi duyệt / **Trả lại (98) — Phase RIÊNG** / Từ chối / Đã duyệt
### 46. Gitea Actions API path `/tasks` not `/runs` + cache stale ~2 min (CICD Monitor S21 t4 discovery)
**Triệu chứng:** CICD Monitor sub-agent S21 t4 run đầu poll Gitea Actions runs sau push → `GET https://git.baocaogiaoduc.vn/api/v1/repos/vietreport-admin/solution-erp/actions/runs?limit=5`**404 Not Found**. Tưởng repo không có Actions enabled hoặc API endpoint sai. Debug 10 phút retry path/auth/header trước khi tìm ra đúng path.
**Root cause:** Gitea API v1 spec dùng `/actions/tasks` (NOT `/actions/runs`). Naming khác GitHub Actions API (GitHub: `/actions/runs`). Public no-auth read OK cho repo public.
**Endpoint đúng:**
```bash
GET https://git.baocaogiaoduc.vn/api/v1/repos/vietreport-admin/solution-erp/actions/tasks?limit=5
```
**Response fields:**
- `id` (task internal ID)
- `run_number` (display number Run #N)
- `head_sha` (commit triggered task)
- `status` (queued / running / success / failure / cancelled)
- `conclusion` (final state when status=success/failure)
- `created_at`, `updated_at`
- `display_title` (commit message summary)
**Match task to commit:** Filter `head_sha == $commitSha` thay vì rely on order.
**Bonus gotcha — cache stale:** `updated_at` field caches ~2 phút sau khi deploy thật xong (act_runner ghi log final, Gitea API chưa update DB ngay). Cross-check VPS file timestamps khi cần time-sensitive verify:
```powershell
ssh vietreport-vps "Get-Item C:\inetpub\solution-erp\admin\index.html | Select LastWriteTime"
ssh vietreport-vps "Get-Item C:\inetpub\solution-erp\api\SolutionErp.Api.dll | Select LastWriteTime"
```
File LastWriteTime VPS = thực sự deploy completion time (NSSM copy + IIS recycle xong).
**Phòng tránh tương lai:**
- CICD Monitor system prompt + Bash command preset đã update sang `/actions/tasks` (saved trong MEMORY S21 t4).
- Khi setup automation script với Gitea API → đọc rõ Gitea API spec v1.20+ (`https://docs.gitea.com/api/`) thay vì assume GitHub naming.
- Time-sensitive verify (vd "deploy xong chưa, có an toàn trigger task tiếp không"): KHÔNG trust API status timestamp đơn lẻ → cross với VPS file mtime hoặc curl bundle hash live.
### 47. `.claude/agent-memory/**` thiếu trong `paths-ignore` filter (CICD waste 3.5min per MEMORY flush) (S22 discovery)
**Triệu chứng:** Cuối session em main flush 3-4 sub-agent MEMORY.md drift patch + commit dạng `[CLAUDE] Docs: chốt S22 ...` — push remote → **Gitea Actions trigger full deploy ~3.5min** dù không có thay đổi BE/FE code. CICD waste ~12K token / run × N session.
**Root cause:** `.gitea/workflows/deploy.yml` `paths-ignore` chỉ có `['docs/**', '**/*.md', '.claude/skills/**']` — KHÔNG bao gồm `.claude/agent-memory/**`. Mỗi MEMORY.md drift commit (end-of-session flush + cross-session sync) trigger full CI pipeline → test gate Domain 58 + Infra 46 + build BE + build FE × 2 + deploy NSSM/IIS.
**Discovery:** S22 CICD Monitor Run #188 self-discovery khi verify push `a74e671` (Docs + 3 agent MEMORY drift patch) — confirmed trigger Run dù không có code change.
**Fix recommended (PENDING bro confirm):** Edit `.gitea/workflows/deploy.yml`:
```yaml
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '**/*.md'
- '.claude/skills/**'
- '.claude/agent-memory/**' # ← ADD: prevent end-of-session MEMORY flush trigger deploy
```
**Severity:** Minor (CI waste only, no functional impact). Em main KHÔNG tự edit `.gitea/workflows/` — flag bro decide.
**Cross-ref:** Gotcha #41 paths-ignore docs-only skip (S5 prior). Pattern: bất kỳ file `**/MEMORY.md` hoặc tooling state files KHÔNG nên trigger code deploy.
**References:**
- CICD Monitor Run #186 (S21 t4 2026-05-13 19:13) — first discovery
- CICD Monitor Run #187 (S21 t5 2026-05-13 20:12) — confirmed pattern + cache stale bonus
- Memory `feedback_multi_agent_setup` Plan G Trial Week 1 evidence
## Checklist debug bug mới
1. Build pass không? → fail → check using + package version compat
2. DI register đủ? → runtime error "Unable to resolve" → add `AddScoped/Singleton`
3. API log startup có error ẩn? → `tail` output file
4. File đã persist đúng chưa? → `head -5` verify sau Write
5. Nếu package exotic → thử downgrade về stable trước
6. Nếu TS error → check `erasableSyntaxOnly`, `verbatimModuleSyntax`
7. Nếu EF expression tree → tách logic ra ngoài query
8. Nếu Unicode CLI → dùng file payload
9. Nếu workflow 403 → check FE `workflow.nextPhases` sync từ BE pinned policy
10. Nếu SignalR 401 → dùng `accessTokenFactory` + BE OnMessageReceived hook (#26)
11. Nếu PS 5.1 script fail → check encoding UTF-8 / BOM / ASCII-only (#30)
12. Nếu subdomain trả sai content / bị hijack → check IPv4/IPv6 port collision trên VPS shared (#33)
13. Nếu 2 NavLink cùng active / không đúng highlight → custom isActive match query string (#34)
14. Nếu menu item có quyền nhưng không hiện → check GetMyMenuTreeQuery inheritance extend (#35)
15. Nếu FE gọi API sai URL sau đổi env → rebuild + clear bundle cache (#36)
16. Nếu .ps1 fail parser trên PS 5.1 → ASCII-only, grep multi-byte chars (#30, #37)
17. Nếu rename email Identity vẫn 401 → update 4 field NormalizedEmail/UserName (#38)
18. Nếu CI fail TCP timeout 21s ở "Set up job" → bypass github.com, manual checkout từ Gitea (#39)
19. Nếu npm install caching fail `tsc not found` → KHÔNG dùng junction Move-Item, thử robocopy/Copy-Item (#40)
20. Nếu CI vẫn trigger khi commit MD-only → paths-ignore trong on:push không match patterns đúng (#41)
21. Nếu user phàn nàn "feature work cho admin nhưng user empty/403 silent" → check class-level Authorize policy có over-restrict cho non-admin không, split per action (#44)
22. Nếu button workflow label nói "Trả lại" nhưng phiếu vẫn tiến approve → audit FE `isReject` payload condition vs button `isSendBack` label condition vs dialog `isSendBack` warning condition — phải sync 3 chỗ với CÙNG set target phase. BE thêm guard `(target ∈ terminalSet) ⇔ (decision=Reject)` chặn caller mismatch (#45)
23. Nếu Gitea Actions API trả 404 trên `/actions/runs` → đúng path là `/actions/tasks` (Gitea naming khác GitHub). Cache `updated_at` stale ~2 min → cross-check VPS file LastWriteTime cho time-sensitive verify (#46)