AMLO — Implementation Spec: แก้ 5 เรื่องที่ BU ตีกลับ (รอบ 1)
spec ลงมือทำจริงของทั้ง 5 ข้อ — ไฟล์ที่ต้องแตะ การเปลี่ยน contract validation ลำดับ PR และเกณฑ์ตรวจรับบนหน้าจอจริง
อัปเดต: 2026-09-10
ต่อจาก 09 — 5 เรื่องที่ BU ตีกลับ ซึ่งเป็นที่มาและเหตุผลของทุกการตัดสินใจ เอกสารนี้บอกเฉพาะ “ทำอะไร ที่ไหน ตรวจอย่างไร” · บรรทัดที่อ้างคือ
origin/developmentณ 10/09/2026 ก่อนแก้ให้เปิดไฟล์จริง — บรรทัดเลื่อนได้หลัง mergedevelopmentเข้า branch
0. ขอบเขตและกติกา
- repo ที่แตะ 2 ตัว:
Backend_UserService(ข้อ 1, 2, 3) และFrontend_AdminSuperApp(ข้อ 2, 3, 4) - ไม่มี migration ทุกข้อใช้คอลัมน์ที่มีอยู่ (
CorrelationIdเป็นstring?max 100 ·EvidenceFileRefsเป็นjsonb) ⇒ ไม่ต้องใช้ branchMIGRATION - ไม่แตะ IaC / APIM — ไม่มี endpoint ใหม่ ใช้ operation เดิมทั้งหมด (นี่คือเหตุผลหลักที่ข้อ 1 เลือกส่งรายชื่อสมาชิกมาใน DTO แทนการเปิด endpoint ใหม่ ซึ่งต้อง PUT operation ที่ APIM ทั้ง dev/uat)
- ข้อ 5 ไม่มีงานโค้ด เหลือ gate ก่อนส่ง UAT (หัวข้อ 6)
- ทุกการเปลี่ยน contract เป็น additive เท่านั้น: เพิ่ม field ใหม่ ไม่ถอด ไม่เปลี่ยนชื่อ field เดิม
- Clean Architecture ตามกฎ repo: interface repository อยู่
04.Domain/Ports· DTO อยู่03.Application/Features/Amlo/...· Controller ไม่มี logic
1. เตรียม branch (ทำก่อนทุกอย่าง)
| repo | branch | ต้องทำ |
|---|---|---|
| Frontend_AdminSuperApp | fix/amlo-v2 | git merge origin/development ก่อนแตะไฟล์ใด — ตามหลัง 18 commit และ f752571 แก้ไฟล์เดียวกับงานนี้ทั้งหมด (amlo-history.*, evidence-viewer.*, amlo-cases.*) แก้ก่อน merge = conflict แน่นอน |
| Backend_UserService | fix/amlo-v2 | ไม่มี commit ของตัวเอง ตามหลัง 16 commit → fast-forward ไป origin/development (git merge --ff-only origin/development) |
2. ข้อ 1 — ซ่อนแถวลูกด้วย CorrelationId (backend อย่างเดียว)
2.1 ฝั่งเขียน — AdminUnblockCompanyHandler.cs
- หลังสร้าง
companyAuditLog(บรรทัด:177) ส่งcorrelationId: companyAuditLog.Id.ToString()เข้าทุกAmloAuditLog.Create(...)ใน loop สมาชิก (:193-208) - ไม่เพิ่ม
SaveChangesระหว่างกลาง —Idถูกตั้งด้วยGuid.NewGuid()ตั้งแต่ในCreate(AmloAuditLog.cs:70) ทุกแถวยังลงใน transaction เดียวเหมือนเดิม - ห้ามแตะ loop 3C publish (
:218) และAmloExistingGrantJoinAuditor— แถวจาก join ไม่มีแถวแม่ ต้องคงCorrelationId = null
2.2 ฝั่งอ่าน — repository (AmloAuditLogRepository.cs + IAmloAuditLogRepository.cs)
GetUnblockHistoryByCompanyIdAsync(:25-38) และGetUnblockHistoryPagedAsync(:39-61): เพิ่ม&& a.CorrelationId == nullในWhereของbaseQueryก่อนCountAsyncและSkip/Take— กรองที่ SQL ไม่ใช่หลังToListAsync- เพิ่ม method ใหม่ใน interface (
04.Domain/Ports/Persistence/IAmloAuditLogRepository.cs):Task<IReadOnlyList<AmloAuditLog>> GetByCorrelationIdsAsync(IReadOnlyCollection<string> correlationIds, CancellationToken ct)— query เดียวWHERE CorrelationId IN (...)ORDER BY OccurredAtUtc - ไม่ต้องมี index ใหม่ — หน้าละไม่เกิน
pageSizeแถวแม่ และแต่ละแม่มีลูกเท่าจำนวนสมาชิก (ที่เห็นบน dev คือ 1–2) ถ้าวันหน้า profile แล้วช้า ค่อยเพิ่ม index บนCorrelationIdผ่านMIGRATION
2.3 DTO — เพิ่มรายชื่อสมาชิกไปกับแถวแม่
ทั้งสอง DTO (GetAmloUnblockHistoryAllDtos.cs และ GetAmloUnblockHistoryDtos.cs) เพิ่ม parameter ท้าย record:
IReadOnlyList<AmloUnblockedMemberDto> Members // ว่างเสมอสำหรับแถวที่ไม่ใช่ CompanyUnblocked
public sealed record AmloUnblockedMemberDto(Guid UserId, string Email);
- ทั้งสอง handler (
GetAmloUnblockHistoryAllHandler.cs,GetAmloUnblockHistoryHandler.cs): หลังได้แถวแม่ของหน้านั้น เรียกGetByCorrelationIdsAsync(parentIds.Select(id => id.ToString()))หนึ่งครั้ง แล้ว group ด้วยCorrelationId· email ของสมาชิกใช้ batchGetUsersByIdsAsyncตัวเดียวกับที่มีอยู่ (รวมTargetUserIdของแถวลูกเข้าไปใน set เดียว ห้ามวนยิงทีละคน — comment ในไฟล์ห้าม N+1 ไว้แล้ว) - record เป็น positional — การเพิ่ม parameter ทำให้ test ที่ new DTO ตรง ๆ compile ไม่ผ่าน ต้องแก้ test ตาม ส่วน JSON consumer เดิมไม่กระทบ (field ใหม่ถูกเพิ่ม field เก่าครบ)
2.4 หน้าจอ (Frontend_AdminSuperApp) — แสดงรายชื่อในแถวแม่
amlo.model.ts: เพิ่มmembers: { userId: string; email: string }[]ในAmloUnblockHistoryItem- หน้าประวัติใช้
ex-tableซึ่งยังไม่มีกลไก expand row (หน้า cases ได้ข้อยกเว้นจาก Owner ให้ใช้nz-tableตรง ๆ เฉพาะตารางเดียว — ดู comment ในamlo-cases.html:35-36) ⇒ ทางที่เลือก: แสดงในคอลัมน์เดียวกับ “ผู้ถูกปลด” เป็นข้อความ “ทั้งบริษัท (n คน)” และรายชื่อ email ต่อท้ายบรรทัดละคน ไม่ต้องกางแถว ไม่ต้องขอข้อยกเว้นเพิ่ม · ถ้า BU ต้องการกางแถวจริง ๆ ต้องขอ Owner อนุมัติใช้nz-tableเป็นตารางที่สอง — เป็นการตัดสินใจแยก ไม่อยู่ใน spec นี้
2.5 เกณฑ์ตรวจรับ
- กดปลดทั้งบริษัทที่มีสมาชิก Active ≥ 2 คนบน dev ผ่านหน้าจอจริง → หน้าประวัติรวมและหน้าประวัติรายบริษัท ขึ้น 1 แถว และแถวนั้นแสดงชื่อสมาชิกครบทุกคน
Totalบนหน้าประวัติรวมลดลงเท่าจำนวนแถวลูกที่ถูกซ่อน และเปลี่ยนหน้าแล้วจำนวนแถวต่อหน้าเต็มตามpageSize- แถวเก่า 5 เหตุการณ์บน dev ยังแสดงแยกแถวเหมือนเดิม (ไม่ใช่บั๊ก — ไม่มี
CorrelationIdให้ผูก) - query ตรงบน DB (read-only): แถว
UserUnblockGrantedที่เกิดใหม่ทุกแถวมีCorrelationId=IdของแถวCompanyUnblockedเวลาเดียวกัน
3. ข้อ 2 — ชื่อผู้ดำเนินการแทน email
3.1 backend
- ทั้งสอง DTO เพิ่ม
string? ActorDisplayName(คงActorEmailไว้) —nullเมื่อหาชื่อคนไม่ได้ ให้หน้าจอตัดสินใจเอง - ลำดับหาชื่อในทั้งสอง handler:
- รวม
ActorUserId(ที่ไม่ null) เข้า set เดียวกับTargetUserIdแล้วเรียกGetUsersByIdsAsyncครั้งเดียว →"{FirstNameTH} {LastNameTH}".Trim()ถ้าไม่ว่างใช้ค่านี้ - ถ้าว่างหรือไม่พบ: อ่านจากตาราง
Employeesด้วย port method ใหม่ บนIEmployeeRepository(04.Domain/Ports/Persistence/):Task<IReadOnlyList<Employee>> GetByEmailsAsync(IReadOnlyCollection<string> emails, CancellationToken ct)— query เดียวWHERE EmpEmail IN (...)แล้วใช้"{EmpFirstNameTH} {EmpLastNameTH}".Trim()· ส่งเฉพาะ email ที่ไม่ซ้ำและไม่ขึ้นต้นsystem:· ห้ามใช้GetByEmailAsyncที่มีอยู่ (IEmployeeRepository.cs:9) — ชื่อหลอก: มันคืนUser?และ query ตารางUsers(EmployeeRepository.cs:29) ไม่ได้แตะEmployeesเลย จึงไม่ถึงEmpFirstNameTH/EmpLastNameTH - ยังไม่ได้ →
ActorDisplayName = null
- รวม
- ห้าม log ชื่อบุคคลใน handler
3.2 หน้าจอ
amlo.model.ts:actorDisplayName: string | nullในAmloUnblockHistoryItem- หน้าประวัติ (
amlo-history.*) แสดงคอลัมน์ผู้ดำเนินการด้วยกฎ:actorDisplayName→ ถ้า null และactorEmailขึ้นต้นsystem:แสดง “ระบบ (งานเบื้องหลัง)” → ไม่งั้นแสดงactorEmail· เลิกแสดงคอลัมน์ email ตามที่ BU ขอ - ข้อความสำรองอยู่ในไฟล์ messages เดียวกับที่
f752571จัดไว้ ไม่ hardcode ใน template
3.3 เกณฑ์ตรวจรับ
- แถวที่ admin กดเมื่อ 08/09 บน dev แสดงชื่อ-นามสกุลไทย (ถ้าแสดง email แปลว่า
Usersไม่มีชื่อไทย และEmployeesก็ไม่มี → บันทึกผลนี้ไว้ในเอกสาร 09 หัวข้อ “ยังไม่ได้ยืนยัน”) - แถวจาก
system:amlo-grant-expiry-job/system:amlo-existing-grantแสดง “ระบบ (งานเบื้องหลัง)” ไม่ใช่ช่องว่าง
4. ข้อ 3 — ชื่อไฟล์จริง (ทั้งสองฝั่ง, backend ขึ้นก่อน)
4.1 contract ของคำสั่งปลดล็อก (backend)
AdminUnblockCompanyRequest / AdminUnblockCompanyCommand เพิ่ม field optional:
IReadOnlyList<AmloEvidenceFileInput>? EvidenceFiles // ควบคู่กับ EvidenceFileIds เดิมที่ยัง required
public sealed record AmloEvidenceFileInput(Guid FileId, string FileName);
EvidenceFileIdsยังเป็นแหล่งความจริงว่าแนบไฟล์ไหน ·EvidenceFilesเป็นแค่ป้ายชื่อ — handler จับคู่ชื่อด้วยFileIdไฟล์ที่ไม่มีชื่อส่งมาได้name = null- Validator (
AdminUnblockCompanyValidator.cs) เพิ่มRuleForEach(x => x.EvidenceFiles):FileIdต้องอยู่ในEvidenceFileIds·FileNametrim แล้วยาว 1–255 · ไม่มี/\และอักขระควบคุม (char.IsControl) · ไม่เท่ากับ.หรือ..— ผิดกฎ = reject ด้วย error code ใหม่AMLO_EVIDENCE_FILE_NAME_INVALID(ไม่ sanitize เงียบ ๆ หน้าจอกันไว้แล้วที่ 255 การเจอค่าผิดแปลว่าไม่ได้มาจากหน้าจอ) - รูปแบบเก็บ ใน
EvidenceFileRefs(บรรทัด:115เดิม serializeList<Guid>): เปลี่ยนเป็น[{"id":"<guid>","name":"<string|null>"}]เสมอสำหรับแถวใหม่ (แม้ไม่มีชื่อเลยก็ใช้รูปแบบ object เพื่อให้ reader มีรูปแบบเดียวไปข้างหน้า)
4.2 ฝั่งอ่าน (backend) — 2 handler
GetAmloUnblockHistoryAllHandler.cs:87-95และGetAmloUnblockHistoryHandler.cs(methodDeserializeEvidenceFileIdsใกล้:61-69): ย้าย logic ไปเป็น helper กลางตัวเดียวใน03.Application/Features/Amlo/Common/ที่รับ JSON แล้วคืนIReadOnlyList<(Guid Id, string? Name)>— ตรวจJsonValueKindของ element แรก:String= รูปแบบเก่า (id ล้วน, name null) ·Object= รูปแบบใหม่ ·null/ว่าง =[]- ทั้งสอง DTO เพิ่ม
IReadOnlyList<AmloEvidenceFileDto> EvidenceFiles(record AmloEvidenceFileDto(Guid FileId, string? FileName)) และคงEvidenceFileIdsไว้ ให้ client เก่า AmloExistingGrantJoinAuditor.cs:65เขียนevidenceFileRefs: null— ไม่ต้องแก้
4.3 หน้าจอ
amlo.model.ts:AmloUnblockCompanyRequestเพิ่มevidenceFiles?: { fileId: string; fileName: string }[]·AmloUnblockHistoryItemเพิ่มevidenceFiles: { fileId: string; fileName: string | null }[]amlo-cases.ts(จุดที่fileIds.push(uploaded.fileId)ราว:940): เก็บคู่{ fileId, fileName: uploaded.originalFileName }แล้วส่งใน request ของunblockCompanyพร้อมevidenceFileIdsเดิมamlo-history.ts:106-114: สร้างviewerFilesจากevidenceFiles—label = fileName ?? \${FILE_LABEL} ${index + 1}“ (แถวเก่าจึงยังเป็น “ไฟล์ 1 / ไฟล์ 2” ตามที่ตกลง)evidence-viewer.ts:AmloEvidenceFile(:19-23) เพิ่มfileName?: string·suggestFileName(:220) ใช้fileNameถ้ามี ไม่งั้นค่อยเดาจาก content type เหมือนเดิม — สองจุดนี้ต้องแก้คู่กันเสมอ
4.4 ลำดับปล่อย (สลับไม่ได้)
- backend ขึ้นก่อน — field ใหม่ optional และ System.Text.Json ข้าม property ที่ไม่รู้จักตาม default (ไม่มีที่ไหนใน
UserService01.APIตั้งJsonUnmappedMemberHandling— ตรวจแล้ว 10/09) ⇒ หน้าจอเก่ายังใช้ได้ - หน้าจอตามหลัง — ถ้าหน้าจอใหม่ไปเจอ backend เก่า
evidenceFilesจะเป็นundefinedต้อง fallback เป็นevidenceFileIdsได้โดยไม่พัง (เขียน guard ไว้ใน mapper)
4.5 เกณฑ์ตรวจรับ
- ปลดล็อกใหม่บน dev โดยแนบไฟล์ชื่อไทยและชื่อที่มีช่องว่าง ผ่านหน้าจอจริง → หน้าดูไฟล์แสดงชื่อนั้น และปุ่มดาวน์โหลด
ได้ไฟล์ชื่อเดียวกัน (ตรวจใน Downloads ของเครื่อง ไม่ใช่แค่ดู
link.download) - แถวเก่ายังแสดง “ไฟล์ 1 / ไฟล์ 2”
- เคสชื่อไฟล์ที่มี
/,\, อักขระควบคุม,.., ยาวเกิน 255, และFileIdที่ไม่อยู่ในEvidenceFileIds→ unit test ของ validator ในUserService05.Testsต้อง reject ด้วยAMLO_EVIDENCE_FILE_NAME_INVALID· ห้ามยิงคำสั่งปลดล็อกตรงไปที่ dev เพื่อทดสอบเคสนี้ — endpoint นี้เป็นการเขียน ถ้า validator มีบั๊ก request จะสำเร็จและสร้างAmloCompanyUnblock+ แถว audit จริงบน dev
5. ข้อ 4 — สถานะ loading จนกว่า PDF จะวาดเสร็จ (หน้าจออย่างเดียว)
evidence-viewer.html:42-47(iframe สำหรับpdf) และ tag รูปสำหรับimage: เพิ่ม(load)="onRendered(key)"และ(error)="onRenderFailed(key)"evidence-viewer.ts:183-198:busy.set(false)ที่:198ย้ายไปอยู่ในonRendered— เฉพาะเมื่อkeyที่วาดเสร็จ คือactiveKey()ปัจจุบัน (กันไฟล์ที่สลับไปแล้วมาปิดสถานะของไฟล์ใหม่) ·onRenderFailedปิดbusyแล้วตั้งข้อความ error ที่มีอยู่แล้ว (:35-37ใน html) · ไฟล์ชนิดที่ไม่ preview (ดาวน์โหลดอย่างเดียว) ปิดbusyทันทีเหมือนเดิม- ห้ามใส่ timer เดา — ถ้า iframe ไม่ยิง
loadในเบราว์เซอร์ที่ใช้ทดสอบ ให้บันทึกเป็น finding แล้วค่อยตัดสินใจ ไม่ใส่ timeout ล่วงหน้า - test (
evidence-viewer.spec.ts): เพิ่มเคส “หลังload()resolve แล้วbusy()ยัง true จนกว่า(load)จะยิง” และเคส(error) - เกณฑ์ตรวจรับต้องดูด้วยตา: เปิด PDF ขนาด ≥ 3 MB บน dev ผ่านหน้าจอจริง ต้องเห็นข้อความ “กำลังเปิดไฟล์…” ต่อเนื่องจนหน้าแรกของ PDF ปรากฏ วัดเป็นวินาทีจากวิดีโอหรือ screenshot ต่อเนื่อง — spec ที่ assert แค่ signal ไม่นับ
6. ข้อ 5 — gate ก่อนส่ง UAT (ไม่มีงานโค้ด)
ทำตามลำดับใน 09 หัวข้อข้อ 5:
อ่าน client_max_body_size จาก Kong บน kong-system ก่อน → ไม่จำกัด = บันทึกแล้วจบ → จำกัดต่ำกว่า 5 MB =
KongPlugin request-size-limiting ผูกด้วย konghq.com/plugins หรือแก้ Helm values ของ kong-uat (นอก Backend_Iac
ต้องประสานคนดูแล Kong) · ห้ามแปะ annotation ของ nginx ที่ uat
7. ลำดับ PR
| ลำดับ | repo | เนื้อหา | หมายเหตุ |
|---|---|---|---|
| 1 | Backend_UserService fix/amlo-v2 → development | ข้อ 1 + 2 + 3 (backend) | contract additive ทั้งหมด ขึ้นก่อนได้โดยหน้าจอเก่าไม่พัง |
| 2 | Frontend_AdminSuperApp fix/amlo-v2 → development | ข้อ 2 + 3 + 4 (หน้าจอ) | ต้อง merge development เข้ามาก่อนเริ่ม (หัวข้อ 1) |
- PR ฝั่ง backend ต้องเขียนใน description ให้ชัด 3 เรื่อง: (1) ไม่กลับมติ 04/09 ใช้
CorrelationIdแทน (2)EvidenceFilesเป็นการเปลี่ยน contract แบบ additive และชื่อไฟล์เป็นค่าจาก client (3) แถวเก่าไม่ backfill - ปิด PR #2878 และ #2888 ได้ (เนื้อหาอยู่ใน
developmentแล้ว) — ไม่เกี่ยวกับ PR ชุดนี้ แต่ลดความสับสนตอน review
8. คำสั่งพิสูจน์ก่อน claim ว่าเสร็จ
# Backend_UserService
dotnet build src/UserService01.API
dotnet test tests/UserService05.Tests
# test ที่ต้องใช้ Postgres จริง (jsonb / partial index) รันได้เฉพาะเมื่อตั้ง USERSERVICE_COVERAGE_POSTGRES_CONNECTION
# ดูวิธีตั้ง sidecar ใน USERSERVICE_REPO_KNOWLEDGE.md — ถ้าไม่ตั้ง test เหล่านั้น Assert.Fail ไม่ใช่ผ่านเงียบ
# Frontend_AdminSuperApp
npx nx lint admin
npx nx test admin --testFile=amlo-history
npx nx test admin --testFile=evidence-viewer
npx nx test admin --testFile=amlo-cases
npx nx build admin
รายงานผลเป็น exit code + จำนวน pass/fail — และทุกข้อต้องมี screenshot จากหน้าจอจริงบน dev ตามเกณฑ์ตรวจรับของแต่ละหัวข้อ ก่อนส่ง BU ดูรอบ 2
9. สิ่งที่ spec นี้ตั้งใจไม่ทำ
- ไม่ backfill แถวเก่า (
CorrelationId, ชื่อไฟล์) — ตาราง audit เป็น append-only การเปิดทางแก้ย้อนหลังต้องขออนุมัติแยก - ไม่เปิด endpoint ใหม่สำหรับรายชื่อสมาชิกหรือ metadata ไฟล์ — เลี่ยงงาน APIM ทั้งชุด
- ไม่ให้ handler ยิง FileService กลาง transaction
- ไม่แก้
ex-tableให้กางแถวได้ — เป็นงาน ui-kit แยกต่างหาก