Private Docs

AMLO — Implementation Spec: แก้ 5 เรื่องที่ BU ตีกลับ (รอบ 1)

spec ลงมือทำจริงของทั้ง 5 ข้อ — ไฟล์ที่ต้องแตะ การเปลี่ยน contract validation ลำดับ PR และเกณฑ์ตรวจรับบนหน้าจอจริง

อัปเดต: 2026-09-10

ต่อจาก 09 — 5 เรื่องที่ BU ตีกลับ ซึ่งเป็นที่มาและเหตุผลของทุกการตัดสินใจ เอกสารนี้บอกเฉพาะ “ทำอะไร ที่ไหน ตรวจอย่างไร” · บรรทัดที่อ้างคือ origin/development ณ 10/09/2026 ก่อนแก้ให้เปิดไฟล์จริง — บรรทัดเลื่อนได้หลัง merge development เข้า branch

0. ขอบเขตและกติกา

  • repo ที่แตะ 2 ตัว: Backend_UserService (ข้อ 1, 2, 3) และ Frontend_AdminSuperApp (ข้อ 2, 3, 4)
  • ไม่มี migration ทุกข้อใช้คอลัมน์ที่มีอยู่ (CorrelationId เป็น string? max 100 · EvidenceFileRefs เป็น jsonb) ⇒ ไม่ต้องใช้ branch MIGRATION
  • ไม่แตะ 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 (ทำก่อนทุกอย่าง)

repobranchต้องทำ
Frontend_AdminSuperAppfix/amlo-v2git merge origin/development ก่อนแตะไฟล์ใด — ตามหลัง 18 commit และ f752571 แก้ไฟล์เดียวกับงานนี้ทั้งหมด (amlo-history.*, evidence-viewer.*, amlo-cases.*) แก้ก่อน merge = conflict แน่นอน
Backend_UserServicefix/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 ของสมาชิกใช้ batch GetUsersByIdsAsync ตัวเดียวกับที่มีอยู่ (รวม 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:
    1. รวม ActorUserId (ที่ไม่ null) เข้า set เดียวกับ TargetUserId แล้วเรียก GetUsersByIdsAsync ครั้งเดียว → "{FirstNameTH} {LastNameTH}".Trim() ถ้าไม่ว่างใช้ค่านี้
    2. ถ้าว่างหรือไม่พบ: อ่านจากตาราง 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
    3. ยังไม่ได้ → 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 · FileName trim แล้วยาว 1–255 · ไม่มี / \ และอักขระควบคุม (char.IsControl) · ไม่เท่ากับ . หรือ .. — ผิดกฎ = reject ด้วย error code ใหม่ AMLO_EVIDENCE_FILE_NAME_INVALID (ไม่ sanitize เงียบ ๆ หน้าจอกันไว้แล้วที่ 255 การเจอค่าผิดแปลว่าไม่ได้มาจากหน้าจอ)
  • รูปแบบเก็บ ใน EvidenceFileRefs (บรรทัด :115 เดิม serialize List<Guid>): เปลี่ยนเป็น [{"id":"<guid>","name":"<string|null>"}] เสมอสำหรับแถวใหม่ (แม้ไม่มีชื่อเลยก็ใช้รูปแบบ object เพื่อให้ reader มีรูปแบบเดียวไปข้างหน้า)

4.2 ฝั่งอ่าน (backend) — 2 handler

  • GetAmloUnblockHistoryAllHandler.cs:87-95 และ GetAmloUnblockHistoryHandler.cs (method DeserializeEvidenceFileIds ใกล้ :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 จาก evidenceFileslabel = fileName ?? \${FILE_LABEL} ${index + 1}“ (แถวเก่าจึงยังเป็น “ไฟล์ 1 / ไฟล์ 2” ตามที่ตกลง)
  • evidence-viewer.ts: AmloEvidenceFile (:19-23) เพิ่ม fileName?: string · suggestFileName (:220) ใช้ fileName ถ้ามี ไม่งั้นค่อยเดาจาก content type เหมือนเดิม — สองจุดนี้ต้องแก้คู่กันเสมอ

4.4 ลำดับปล่อย (สลับไม่ได้)

  1. backend ขึ้นก่อน — field ใหม่ optional และ System.Text.Json ข้าม property ที่ไม่รู้จักตาม default (ไม่มีที่ไหนใน UserService01.API ตั้ง JsonUnmappedMemberHandling — ตรวจแล้ว 10/09) ⇒ หน้าจอเก่ายังใช้ได้
  2. หน้าจอตามหลัง — ถ้าหน้าจอใหม่ไปเจอ backend เก่า evidenceFiles จะเป็น undefined ต้อง fallback เป็น evidenceFileIds ได้โดยไม่พัง (เขียน guard ไว้ใน mapper)

4.5 เกณฑ์ตรวจรับ

  • ปลดล็อกใหม่บน dev โดยแนบไฟล์ชื่อไทยและชื่อที่มีช่องว่าง ผ่านหน้าจอจริง → หน้าดูไฟล์แสดงชื่อนั้น และปุ่มดาวน์โหลด ได้ไฟล์ชื่อเดียวกัน (ตรวจใน Downloads ของเครื่อง ไม่ใช่แค่ดู link.download)
  • แถวเก่ายังแสดง “ไฟล์ 1 / ไฟล์ 2”
  • เคสชื่อไฟล์ที่มี /, \, อักขระควบคุม, .., ยาวเกิน 255, และ FileId ที่ไม่อยู่ใน EvidenceFileIdsunit 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เนื้อหาหมายเหตุ
1Backend_UserService fix/amlo-v2developmentข้อ 1 + 2 + 3 (backend)contract additive ทั้งหมด ขึ้นก่อนได้โดยหน้าจอเก่าไม่พัง
2Frontend_AdminSuperApp fix/amlo-v2developmentข้อ 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 แยกต่างหาก