Private Docs

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 modelfree-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 hintCreateTaskRequest.IsRework (bool) มีอยู่ แต่ ไม่ถูก persist (entity ไม่มี field นี้)CreateTask/CreateTaskRequest.cs
Lock flagTaskTransaction.IsLocked (bool)Entities/TaskTransaction.cs
Repo lookupITaskRepository.QueryWithWorkType() / .Query() → LINQ FirstOrDefault(t => t.RefNo == x && t.IsActive) หรือ t.TaskID == idPorts/Persistence/ITaskRepository.cs
404 mappingHandleResult map error message ที่มีคำว่า "not found"/"ไม่พบ" → HTTP 404; อื่น ๆ → 400Controllers/TaskApiControllerBase.cs
Route baseapi/task-service/v{version:apiVersion}/tasksControllers/v1/TaskController.cs
Test cmddotnet test C:/Source/AzureDevOps_SuperAPP/Backend_TaskService/tests/Task05.Tests/Task05.Tests.csprojnet10.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 parse TaskID จาก TaskBaseResponse<CreateTaskViewModel>.Result.TaskID
    • mark-rework (M10): เสนอ PATCH api/task-service/v1/tasks/ref/{refNo}/rework หรือ by-id PATCH .../tasks/{id:int}/rework — เลือกอันเดียว (ดู A.3 key)
    • complete (M11a): เสนอ PATCH .../tasks/{key}/complete
    • close (M11b): เสนอ PATCH .../tasks/{key}/close
    • decision needed: by refNo หรือ by taskId? (orchestrator ถือทั้งคู่: refNo เสมอ, TaskId หลัง create) — แนะนำ by-id เป็นหลัก, by-ref เป็น fallback เพราะ orchestrator มี TaskId ใน Correlations แล้ว
  • A.2 — GET /ref miss behavior (T4) ตกลงกับ orchestrator adapter (P3): เปลี่ยน miss จาก 200+null404 ไหม?
    • ถ้า 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 ทิ้ง
  • 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) — ต้องตกลง
  • A.4 — Task status model (string values) ยืนยันกับทีม Task — คำที่จะเขียนลง Progress column:
    • 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"} — ยืนยัน
  • 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_OpenProgress == "REWORK" (ค่าจาก A.4), IsActive == true, history entry เพิ่ม 1
    • Handle_When_TaskNotFound_Returns_FailIsFailed, message contains key (404 mapping)
    • ใช้ TestDbContextFactory.CreateWithSeed + EntityBuilders.BuildTask (มี helper อยู่แล้ว)
  • B1.2 — (GREEN) command + request + handler + viewmodel:
    // 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; } }
    
    Handler (pattern จาก 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_CompletedProgress == "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_CancelledProgress == "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+nullIsFailed + message contains “ไม่พบ” (เพื่อให้ HandleResult map 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

  1. dotnet test Task05.Tests.csproj เขียวทั้งหมด (เดิม + ใหม่)
  2. 3 endpoints ใหม่ (mark-rework, complete, close) ตอบตาม contract ที่ A.1-A.5 lock
  3. close + complete idempotent (re-call = 200 no-op) — มี test ครอบ
  4. ทุก type (Command/Request/Response) อยู่ใน 03.Application/Features/Tasks/{Name}/ — controller import เท่านั้น (กฎ Clean Architecture)
  5. ไม่มี migration (ใช้ Progress string 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 แล้ว ✅)