Files
solution-erp/docs/gotchas.md
pqhuy1987 66c1a5c170
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m52s
[CLAUDE] Rebrand: 3 domain huypham.vn → solutions.com.vn + migrate script
User request: anh trỏ 3 subdomain mới về VPS IP 103.124.94.38:
  - api.huypham.vn        → api.solutions.com.vn
  - admin.huypham.vn      → admin.solutions.com.vn
  - user.huypham.vn       → eoffice.solutions.com.vn

Verified DNS: cả 3 resolve 103.124.94.38 ✓

Update 17 file repo:
FE (4): fe-admin/.env.production + fe-user/.env.production
        (VITE_API_BASE_URL → https://api.solutions.com.vn)
        fe-admin/src/lib/{api,realtime}.ts + fe-user equivalents (comment)
BE (1): appsettings.Production.json.example — CORS AllowedOrigins
CI/CD (1): .gitea/workflows/deploy.yml — smoke test URL
Scripts (3): setup-iis-sites (DomainApi/Admin/User), setup-ssl (3 host),
             deploy-all (verify curls)
Docs (5): STATUS, HANDOFF, PROJECT-MAP, vps-setup, gotchas
Skill (1): iis-deploy-runbook — 3 site table + description
Email admin@huypham.vn giữ nguyên (Let's Encrypt contact — không phải
domain serve).

Thêm scripts/migrate-domains.ps1 — 1-shot VPS migration:
  1. Pre-flight: resolve DNS 3 domain → verify IP VPS khớp
  2. Add HTTP binding mới cho 3 IIS site (giữ binding cũ làm fallback)
  3. Run win-acme xin 3 cert Let's Encrypt qua HTTP-01 challenge
     (auto add HTTPS binding + http→https redirect)
  4. Verify /health/live + /health/ready + 2 FE endpoint
  5. (Optional -RemoveOld) xóa binding huypham.vn sau verify OK
Rollback: nếu fail, binding cũ vẫn active → site serve qua huypham.vn.

Anh chạy trên VPS:
  cd C:\solution-erp\scripts  ;  .\migrate-domains.ps1
  # Sau 1-2 ngày verify stable:
  .\migrate-domains.ps1 -RemoveOld -SkipCert
2026-04-24 09:43:05 +07:00

350 lines
14 KiB
Markdown

# 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.
## 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ộ
## 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)