Files
solution-erp/docs/gotchas.md
pqhuy1987 e65578a821
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m55s
[CLAUDE] Docs: chot session 3 — PE polish iter 2 + domain rebrand + 5 gotcha moi
User request: 'Chot lai toan bo MD de sang session moi'.

Session 3 (2026-04-24) — ~15 commit feat/fix PE module + domain migration:
 - Domain 3 subdomain huypham.vn → solutions.com.vn E2E live
 - PE rename 'Phuong An' → 'Giai phap' + backfill DB
 - Menu tree inheritance extend Pe_*/PeWf_*
 - Accordion mutex Pe_* + sidebar w-72 + label nowrap
 - NavLink queryMatches (fix 2 leaf cung highlight)
 - PE detail flat layout: Panel 2 = 4 section, Panel 3 + approvals/history
 - Upload file dinh kem per-NCC (SupplierAttachmentsCell) + Bang so sanh tong
 - readOnly mode menu 'Duyet' (pendingMe=1)
 - HD move Lich su dieu chinh → Panel 3
 - Demo email rebrand @solutionerp.local → @solutions.com.vn + BackfillUserEmailDomain

Docs updated (6 file):
 - STATUS.md: +9 row Recently Done session 3. In Progress tick 10+ done. Phase
   hien tai = 'UX polish hoan thien, UAT-ready'.
 - HANDOFF.md: TL;DR session 3 summary. Priority 0 = 3 task MISSING cuoi
   (Designer UI, Y kien 4 phong ban, Export PDF). Login email moi.
 - gotchas.md: +5 entry (#34 NavLink query, #35 menu inheritance extend,
   #36 Vite env rebuild, #37 PS 5.1 ASCII, #38 Identity rename 4 field) +
   checklist debug +5 entry.
 - ef-core-migration SKILL: migration 13 AddPurchaseEvaluationCodeSequences
   + Phase 6 update section (ComparisonTable enum + BackfillUserEmail).
 - skills/README: ef-core-migration 13 migration label updated.
 - docs/changelog/sessions/2026-04-24-chot-session-3-pe-polish.md: session log
   15 commit + bugs + stats + next priorities session 4.

Memory project_solution_erp.md: Phase 6 iter 2 DONE. Domain rebrand DONE.
Session 4 priority 3 PE gap remaining.

Stats: 47 DB tables (+1 MaPhieu seq), ~113 endpoint (+3 PE attachments),
13 migrations, 38 gotchas, ~85 commits total.
2026-04-25 00:37:30 +07:00

20 KiB

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:

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:

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 addUnable 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<>:

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:

$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 :8082taskkill /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.

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:

& "$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/*:
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:

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:

"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):

[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:

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

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:

<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 localhost127.0.0.1 để đồng bộ

FE routing + state (Phase 6)

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:

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: ContractsWorkflows. 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:

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 PeWorkflowsPeWf_*) — 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:

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:

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.

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)