Plan — TaskService Integration
แผน implement การต่อ TaskService เข้ากับ orchestrator
อัปเดต: 2026-06-15
For agentic workers: REQUIRED SUB-SKILL: Use superpowers
(recommended) or superpowers to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. ⚠️ Contract status: Task↔Orchestrator contract ยังไม่ lock — ส่วน A ต้องเสร็จก่อนส่วน B อ่านก่อน: 00-OVERVIEW.md (§7 contract matrix M9-M11) · CONTEXT.md · 04-taskservice.md
Goal: เพิ่ม HTTP endpoints ใน TaskService ให้ orchestrator เรียกได้ครบ saga lifecycle — mark-rework (resubmit), complete (approve), close (reject/cancel) แบบ idempotent — โดย create/GET-by-ref ที่มีอยู่แล้วยืนยัน/ปรับ contract
Architecture: Clean Architecture 4-layer (01.API / 02.Infrastructure / 03.Application / 04.Domain). Controller รับ request → mediator.Send() → HandleResult(result). Feature ใหม่แต่ละตัว = Command record + Request + Handler + ViewModel ใน 03.Application/Features/Tasks/{Name}/. Status เป็น free-text string column (TaskTransaction.Progress, max 50) — ไม่มี migration ถ้าใช้ค่าใหม่ในคอลัมน์เดิม
Tech Stack: .NET 10, EF Core, MediatR, AutoMapper, FluentResults, xUnit + Moq + FluentAssertions (EF InMemory)
สถานะ code ที่สำรวจแล้ว (ground truth — อย่าเดา)
| สิ่งที่ต้องรู้ | สถานะจริงในโค้ด | ไฟล์ |
|---|---|---|
| Create task | ✅ มีแล้ว — คืน TaskID ใน Result<CreateTaskViewModel>, HTTP 200 (HandleResult คืน Ok เสมอ; attribute [ProducesResponseType(201)] บน controller ไม่ตรงจริง) | Features/Tasks/CreateTask/*, TaskController.CreateTask |
| GET by ref | ✅ มีแล้ว — miss → Result.Ok<...>(null!) → HTTP 200 + null (ไม่ใช่ 404); test ยืนยันพฤติกรรมนี้ (Handle_When_NotFound_Returns_Ok_With_Null_Value) | Features/Tasks/GetTaskByRefNo/* |
| mark-rework | ❌ ไม่มี — ต้องสร้างใหม่ | — |
| complete | ❌ ไม่มี — ต้องสร้างใหม่ | — |
| close | ❌ ไม่มี — ต้องสร้างใหม่ | — |
| Status model | free-text string? Progress ([StringLength(50)]) บน TaskTransaction. ค่าที่เจอ: "NOTSTARTED", "COMPLETED". Patch ใช้ "COMPLETED" เป็น marker. มี enum TaskStatusEnum {NotStarted, Completed, Cancel} แต่ entity ไม่ได้ใช้ (เก็บ string ตรง ๆ) | Entities/TaskTransaction.cs, Enum/TaskStatusEnums.cs |
| Rework hint | CreateTaskRequest.IsRework (bool) มีอยู่ แต่ ไม่ถูก persist (entity ไม่มี field นี้) | CreateTask/CreateTaskRequest.cs |
| Lock flag | TaskTransaction.IsLocked (bool) | Entities/TaskTransaction.cs |
| Repo lookup | ITaskRepository.QueryWithWorkType() / .Query() → LINQ FirstOrDefault(t => t.RefNo == x && t.IsActive) หรือ t.TaskID == id | Ports/Persistence/ITaskRepository.cs |
| 404 mapping | HandleResult map error message ที่มีคำว่า "not found"/"ไม่พบ" → HTTP 404; อื่น ๆ → 400 | Controllers/TaskApiControllerBase.cs |
| Route base | api/task-service/v{version:apiVersion}/tasks | Controllers/v1/TaskController.cs |
| Test cmd | dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_TaskService/tests/Task05.Tests/Task05.Tests.csproj | net10.0 |
⚠️ ค่า string status เป็น contract ฝั่ง Task ล้วน —
"NOTSTARTED"/"COMPLETED"มาจาก convention เดิม ไม่ใช่ enum. ค่า status สำหรับ rework/cancelled ที่จะเพิ่ม (เช่น"REWORK","CANCELLED") ต้องคุยกับทีม Task ว่าจะใช้คำว่าอะไร + กระทบ filter/search/UI ของ Task เองไหม → blocked on A.4
Part A — Phase-0 Coordination
กฎ: ทุก item ต้องได้คำตอบจากทีม Task + orchestrator dev (adapter
ITaskPortฝั่ง orchestrator) ก่อนเริ่ม Part B ที่ depend อยู่. ผลลัพธ์ = อัปเดต 04-taskservice.md §Tasks ให้เป็น “locked contract” แล้ว unblock Part B
- A.1 — Endpoint paths/verbs M9-M11 ยืนยันกับทีม Task + orchestrator adapter:
- create (M9): คงเดิม
POST api/task-service/v1/tasksคืนResult.TaskID(200) — orchestrator เก็บลงCorrelations.TaskId. ยืนยันว่า orchestrator parseTaskIDจากTaskBaseResponse<CreateTaskViewModel>.Result.TaskID - mark-rework (M10): เสนอ
PATCH api/task-service/v1/tasks/ref/{refNo}/reworkหรือ by-idPATCH .../tasks/{id:int}/rework— เลือกอันเดียว (ดู A.3 key) - complete (M11a): เสนอ
PATCH .../tasks/{key}/complete - close (M11b): เสนอ
PATCH .../tasks/{key}/close - decision needed: by
refNoหรือ bytaskId? (orchestrator ถือทั้งคู่: refNo เสมอ, TaskId หลัง create) — แนะนำ by-id เป็นหลัก, by-ref เป็น fallback เพราะ orchestrator มี TaskId ใน Correlations แล้ว
- create (M9): คงเดิม
- A.2 — GET /ref miss behavior (T4) ตกลงกับ orchestrator adapter (P3): เปลี่ยน miss จาก
200+null→404ไหม?- ถ้า orchestrator แค่เช็ค
Correlations.TaskId(decision C idempotency อยู่ฝั่ง orchestrator) อาจ ไม่ต้อง GET /ref เลย → T4 อาจตัดทิ้ง - ถ้าตกลง 404: ระวัง breaking change — มี test + ผู้เรียกเดิมพึ่ง 200+null contract (
GetTaskByRefNoHandlerTests+ comment “IOS design”). ต้องถามว่ามี consumer อื่น (FE/IOS) พึ่ง 200+null ไหม - ผลลัพธ์: ตัดสิน 1 ใน 3 — (a) คงเดิม 200+null, (b) เปลี่ยน 404, (c) ตัด T4 ทิ้ง
- ถ้า orchestrator แค่เช็ค
- A.3 — Idempotency contract (close/complete) ยืนยัน:
- orchestrator ถือ idempotency key (saga-state skip create ถ้า
Correlations.TaskIdมี) — Task ไม่ต้อง idempotency-by-refNo บน create - แต่ close/complete ต้อง idempotent: ปิด task ที่ปิดแล้ว = no-op 200 (ไม่ใช่ 4xx) — กัน cancel double-sweep (00 §6) + cancel-vs-create race
- ยืนยันว่า “ปิดแล้ว” = สถานะอะไร (terminal set) → ดู A.4
- ยืนยันพฤติกรรม task ไม่เจอ (refNo/id ไม่มีจริง) ตอน close/complete: 404 หรือ no-op 200? (00 §6 teardown “ทน target ยังไม่ถูกสร้าง” → แนะนำ no-op 200 สำหรับ close, แต่ complete อาจ 404) — ต้องตกลง
- orchestrator ถือ idempotency key (saga-state skip create ถ้า
- A.4 — Task status model (string values) ยืนยันกับทีม Task — คำที่จะเขียนลง
Progresscolumn:- complete (approve) →
"COMPLETED"(มีใช้แล้ว ✅) - close (reject/cancel) → เสนอ
"CANCELLED"(enum มีCancel) — ถามว่าใช้คำไหน + แยก reject กับ cancel ไหม (เสนอ: รวมเป็น close เดียว, outcome ต่างใน history comment) - mark-rework → เสนอ
"REWORK"— ถามว่าใช้คำไหน + กระทบ Task filter/search/pending-tasks ไหม (mark-rework ไม่ปิด task → ยังต้องโผล่ใน list ของ maker) - terminal set (สำหรับ idempotency A.3): เสนอ
{"COMPLETED", "CANCELLED"}— ยืนยัน
- complete (approve) →
- A.5 — mark-rework semantics ยืนยัน: rework = task ยังเปิด (owner กำลังแก้), เปลี่ยน
Progress+ เพิ่ม history entry, ไม่แตะIsActive. ถาม: ต้อง un-assign / reset assignee ไหม ตอน rework? (default: ไม่แตะ assignee)
เมื่อ A.1-A.5 ratified → mark ✅ ในตารางนี้ + อัปเดต 04-taskservice.md → เริ่ม Part B
Part B — Implementation (provisional, TDD)
TDD loop ต่อ task: เขียน test (red) → handler+command (green) → wire controller →
dotnet testเขียว → refactor. ทุก handler ตาม pattern เดิม (PatchTaskHandler): repo.Query()/.QueryWithWorkType()lookup → mutate →_taskRepository.Update(task)→_unitOfWork.Commit()→ map ViewModel →Result.Ok. blocked tags ระบุว่ารอ A item ไหน
B0 — Shared: lookup helper (key resolution) [blocked on A.1, A.3]
ตัดสินจาก A.1: ถ้า by-id เป็นหลัก ใช้ Query().FirstOrDefault(t => t.TaskID == id && t.IsActive); ถ้า by-ref ใช้ t.RefNo == refNo && t.IsActive. ใช้ pattern เดียวกับ PatchTaskHandler:50 / GetTaskByRefNoHandler:36.
- B0.1 — ยืนยัน key จาก A.1 แล้วเลือก lookup expression (ไม่สร้าง abstraction ใหม่ ถ้าใช้แค่ 3 handler — copy pattern เดิม ตาม “เรียบง่ายก่อน”)
B1 — mark-rework endpoint (T2) [blocked on A.1, A.4, A.5]
ไฟล์ใหม่: Features/Tasks/MarkTaskRework/ → MarkTaskReworkCommand.cs, MarkTaskReworkRequest.cs, MarkTaskReworkHandler.cs, MarkTaskReworkViewModel.cs
- B1.1 — (RED) test
Features/Tasks/MarkTaskReworkHandlerTests.cs:Handle_When_TaskFound_Sets_Rework_Status_And_Keeps_Open→Progress == "REWORK"(ค่าจาก A.4),IsActive == true, history entry เพิ่ม 1Handle_When_TaskNotFound_Returns_Fail→IsFailed, message contains key (404 mapping)- ใช้
TestDbContextFactory.CreateWithSeed+EntityBuilders.BuildTask(มี helper อยู่แล้ว)
- B1.2 — (GREEN) command + request + handler + viewmodel:
Handler (pattern จาก// MarkTaskReworkCommand.cs public record MarkTaskReworkCommand(/* int TaskId หรือ string RefNo ตาม A.1 */ int TaskId, MarkTaskReworkRequest Request) : IRequest<Result<MarkTaskReworkViewModel>>; // MarkTaskReworkRequest.cs public class MarkTaskReworkRequest { public string? Reason { get; set; } }PatchTaskHandler):var task = _taskRepository.Query() .FirstOrDefault(t => t.TaskID == request.TaskId && t.IsActive); if (task is null) return Result.Fail($"ไม่พบ Task ที่มี ID: {request.TaskId}"); task.Progress = "REWORK"; // ค่าจริงจาก A.4 task.UpdatedBy = _currentUserService.UserName; // หรือ SystemSettings.DefaultUser ตาม create-handler pattern (orchestrator = system caller) task.UpdatedDate = DateTime.UtcNow; task.AssignHisItems ??= new(); task.AssignHisItems.Add(new TaskTransactionHistory { UserName = task.AssignTo, Comment = request.Request.Reason ?? "งานถูกส่งกลับแก้ไข (rework)", CreatedBy = ..., UpdatedBy = ... }); _taskRepository.Update(task); _unitOfWork.Commit(); return Result.Ok(_mapper.Map<MarkTaskReworkViewModel>(task));decision (A.5): ไม่แตะ
AssignTo/IsActive. ถ้า A.5 บอกให้ reset assignee → เพิ่มที่นี่ - B1.3 — wire controller:
[HttpPatch("{id:int}/rework")](หรือref/{refNo}/reworkตาม A.1) →mediator.Send(new MarkTaskReworkCommand(id, request))→HandleResult(...). import command จาก03.Application(ห้ามนิยาม type ใน controller) - B1.4 — AutoMapper: เพิ่ม
TaskTransaction → MarkTaskReworkViewModelในTaskMappingProfile(reuse field เดิม) —dotnet testเขียว
B2 — complete + close endpoints (T3, idempotent) [blocked on A.1, A.3, A.4]
ไฟล์ใหม่: Features/Tasks/CompleteTask/ + Features/Tasks/CloseTask/ (แยก 2 feature — verb ต่างกัน, outcome ต่างกัน)
- B2.1 — (RED) test
CompleteTaskHandlerTests.cs:Handle_When_Open_Task_Marks_Completed→Progress == "COMPLETED"Handle_When_Already_Completed_Is_NoOp_Ok→ เรียกซ้ำ →IsSuccess, ไม่ throw, status คง"COMPLETED"(idempotent — A.3)Handle_When_TaskNotFound_*→ ตาม A.3 (404 หรือ no-op — เขียน test ตามที่ตกลง)
- B2.2 — (RED) test
CloseTaskHandlerTests.cs:Handle_When_Open_Task_Marks_Cancelled→Progress == "CANCELLED"(ค่าจาก A.4)Handle_When_Already_Closed_Is_NoOp_Ok→ ปิดซ้ำ = no-op 200 (idempotent — critical สำหรับ cancel double-sweep 00 §6)Handle_When_TaskNotFound_Returns_Ok_NoOp→ close บน task ที่ไม่มี = no-op 200 (teardown ทน “ยังไม่ถูกสร้าง” 00 §6) — ยืนยัน A.3
- B2.3 — (GREEN) CompleteTaskHandler — idempotent guard:
var task = _taskRepository.Query().FirstOrDefault(t => t.TaskID == request.TaskId && t.IsActive); if (task is null) return /* A.3: Result.Fail("ไม่พบ...") หรือ Result.Ok(no-op) */; if (task.Progress == "COMPLETED") // idempotent no-op return Result.Ok(_mapper.Map<...>(task)); task.Progress = "COMPLETED"; task.UpdatedDate = DateTime.UtcNow; /* + history entry "อนุมัติแล้ว" */ _taskRepository.Update(task); _unitOfWork.Commit(); return Result.Ok(_mapper.Map<...>(task)); - B2.4 — (GREEN) CloseTaskHandler — idempotent guard (terminal set จาก A.4):
var task = _taskRepository.Query().FirstOrDefault(t => t.TaskID == request.TaskId /* + IsActive? */); if (task is null) return Result.Ok<CloseTaskViewModel>(null!); // no-op 200 (A.3 teardown-tolerant) if (task.Progress is "CANCELLED" or "COMPLETED") // already terminal → no-op return Result.Ok(_mapper.Map<...>(task)); task.Progress = "CANCELLED"; task.UpdatedDate = DateTime.UtcNow; /* + history "ยกเลิก/ปฏิเสธ" + reason */ _taskRepository.Update(task); _unitOfWork.Commit(); return Result.Ok(_mapper.Map<...>(task));idempotency = re-read-then-guard (ไม่ใช่ unique index). ปลอดภัยพอสำหรับ single-writer-per-task ตาม saga; ถ้าทีม Task กังวล concurrent double-sweep จริง → พิจารณา optimistic concurrency (xmin) เป็น follow-up — อย่าเพิ่มถ้าไม่ได้ขอ
- B2.5 — wire controller 2 endpoints:
[HttpPatch("{id:int}/complete")],[HttpPatch("{id:int}/close")](path ตาม A.1). request body:CloseTaskRequest { string? Reason }สำหรับ close (reject/cancel reason); complete อาจไม่มี body - B2.6 — AutoMapper mappings ทั้ง 2 ViewModel —
dotnet testเขียว
B3 — GET /ref → 404 (T4) [blocked on A.2 — อาจ "ไม่ทำ"]
- B3.1 — เงื่อนไข: ทำ เฉพาะ ถ้า A.2 = (b) เปลี่ยนเป็น 404. ถ้า A.2 = (a) คงเดิม หรือ (c) ตัด T4 → ข้าม task นี้ทั้งหมด
- B3.2 — (RED) แก้ test
GetTaskByRefNoHandlerTests.Handle_When_NotFound_*: เปลี่ยน expectation จากOk+null→IsFailed+ message contains “ไม่พบ” (เพื่อให้HandleResultmap 404) - B3.3 — (GREEN)
GetTaskByRefNoHandler:40-44: เปลี่ยนreturn Result.Ok<GetTaskByRefNoViewModel>(null!);→return Result.Fail($"ไม่พบ Task ที่มี RefNo: {request.RefNo}");⚠️ breaking change — แตะ contract เดิมที่ IOS/FE อาจพึ่ง (comment ในโค้ดระบุ “IOS design: return Ok with null”). ต้องได้ A.2 ยืนยันว่าไม่มี consumer อื่นพังก่อน
Test checklist (จาก 04-taskservice.md)
- create คืน TaskID — ✅ มี test แล้ว (
CreateTaskHandlerTests), แค่ยืนยัน orchestrator parse path (A.1) - mark-rework เปลี่ยนสถานะถูก (ไม่ปิด task) — B1.1
- complete / close แยกผลถูก — B2.1, B2.2
- close idempotent (ปิดซ้ำ = no-op 200) — B2.2
- GET by ref miss → 404 (ถ้าตกลงตาม T4/A.2) — B3.2
Success criteria
dotnet test Task05.Tests.csprojเขียวทั้งหมด (เดิม + ใหม่)- 3 endpoints ใหม่ (mark-rework, complete, close) ตอบตาม contract ที่ A.1-A.5 lock
- close + complete idempotent (re-call = 200 no-op) — มี test ครอบ
- ทุก type (Command/Request/Response) อยู่ใน
03.Application/Features/Tasks/{Name}/— controller import เท่านั้น (กฎ Clean Architecture) - ไม่มี migration (ใช้
Progressstring column เดิม) — ยืนยัน A.4 ว่าไม่ต้องขยายคอลัมน์/ไม่ชน filter
หมายเหตุ Clean Architecture (กฎ global)
- Command/Query record + Response record ต้อง อยู่ใน
03.Application/Features/Tasks/{Name}/— ห้ามอยู่ใน controller - Controller pattern: รับ request →
mediator.Send()→HandleResult(result)(ห้าม business logic) - ไม่มี repository interface ใหม่จำเป็น — reuse
ITaskRepository.Query()(อยู่ใน04.Domainแล้ว ✅)