docs: add design spec for invest-asset self-service approval
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153wBt23EjW125ZsADwWVAs
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
29f2da97da
commit
f883f64c82
@@ -0,0 +1,161 @@
|
||||
# แผนความต้องการงบลงทุนค่าครุภัณฑ์ — เปลี่ยนเป็นผู้ส่งเลือกผู้อนุมัติเอง (Design)
|
||||
|
||||
**สถานะ:** ผ่าน design review แล้ว รอเขียน implementation plan
|
||||
**อ้างอิง:** `docs/superpowers/specs/2026-09-07-approve-state-workflow-reference.md` (การอนุมัติ 2 ลำดับ + การส่งอนุมัติ ของ code "04")
|
||||
|
||||
## 1. บริบทและปัญหา
|
||||
|
||||
หน้า **แผนความต้องการงบลงทุน ⟶ ค่าครุภัณฑ์** ที่ใช้งานจริงตอนนี้คือ
|
||||
`/app/durable-articles` (โฟลเดอร์
|
||||
`feature/budget-request/investment-budget/durable-articles/`) ใช้กลไกอนุมัติแบบ
|
||||
**แอดมินตั้งคิวล่วงหน้า** (`request_approve_type = "01"`) เท่านั้น: ตอนกด "ส่งอนุมัติ"
|
||||
(`GET /invest/invest_asset/send_approve/{uid}`) ระบบจะเรียก
|
||||
`ApproveStateService.getApproveState(..., "01", ...)` ซึ่งอ่านคิวจากตาราง config
|
||||
`request_approve_type` / `request_approve_type_state` ที่แอดมินต้องตั้งไว้ล่วงหน้าเป็น
|
||||
รายหน่วยงาน/พื้นที่ผ่านหน้า "จัดการเมนูการอนุมัติ" — ถ้าไม่ตั้งไว้ หรือหน่วยงานใหม่ยังไม่ถูก
|
||||
ตั้งค่า จะไม่มีผู้อนุมัติให้เข้าคิวเลย
|
||||
|
||||
ผู้ใช้ต้องการเปลี่ยนกลไกนี้ **ทั้งหมด** ให้เป็นแบบเดียวกับสรุปคำของบประมาณรายได้
|
||||
(`request_expense`, code `"04"`): **ผู้ส่งเป็นคนพิมพ์ชื่อผู้อนุมัติเองตอนส่งอนุมัติ**
|
||||
แทนที่การพึ่ง config แอดมินล่วงหน้า
|
||||
|
||||
## 2. ของเดิมที่ยังใช้ได้ (ไม่แตะ)
|
||||
|
||||
จากการอ่านโค้ดจริงของหน้านี้ พบว่ามีกลไกที่คล้ายกับ `is_sent` ของ request_expense อยู่แล้ว
|
||||
เพียงแต่ใช้ฟิลด์คนละตัว:
|
||||
|
||||
| request_expense (code "04") | invest_asset (ของเดิม) |
|
||||
|---|---|
|
||||
| `is_sent` (`false`/`null`→ยังไม่ส่ง, `true`→ส่งแล้ว) | `is_draft` (`true`→ยังไม่ส่ง/ฉบับร่าง, `null`→ส่งแล้ว) |
|
||||
| `SendApprove2()` เซ็ต `is_sent=true` | `SendApproveEntityId()` เซ็ต `is_draft=null` (มีอยู่แล้ว) |
|
||||
| `ApproveGetEntity`/`RejectGetEntity` ใช้ `t_approve_state` ร่วมกับ invest_asset/invest_construct | เหมือนกันเป๊ะ — เป็น controller เดียวกัน (`ApproveStateController`) |
|
||||
|
||||
**สิ่งที่ไม่ต้องแก้เพราะทำงานถูกต้องอยู่แล้ว:**
|
||||
- `SendApproveEntityId` (`Modules/Invests/Controllers/InvestAsset.cs:206`) — เช็คอยู่แล้วว่ามี
|
||||
`approve_state` แถวที่ผูกกับ `invest_asset_uid` นี้หรือยัง (`check_approve`) ถ้า**มีแล้ว**
|
||||
(เช่น มาจากการเรียก `SetCustomApprovers` ก่อนหน้า) จะ**ข้าม**การดึง config แอดมินให้เอง
|
||||
ไม่ต้องเขียนโค้ดเพิ่ม
|
||||
- `RejectGetEntity` (invest_asset branch) — รีสตาร์ทคิวกลับไป row_order 1 เสมอเมื่อถูกปฏิเสธ
|
||||
ทำงานได้เหมือนกันไม่ว่าคิวจะมาจากแอดมินตั้งหรือผู้ส่งตั้งเอง (อ่านจาก `t_approve_state` ตรงๆ)
|
||||
- `save_header_approve` / `save_chief_approve` / `save_dean_approve` ในฟอร์ม — เป็นการเซ็นภายใน
|
||||
หน่วยงานตัวเอง (fixed role, เซ็ตฟิลด์ `is_header_approve`/`is_chief_approve`/`is_dean_approve`
|
||||
ตรงบน entity) **คนละกลไกกับคิวอนุมัติกลาง ไม่เกี่ยวกัน ไม่แตะ**
|
||||
|
||||
## 3. ของใหม่ที่ต้องเพิ่ม
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
**a) `SetCustomApprovers` ใหม่ใน `InvestAssetService`**
|
||||
(`Modules/Invests/Services/InvestAsset.cs`) — mirror
|
||||
`RequestExpenseService.SetCustomApprovers` ทุกจุด:
|
||||
|
||||
```
|
||||
Task<(bool ok, string error)> SetCustomApprovers(
|
||||
Guid? investAssetUid, List<ApproverInput> approvers, ClaimsIdentity claimsIdentity)
|
||||
```
|
||||
|
||||
- โหลด `t_invest_asset` ตาม uid, ไม่พบ → error "ไม่พบรายการ"
|
||||
- Guard: `entity.is_draft != true` → error "รายการนี้ต้องอยู่ในสถานะฉบับร่างก่อนจึงจะกำหนด
|
||||
ผู้อนุมัติได้" (invest_asset ใช้ `is_draft==true` แทนความหมาย "ยังไม่ส่ง" ตรงข้ามกับ
|
||||
request_expense ที่ใช้ null/null — ต้องเทียบ semantics ให้ถูก ไม่ใช่ copy เงื่อนไขตรงตัว)
|
||||
- Guard: มีแถว `t_approve_state` ที่ `invest_asset_uid==uid && is_approve==true` อยู่แล้ว →
|
||||
error "รายการนี้เข้าสู่กระบวนการอนุมัติไปแล้ว"
|
||||
- รับ `List<ApproverInput>` (มี `personnel_uid`, `display_name_th`), กรองแถวว่างทิ้ง, ถ้าไม่มี
|
||||
เหลือเลย → error "กรุณาระบุผู้อนุมัติอย่างน้อย 1 คน"
|
||||
- ลบแถว `t_approve_state` เดิมของ invest_asset นี้ทั้งหมด (ถ้ามี, เผื่อกดกำหนดซ้ำก่อนส่ง),
|
||||
สร้างแถวใหม่ row_order 1..N, `is_state = (row_order==1)`, **`request_approve_type =
|
||||
<รหัสใหม่>`** (ดูข้อ 4)
|
||||
- เซ็ต `entity.current_approve_name` เป็นชื่อผู้อนุมัติคนแรก, save
|
||||
|
||||
หมายเหตุ: โค้ดต้นแบบ (`RequestExpenseService.SetCustomApprovers`) มี docstring บอกว่า
|
||||
"ห้ามระบุตัวเองเป็นผู้อนุมัติ" แต่จากการอ่านโค้ดจริงไม่มีการเช็คนี้ในฝั่ง backend
|
||||
(ปล่อยให้ UI เป็นคนกรองตอนเลือกจากรายชื่อ) — เวอร์ชันใหม่นี้จะ mirror พฤติกรรมจริงที่มีอยู่
|
||||
คือไม่เช็คใน backend เช่นกัน เพื่อความสอดคล้องกัน ไม่ใช่ bug ใหม่ที่เพิ่มเข้ามา
|
||||
|
||||
**b) Endpoint ใหม่**
|
||||
`POST /api/invest/invest_asset/{uid}/set_approvers` ใน `InvestAssetController` เรียก
|
||||
`SetCustomApprovers` ข้างบน, คืน 200 + error message ถ้าไม่ผ่าน guard (mirror ทรงเดียวกับ
|
||||
endpoint ของ request_expense)
|
||||
|
||||
**c) ปิดช่องโหว่ใน `ApproveGetEntity` (approve endpoint ที่ใช้ร่วมกัน)**
|
||||
|
||||
ที่ `Modules/Setting/Controllers/ApproveState.cs`, invest_asset branch (บรรทัด ~50-86)
|
||||
ปัจจุบันไม่มีการเช็คว่าใครเป็นคนกด และไม่เช็คว่า "ส่งอนุมัติ" แล้วหรือยัง — เพราะของเดิม
|
||||
approve_state จะถูกสร้างพร้อมกับตอนกด "ส่งอนุมัติ" เท่านั้น (ไม่มีช่วงเวลาที่แถวมีอยู่ก่อน
|
||||
ส่งจริง) แต่พอเพิ่ม `SetCustomApprovers` ที่สร้างแถว (พร้อม `is_state=true` ที่แถวแรก) **ก่อน**
|
||||
กด "ส่งอนุมัติ" จะเกิดช่องโหว่ทันที: ใครก็เรียก `GET approve/{uid}` อนุมัติแทนได้เลยตั้งแต่
|
||||
ยังไม่กด "ส่งอนุมัติ" ด้วยซ้ำ
|
||||
|
||||
ต้องเพิ่ม guard แบบเดียวกับที่ `request_expense` code "04" มี (บรรทัด 133-150 เดิม) แต่สลับให้
|
||||
ตรงกับ semantics ของ `is_draft`:
|
||||
|
||||
```csharp
|
||||
if (invest_asset != null && getResult != null)
|
||||
{
|
||||
if (getResult.row_order == 1 && getResult.request_approve_type == "<รหัสใหม่>")
|
||||
{
|
||||
if (invest_asset.is_draft == true)
|
||||
{
|
||||
return BadRequest(new { description = "ยังไม่ได้ส่งอนุมัติ กรุณากด \"ส่งอนุมัติ\" ก่อน" });
|
||||
}
|
||||
var currentDisplayName = (User.Identity as ClaimsIdentity)?.FindFirst("display_name")?.Value;
|
||||
if (string.IsNullOrEmpty(currentDisplayName) || currentDisplayName != getResult.display_name_th)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden,
|
||||
new { description = "ไม่มีสิทธิ์อนุมัติ (ต้องเป็นผู้ที่ถูกกำหนดเป็นผู้อนุมัติลำดับ 1 เท่านั้น)" });
|
||||
}
|
||||
}
|
||||
// ... โค้ดเดิมที่เหลือ (getResult.is_state=false, is_approve=true, ฯลฯ) ไม่เปลี่ยน
|
||||
}
|
||||
```
|
||||
|
||||
เช็คนี้ทำเฉพาะ `row_order==1` เหมือน "04" เป๊ะ (ลำดับถัดไปไม่เช็ค ตาม pattern เดิม
|
||||
ที่มีอยู่แล้วในระบบ — ไม่ใช่ scope ของงานนี้ที่จะไปแก้ pattern เดิม)
|
||||
|
||||
`send_approve` (`SendApproveEntityId`) **ไม่ต้องแก้** ตามที่อธิบายในข้อ 2
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
**a) Dialog ใหม่ "ระบุผู้อนุมัติ" สำหรับครุภัณฑ์**
|
||||
|
||||
Copy โครงจาก `feature/income-report/presenter/popup/set-approvers-dialog/` มาสร้างใน
|
||||
`feature/budget-request/investment-budget/durable-articles/presenter/popup/set-approvers-dialog/`
|
||||
(หรือ path เทียบเท่าในโมดูลเดียวกับฟอร์มครุภัณฑ์) ปรับ:
|
||||
- แหล่งข้อมูลบุคลากรให้ตรงกับที่ใช้ในหน้าครุภัณฑ์ (เดิม `AgencyService.getHr()`) — คงไว้ ใช้ตัวเดิม
|
||||
- **2 ลำดับตายตัวเหมือน request_expense**: `approverLabels = ['หัวหน้าหน่วยงาน', 'รองอธิการบดี']`
|
||||
(ยืนยันกับผู้ใช้แล้วว่าใช้ชุดเดียวกัน ไม่ต้องออกแบบ label ใหม่) — ลำดับแรก required,
|
||||
ลำดับสอง optional เหมือนต้นแบบ (`this.approverForm(..., required=true)` /
|
||||
`(..., required=false)`)
|
||||
|
||||
**b) แก้ `durable-articles-form.component.ts`**
|
||||
|
||||
`save_approve()` (บรรทัด 734) ปัจจุบันเรียก `.put()` แล้วต่อด้วย `send_approve` ตรงๆ — เปลี่ยน
|
||||
เป็น: เปิด dialog ระบุผู้อนุมัติก่อน (ถ้ายังไม่เคยกำหนด) → เรียก `set_approvers` endpoint ใหม่
|
||||
→ ต่อด้วย `send_approve` เดิม (concatMap ต่อกัน คล้ายที่ income-report ทำ)
|
||||
|
||||
**c) ไม่แตะ:** `save_header_approve/save_chief_approve/save_dean_approve/save_president_approve`,
|
||||
`save_draft`, `save_draft_as`, `add_approve` — เป็นคนละกลไก/คนละปุ่ม
|
||||
|
||||
## 4. รหัส `request_approve_type` ใหม่
|
||||
|
||||
โค้ด `"01"`/`"02"`/`"03"`/`"04"` ถูกใช้แล้ว (invest_asset เดิม / invest_construct /
|
||||
request_expense เดิม / request_expense self-service) ต้องเลือกรหัสใหม่ที่**ไม่ชนกับข้อมูล
|
||||
จริงในตาราง** `approve_state`/`request_approve_type` — ขณะออกแบบนี้ต่อฐานข้อมูล production
|
||||
ไม่ได้ชั่วคราว (connection timeout ที่ port 31620) **จะเช็คแบบสดตอนเริ่ม task แรกของแผน
|
||||
implementation ก่อนเขียนโค้ดจริง** (`SELECT DISTINCT request_approve_type FROM approve_state`
|
||||
+ ดูตาราง `request_approve_type`) แล้วเลือกรหัสว่างจริง (คาดว่าจะเป็น `"05"` แต่ต้องยืนยันก่อน)
|
||||
|
||||
## 5. Backward compatibility
|
||||
|
||||
- คำขอครุภัณฑ์เก่าที่มี `approve_state` แบบ `request_approve_type="01"` ค้างอยู่ (กำลังอยู่
|
||||
ระหว่างอนุมัติ) **ไม่ถูกแตะ** — เดินหน้าอนุมัติ/ปฏิเสธต่อได้ตามปกติ เพราะ guard ใหม่ในข้อ 3.1(c)
|
||||
เช็คเฉพาะรหัสใหม่เท่านั้น
|
||||
- โค้ด `getApproveState(...,"01",...)` และตาราง config เดิมยังอยู่ในระบบ (ไม่ลบ) แต่จะไม่มีใคร
|
||||
ตั้งค่าใหม่อีกต่อไปเพราะ frontend บังคับให้ต้องกำหนดผู้อนุมัติเองก่อนส่งเสมอ — ตรงตามที่ผู้ใช้
|
||||
ต้องการ ("แทนที่ไปเลย")
|
||||
|
||||
## 6. Testing
|
||||
|
||||
ตาม methodology เดียวกับที่ใช้ทำ income-budget-review-stage ใน session นี้: สร้างข้อมูลทดสอบ
|
||||
สังเคราะห์ (`test.verify`) ผ่าน SQL, ยิง endpoint จริงด้วย JWT ทดสอบ, ตรวจผลผ่าน SQL,
|
||||
ลบข้อมูลทดสอบทิ้งหลังตรวจเสร็จ — ไม่มี automated test suite ในโปรเจกต์นี้
|
||||
Reference in New Issue
Block a user