From d1709040f80749221e654c8c895f1a2c58def6c7 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Sun, 12 Jul 2026 14:19:19 +0700 Subject: [PATCH] wal: flush 20260712T1419 --- .claude/WAL.md | 19 +-- .../run.md | 23 +++- .../sub-invest-A.md | 66 +++++++++++ .../sub-invest-B.md | 85 ++++++++++++++ .../sub-invest-C.md | 75 ++++++++++++ .../sub-reviewer.md | 108 ++++++++++++++++++ 6 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 .claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-A.md create mode 100644 .claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-B.md create mode 100644 .claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-C.md create mode 100644 .claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-reviewer.md diff --git a/.claude/WAL.md b/.claude/WAL.md index f81901a..79e4c1d 100644 --- a/.claude/WAL.md +++ b/.claude/WAL.md @@ -3,15 +3,16 @@ updated: 2026-07-12 | session: S112 | branch: main goal: 2 mạch song song — (A) Supplier Excel-import BUILD + close-spec · (B) PE sign-off save-advance INVESTIGATE chain A (Supplier import): -[x] investigate wf_e2f23a84-933 PASS_WITH_CONCERNS + anh chốt 5 decision (+2field/Code=col4/A-scoped/fill-nulls/Type→NCC) -[!] BE (implementer-backend BG a4cde85): entity+Mig63 + import service (case-norm Code + fill-nulls upsert + Type-NCC + all-or-nothing) + endpoint preview/confirm + DTO -[ ] test-specialist test-before → FE fe-admin upload/preview/confirm -[ ] /fable-clone review đóng-spec (Opus MAX) → fan-out Opus adjust nếu-có → ĐÓNG SPEC + commit + cicd + harvest +[x] investigate wf_e2f23a84-933 + anh chốt 5 decision +[x] BE DONE (a4cde85): entity+Mig63(3-file)+SupplierExcelImportService+2 endpoint preview/confirm+DTO+DI; `dotnet build` EXIT-0 ✅; case-fix verified (OrdinalIgnoreCase preview+confirm) + fill-nulls + all-or-nothing + Type→NCC OK +[!] test-specialist (BG aac55f3d) test-before case-collation+fill-nulls+all-or-nothing+parser +[ ] FE fe-admin upload/preview/confirm (admin-only) +[ ] /fable-clone review đóng-spec (Opus MAX) → adjust (⚠️ #1 = ExpectedHeaderTokens best-guess → 30 real token đã extract từ Excel, bake vào; header hiện reject file thật) → ĐÓNG SPEC + commit + cicd + harvest chain B (PE sign-off): -[!] /fable-clone invest/review ensemble (Opus MAX, wf_pending) — creator-comment-own-box→save→advance + reject-return-to-commenter; RUN-TRACE runs/2026-07-12-S112-pe-signoff-save-advance -[ ] em synthesize PE → design + option cho anh quyết (KHÔNG code tới khi anh chốt) +[x] ensemble wf_fd8f2bfe-9f6 PASS_WITH_CONCERNS 4/4 — root=ApplyDrafterBypassOnSubmitAsync (S60 feature) auto-ký-hộ ô creator+comment-canned; fix=gỡ/gate bypass 1-chỗ, 0-đổi-ApproveV2Async, FE~0, giữ 1-nút; reject=Assignee-mode-đã-gần-đủ; +EDGE-5 mở-lại-DaDuyet MAJOR pre-existing reachable +[!] anh Kiệt QUYẾT 6 decision: (1)A1/A2-bypass (2)guard-B2-self-finalize (3)EDGE-5-fix (4)1nút/2nút (5)stale-comment (6)migrate-data-cũ → KHÔNG code PE tới khi chốt -next: chờ BE(a4cde85) + PE-ensemble land → synthesize từng cái; KHÔNG deploy/code-PE tới khi review/anh-chốt -verify: Supplier build PASS+Mig63+case-norm · PE run-folder spec+synthesis non-zero + reject-flow mapped -side: harness_floor 30K→100K (anh) + measured 21K→55K (S112 re-measure sister-parity ~60-71K) +next: chờ test-specialist(aac55f3d) land → FE Supplier → close-review; PE chờ anh chốt 6 decision. KHÔNG deploy/code-PE tới khi review+anh-chốt +verify: Supplier build EXIT-0 (DONE) + test PASS + header-token-baked ; PE run-folder spec+4sub+synthesis non-zero (DONE) +side: harness_floor 30K→100K + measured 21K→55K (S112) diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/run.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/run.md index 4e4cf23..ef368b5 100644 --- a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/run.md +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/run.md @@ -18,5 +18,24 @@ ## deliverable Design-doc: luồng hiện tại + thay đổi BE/FE cần làm + reject-flow + risk → **anh quyết** design + scope. KHÔNG code lượt này. -## synthesis -_(pending — em-main ghi sau khi Workflow return)_ +## synthesis (em-main — wf_fd8f2bfe-9f6, 4/4 clean, Opus 4.8) + +> sub-file naming = journal-order ≠ lane-order: `sub-invest-B`=Lane A (current-flow) · `sub-invest-C`=Lane B (change-design) · `sub-invest-A`=Lane C (reject) · `sub-reviewer`=reviewer. + +**REVIEWER VERDICT: PASS_WITH_CONCERNS.** 3 lane khớp source thật 100% (mọi file:line verify đúng). Thiết kế sound, 0 vỡ 5-state. + +### Cốt lõi (bất ngờ tốt): yêu cầu = sửa 1 CHỖ, cơ chế đã có sẵn +- "comment-in-own-box → save → advance" ĐÃ tồn tại trong dialog Duyệt (`ApproveV2Async` UPSERT opinion `:763-790` + advance `:927`, cùng 1 call). +- 🔴 ĐIỂM CHẶN = **`ApplyDrafterBypassOnSubmitAsync`** (`PurchaseEvaluationWorkflowService.cs:571-681`, gọi `:260-261`): lúc creator (=DrafterUserId=PRO) submit, nó TỰ ký hộ ô Cấp-1 bằng comment CANNED "(duyệt tự động…)" `:619` + advance qua → creator mất ô comment thật của mình (đúng cái screenshot Cấp-1 Bình muốn). +- 🔴 Đây là feature UAT **S60** (anh Kiệt cũ: "TP tạo thì bypass — NV khỏi duyệt lại"). Yêu cầu mới **NGƯỢC lại** → **đảo hành vi S60, cần sign-off**. +- **FIX:** gỡ/gate bypass → pointer đứng `(0,1)` → creator gõ comment thật + Duyệt → `ApproveV2Async` lo phần còn lại. **0 đổi ApproveV2Async · FE ~0**. Giữ **1-nút** (save=advance); 2-nút draft = thừa (cần schema+endpoint+DTO). + +### Reject (item 3): phần lớn ĐÃ có (mode `Assignee` — về NV đã ký, giữ ChoDuyet, re-comment UPSERT, forward). 3 gap: default=Drafter(full-restart) · phải admin bật Allow*-per-slot · rejecter pick tay. + +### Governance: creator-sign Cấp-1 = bước ĐỀ-XUẤT hợp lệ (CCM/CEO cấp trên mới approval thật, phân-tách nhiệm-vụ giữ). Lỗ CHỈ khi slot creator bật `AllowApproverFinalize`/`SkipToFinal` → self-finalize bỏ CEO (hiện 0 guard → đề-xuất guard B2, đã tồn-tại hôm nay qua bypass single-step-terminal). + +### 🔴 EDGE-5 (reviewer nâng latent→REACHABLE, MAJOR pre-existing): Drafter forge `Reject+TargetPhase=TraLai` trên phiếu **DaDuyet của chính mình** (đã chốt ApprovedPrice) → mở lại → sửa → gửi lại. Controller `:130-143` chỉ `[Authorize]` class-level + handler `FEAT:510-539` KHÔNG guard fromPhase + `EnsureCanRejectV2Async` early-return khi Phase≠ChoDuyet `:321`. Vùng reject-return CHÍNH LÀ scope sửa → nên bịt luôn (guard `fromPhase∈{DaDuyet,TuChoi}→throw`). + +### 6 quyết định owner (anh Kiệt): (1) A1/A2 gỡ-bypass · (2) guard-B2 self-finalize · (3) EDGE-5-fix · (4) 1-nút/2-nút · (5) stale-comment-khi-trả-lại · (6) migrate-data-cũ-bypass. + +**Harvest:** pending @closeout — investigator-codebase + reviewer, stamp `[engine: fable-clone-ensemble · Opus-S112]`. diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-A.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-A.md new file mode 100644 index 0000000..fb3d5e4 --- /dev/null +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-A.md @@ -0,0 +1,66 @@ +**HEADLINE:** LANE C: Trả-lại V2 đã có 4 mode (Drafter/OneLevel/OneStep/Assignee); Assignee ĐÃ khớp ý owner "về đúng commenter → re-comment → đi tiếp" nhưng bị chặn 3 điều kiện: default=Drafter (full-restart), phải admin bật Allow*-per-slot, rejecter tự pick tay. Opinion KHÔNG BAO GIỜ bị xoá (UPSERT) → tồn tại comment-cũ-stale khi trả về. + +## LANE C — REJECT / TRẢ-LẠI (Phase 98) — luồng hiện tại vs mong muốn + +### 1. Entry point + guard (file:line) +- `PurchaseEvaluationsController.cs:130-143` POST `/{id}/transitions` → `TransitionPeBody` (`:343-355`: `TargetPhase, Decision, Comment, ReturnMode?, ReturnTargetUserId?, ...`) → `TransitionAsync`. +- `PurchaseEvaluationWorkflowService.cs:67-75` **Guard#1**: target=TraLai/TuChoi BẮT BUỘC `decision=Reject` (gotcha #45 — nếu FE gửi Approve → BE lỡ vào ApproveV2Async = "trả về nhưng vẫn duyệt"). +- `:84-89` **Guard#2**: **"Từ chối" (TuChoi) đã GỠ HẲN S60** (throw cả Admin). ⇒ đường reject DUY NHẤT còn lại = **Trả lại → Phase TraLai=98**. +- `:98` `EnsureCanRejectV2Async` (impl `:316-341`): chỉ approver Cấp hiện tại (`actor.Id == level.ApproverUserId`, discriminate OR-of-N `:337`) HOẶC Admin được Trả lại. Non-approver → Forbidden. **Early-return (KHÔNG throw) khi `Phase != ChoDuyet` `:321`** — nhớ điểm này cho EDGE-5. + +### 2. Reject branch = `:92-146` → 4 mode trong `ApplyReturnModeAsync :348-548` +`effectiveMode = returnMode ?? Drafter` (`:117`). Mode khả dụng gate per-slot bằng `curLevel.AllowReturn*` (`:397-410`, non-admin flag off → Conflict). + +| Mode | Phase sau | Pointer sau | Người NHẬN lại | ref | +|---|---|---|---|---| +| **Drafter** (default/fallback) | **TraLai=98** | **xoá (null,null)** + SLA null | Người soạn — gửi lại chạy **LẠI từ Bước0/Cấp1 (full restart)** | `:418-425` | +| OneLevel | giữ ChoDuyet | curLevel−1 (hết→prevStep max; Bước0Cấp1→reset 0/1 no-op) | approver Cấp liền trước | `:437-463` | +| OneStep | giữ ChoDuyet | prevStep, max level | approver Bước trước (cấp cuối) | `:465-483` | +| **Assignee** | giữ ChoDuyet | Step/Level của NV được pick | **1 NV cụ thể ĐÃ ký** (`returnTargetUserId`) | `:485-510` | + +- `:132-141` add `PurchaseEvaluationApproval` row (Decision=Reject, prefix `[Bước x — Cấp y]`) cho Lịch-sử-duyệt. +- V1 legacy (`ApprovalWorkflowId==null`) → luôn fallback Drafter (`:359-369`). + +### 3. Comment (LevelOpinion) khi trả về — **GIỮ, KHÔNG XOÁ** +- **Grep `.Remove(`/`RemoveRange` trên LevelOpinions toàn backend = 0 match.** Opinion key theo `ApprovalWorkflowLevelId` (slot workflow, ổn định qua reject). Re-approve = **UPSERT overwrite** cùng row (`ApproveV2Async:766-790`: comment+SignedAt+SignedByUserId). +- ⇒ Người nhận lại **re-comment ĐƯỢC**, nhưng bằng cách **DUYỆT LẠI** (comment gắn liền hành động Duyệt — Q1=1B). KHÔNG có bước "comment mà chưa forward". + +### 4. "Đi tiếp" forward lại +- Drafter: TraLai → Drafter sửa → resubmit (`TransitionAsync:148-152`: DangSoanThao|TraLai→ChoDuyet) → **restart Bước0/Cấp1**; mọi cấp duyệt lại lần lượt, UPSERT đè opinion. +- OneLevel/OneStep/Assignee: giữ ChoDuyet, pointer lùi; NV nhận re-approve `ApproveV2Async` → advance `:926-957` đi tiếp +1 cấp/bước, **re-pass (UPSERT đè) các cấp phía trước**. + +### 5. FE (`PeWorkflowPanel.tsx`, fe-user == fe-admin — diff rỗng) +- `:88-90` `signedApprovers` = distinct `evaluation.levelOpinions` ⇒ dropdown Assignee **chỉ hiện người ĐÃ KÝ**. +- `:523-598` picker CHỈ hiện mode được admin bật (`CurrentLevelOptions` DTO `PurchaseEvaluationDtos.cs:300`, build từ **slot Cấp hiện tại của rejecter** `PurchaseEvaluationFeatures.cs:1023-1039`). Không mode nào bật → **chỉ còn Drafter**. +- `:193-195` chỉ gửi `returnMode`/`returnTargetUserId` khi `isTraLaiAction`. + +--- + +## MONG MUỐN owner vs HIỆN TẠI — cần đổi gì +> Owner: reject → phiếu về **ĐÚNG người commenter** → họ **COMMENT LẠI** → **ĐI TIẾP forward**. + +**Kết luận cốt lõi: mode `Assignee` ĐÃ implement gần đủ ý owner** (về NV cụ thể đã ký, giữ ChoDuyet, re-comment qua UPSERT, forward tiếp từ slot đó). Requirement **phần lớn đã có sẵn** — nhưng bị 3 rào: + +| # | GAP | Vị trí | Đổi gì | +|---|---|---|---| +| G1 | **Default = Drafter (full-restart)**, KHÔNG khớp "về đúng người, đi tiếp từ đó" | `:117` `?? Drafter` + FE `:585` (Drafter luôn hiện) | Đổi default sang OneLevel/Assignee, HOẶC bắt admin bật Allow* cho slot. Cần owner chốt hành-vi-mặc-định. | +| G2 | **Rejecter tự pick tay** người nhận (dropdown) | FE `:571-580` + `Assignee:485-510` | Nếu owner muốn AUTO về "người liền trước" → dùng OneLevel (auto prev Cấp) làm mặc-định; nếu muốn auto về Cấp-1/creator → cần logic mới target=firstStep/drafter. | +| G3 | **Re-comment = phải DUYỆT** (comment gắn Duyệt), chưa có "comment-in-own-box rồi Save mới advance" | `ApproveV2Async:751,766-790` (add Approval Approve + advance cùng lúc) | = đúng điểm (2) của SPEC tổng (decouple save↔advance) — **cross-lane với Lane A/B**. Nếu tách: reject-return chỉ cần đưa pointer về đúng người; re-comment+save+advance dùng chung cơ chế mới. | + +**Việc cần làm (Lane C thuần):** (a) BE reject handler — quyết default mode + có auto-return-target không (G1/G2); (b) FE nút Trả lại — nếu default đổi thì bỏ ràng "phải admin bật Allow*" cho flow chuẩn này; (c) state — Assignee đã giữ ChoDuyet + lùi pointer đúng, KHÔNG cần cột DB mới cho luồng cơ bản. + +--- + +## ≥3 EDGE cases +1. **OR-of-N cùng Cấp bị reject:** Cấp có N approver (N row cùng Order); `ApproveV2Async:763-764` chỉ 1 người ký → chỉ 1 opinion row/Cấp; advance khi BẤT KỲ 1 người ký (`:927`). ⇒ `signedApprovers` chỉ liệt kê người-đã-ký; N−1 đồng-cấp KHÔNG ký thì **không pick được làm return-target**. Nếu owner muốn trả về đúng 1 co-approver chưa ký → không có trong dropdown. +2. **Reject nhảy NHIỀU cấp/bước:** OneLevel chỉ lùi 1 Cấp, OneStep 1 Bước. Nhảy từ Bước3 về Bước1-Cấp1 trong 1 thao tác **bắt buộc Assignee** (và target phải có LevelOpinion). Khi forward lại, mọi cấp trung gian **vẫn hiện opinion CŨ (pass đầu) cho tới khi được re-approve đè** → người xem tưởng đã ký hợp lệ. +3. **Comment cũ (stale) khi bị trả về:** Opinion KHÔNG bao giờ clear. Drafter-mode full-restart mà pass-2 lại bị reject giữa chừng → các cấp phía sau vẫn giữ opinion **pass-1 (stale)**. FE `levelOpinions` không phân biệt "ký vòng này" vs "ký vòng trước" (không có round/version stamp). ⇒ owner có thể muốn **clear opinion phía-trước-pointer khi trả về**, hoặc thêm cột round/superseded. +4. **Reject-loop vô hạn:** KHÔNG có cap số lần reject. Drafter resubmit ↔ reject vô hạn. Mỗi vòng thêm Approval+Changelog row (audit phình) nhưng opinion UPSERT (không nổ row). Không phải bug data nhưng **thiếu guard giới-hạn/escalation**. +5. **Phiếu đã DaDuyet (terminal) — LATENT HOLE:** DaDuyet xoá pointer (null,null). Reject branch (`:92`) chạy khi `decision==Reject` **bất kể fromPhase — KHÔNG có guard `fromPhase==DaDuyet`**; adjacency-check (`:300`) bị reject-branch bỏ qua hoàn toàn; `EnsureCanRejectV2Async` early-return vì `Phase!=ChoDuyet` (`:321`). ⇒ 1 request forge `Reject+target=TraLai` từ DaDuyet + Drafter-mode sẽ **MỞ LẠI phiếu DaDuyet về TraLai**. FE không gửi (không render nút khi `currentApproval==null`) nhưng BE **thiếu defense-in-depth**. (3 pointer-mode kia sẽ throw Conflict do pointer null `:429-433` — chỉ Drafter mode lọt.) + +## Open Questions +1. **Hành vi mặc định khi Trả lại** (G1): giữ Drafter=full-restart, hay đổi default sang "về người liền trước / về đúng commenter"? Ảnh hưởng có cần đổi `?? Drafter` (`Service:117`) + bỏ ràng buộc admin-phải-bật-Allow* per-slot. +2. **Ai chọn người nhận** (G2): rejecter pick tay (như Assignee hiện tại) hay AUTO về canonical (Cấp liền trước = OneLevel / hay về Cấp-1 creator)? Nếu auto-về-creator cần logic target mới. +3. **Comment cũ khi trả về** (EDGE-3): GIỮ opinion cũ (hiện tại) hay CLEAR các cấp phía-trước-pointer để buộc ký lại sạch? Hay thêm round/version-stamp để phân biệt vòng ký? — quyết định này chạm schema (có thể cần cột mới trên LevelOpinion). +4. **Re-comment tách khỏi Duyệt** (G3): owner có muốn "comment-in-own-box → Save riêng → mới advance" (điểm 2 SPEC tổng) áp cho CẢ luồng reject-return không? Đây là cross-lane với Lane A/B (decouple save↔advance) — nếu có thì reject-return chỉ lo đưa pointer về đúng người. +5. **Giới hạn reject-loop** (EDGE-4) + **chặn reject phiếu DaDuyet** (EDGE-5): có cần thêm guard `fromPhase==DaDuyet → throw` và/hoặc cap số lần trả lại không? (bảo mật/toàn vẹn). diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-B.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-B.md new file mode 100644 index 0000000..9d730b3 --- /dev/null +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-B.md @@ -0,0 +1,85 @@ +**HEADLINE:** Luồng hiện tại: ApproveV2Async làm UPSERT-opinion + advance-pointer trong CÙNG 1 call ("save = đẩy thẳng"). Điểm CHẶN "creator comment ô của mình rồi save" = `ApplyDrafterBypassOnSubmitAsync` — lúc SUBMIT nó TỰ ký Cấp-1 của creator bằng comment canned + nhảy pointer qua luôn. + +## LANE A — Luồng hiện tại (create → sign-off → advance) + +Tất cả file:line từ `PurchaseEvaluationWorkflowService.cs` (viết tắt `SVC`), `PurchaseEvaluationFeatures.cs` (`FEAT`), `PurchaseEvaluationsController.cs` (`CTRL`), `ApprovalWorkflow.cs` (`AW`). + +### Sơ đồ luồng (file:line từng bước) + +``` +[CREATE] CreatePurchaseEvaluationCommandHandler (FEAT:61-160) + Phase = DangSoanThao FEAT:139 + DrafterUserId = currentUser.UserId FEAT:147 ← creator == DrafterUserId (dùng làm ID logic) + ApprovalWorkflowId = request.ApprovalWorkflowId (pin V2) FEAT:149 (user chọn ở PeWorkspaceCreateView:176-177) + CurrentWorkflowStepIndex = null, CurrentApprovalLevelOrder = null (không set) + → Phiếu Nháp, CHƯA có pointer. + +[SUBMIT] POST /transitions (CTRL:130-143) → TransitionAsync, nhánh Drafter trình (SVC:150-265) + Guard: reject/tuchoi (SVC:67-89) · Section-3 đủ data (SVC:175-229) · require-workflow (SVC:238-241) + Phase = ChoDuyet SVC:243 + CurrentWorkflowStepIndex = 0 SVC:249 + CurrentApprovalLevelOrder = 1 (vì V2 pin) SVC:251 + ►► ApplyDrafterBypassOnSubmitAsync(...) ◄◄ SVC:260-261 ***ĐIỂM CHẶN*** + +[BYPASS] ApplyDrafterBypassOnSubmitAsync (SVC:571-681) — V2-only, chạy MỌI lần submit + drafterSlots = firstStep.Levels WHERE ApproverUserId == DrafterUserId SVC:589 + if drafterSlots rỗng → return (creator NGOÀI bước đầu → flow thường) SVC:590 + k = max Order của drafter slots SVC:592 + ∀ order 1..k: + Approval row Decision=AutoApprove SVC:603-614 + + UPSERT LevelOpinion comment CANNED "(duyệt tự động — SVC:619, 629-641 + người soạn phiếu là người duyệt cấp này)" + Advance pointer: kk SVC:647-656 + còn Bước → (Step 1, Cấp 1) SVC:657-666 + hết → Phase=DaDuyet terminal SVC:667-680 + +[SIGN-OFF] POST /transitions decision=Approve → ApproveV2Async (SVC:686-958) + currentIdx = CurrentWorkflowStepIndex ?? 0 SVC:711 + currentLevelOrder = CurrentApprovalLevelOrder ?? 1 SVC:715 + pendingLevelGroup = Levels Order==currentLevelOrder (OR-of-N) SVC:724 + GATE (SVC:728-739): !admin → actor.Id ∈ pendingLevelGroup.ApproverUserId else 403 Forbidden + + Approval row SVC:745-754 + ►► UPSERT LevelOpinion (matchingLevel theo ApproverUserId, comment sync) SVC:763-790 + (skipToFinal SVC:805-846 · AllowApproverFinalize SVC:867-886 · CCM-deleg SVC:888-924) + ►► ADVANCE pointer SVC:927-957: currentLevelOrder `Steps` OrderBy Order (AW:70-78, Bước=Phòng) > `Levels` group by Order (AW:83-88, Cấp) > `ApproverUserId` (AW:88, 1 NV/row, N rows cùng Order = OR-of-N). Cấp-1 Bước-1 = `steps[0].Levels WHERE Order==1`; approver = tập ApproverUserId đó. + +### 3. Creator (PE.CreatedBy) = approver Cấp-1 được không? Có gate actor.Id==ApproverUserId chặn không? +- `DrafterUserId == creator == CreatedBy` (set chung lúc create FEAT:147). Service dùng **DrafterUserId** (KHÔNG dùng CreatedBy) cho mọi logic identity. +- **ĐƯỢC** — không có luật nào cấm creator làm approver. Ngược lại hệ thống có nhánh RIÊNG cho đúng case này: `ApplyDrafterBypassOnSubmitAsync`. +- Gate `actor.Id==ApproverUserId` tại SVC:728-739 là gate **THUẬN** (chỉ approver của Cấp mới được duyệt) — nó KHÔNG chặn creator; nếu pointer đang ở Cấp của creator thì gate này CHO PHÉP. **Đây KHÔNG phải điểm chặn.** + +### 4. Sau create/submit phiếu ở Phase nào + CurrentApprovalLevelOrder nào (đã nhảy qua Cấp-1 chưa?) +| Mốc | Phase | Pointer (Step, Cấp) | +|---|---|---| +| Sau CREATE | DangSoanThao | (null, null) | +| Sau SUBMIT, creator KHÔNG phải Cấp-1 approver | ChoDuyet | (0, 1) — chờ Cấp-1 | +| **Sau SUBMIT, creator LÀ Cấp-1 approver (case spec)** | ChoDuyet (hoặc DaDuyet nếu 1-bước) | **(0, k+1) hoặc (1,1)** — **ĐÃ NHẢY QUA Cấp-1** | + +Trong case mục tiêu, pointer **đã vượt Cấp-1** ngay tại submit, và ô C;ấp-1 của creator đã bị auto-fill comment canned (SVC:619). + +### 5. ĐIỂM CHẶN (chốt) +**`ApplyDrafterBypassOnSubmitAsync` (SVC:571-681, gọi tại SVC:260-261).** Vì lúc SUBMIT nó (a) auto-ký Cấp-1 của creator bằng comment CANNED thay vì comment thật (SVC:619), và (b) advance pointer qua Cấp-1 (SVC:647-680), nên creator: +- KHÔNG bao giờ gõ được ý kiến của mình vào ô của mình (ô đã auto-fill); +- KHÔNG còn thao tác được ở Cấp-1 — pointer đã dời. FE `actorInV2Level` = false → `blockedByV2Level` (PeWorkflowPanel.tsx:98-103); BE gate SVC:728-739 cũng 403 vì pointer không ở Cấp của họ. + +Nguồn gốc: đây là feature UAT S60 (anh Kiệt trước đó: "Trưởng phòng tạo thì bypass — không cần NV duyệt lại", comment SVC:550-570). Nay yêu cầu mới NGƯỢC lại. + +**Quan trọng — sửa 1 chỗ là đủ:** nếu vô hiệu/không chạy bypass (để pointer giữ (0,1) sau submit cho creator-approver), luồng MONG MUỐN chạy được NGAY với **ZERO thay đổi trong ApproveV2Async** — logic "comment-in-own-box → save → advance" ĐÃ có sẵn (UPSERT SVC:763-790 + advance SVC:927). ApproveV2Async vốn hỗ trợ đúng hành vi spec cần. + +FE xác nhận: `currentApproval.approvers` = approver tại pointer HIỆN TẠI (resolve giống `ResolveV2InboxIdsAsync` FEAT:786-821, match `l.Order==CurrentApprovalLevelOrder && ApproverUserId==userId`). Sau bypass đó là Cấp-2 → FE ẩn nút Duyệt của creator. + +### 6. Reject/Trả lại → trả về đúng người đó comment lại rồi forward (spec item 3) +Primitive ĐÃ tồn tại qua 4 mode `ApplyReturnModeAsync` (SVC:348-548). 3 mode OneLevel/OneStep/Assignee giữ Phase=ChoDuyet + lùi pointer về Cấp trước → người đó duyệt lại (UPSERT ghi đè opinion cũ SVC:784-790) rồi forward tiếp. Mode Assignee (SVC:485-510) target bất kỳ NV đã ký. Nhưng lưu ý: Cấp-1 của creator một khi bị bypass mang comment auto, không phải comment thật. + +## Open Questions +1. **Xung đột trực tiếp với UAT S60:** bypass drafter (anh Kiệt cũ: "Trưởng phòng tạo thì bypass") NGƯỢC với yêu cầu mới (creator/PRO comment ô của mình rồi save). Owner chốt: (a) bỏ hẳn bypass, (b) chuyển bypass thành opt-in/opt-out per-workflow hoặc per-level, hay (c) giữ bypass cho drafter-không-phải-Cấp-1 nhưng để creator-Cấp-1 comment tay. Đây là đảo ngược hành vi S60 — cần sign-off rõ. +2. Khi creator LÀ Cấp-1: submit nên để pointer ở (0,1) rồi creator bấm Duyệt riêng (2 click), hay gộp 1 action "submit + duyệt-Cấp-mình"? (1 click). +3. Data cũ: các phiếu đã submit trước đây có Approval row Decision=AutoApprove + LevelOpinion comment canned do bypass ghi — cần migrate/cleanup hay chỉ áp cho phiếu mới? +4. Nếu bỏ bypass, các quy trình mà TP/Trưởng phòng tự tạo sẽ phải tự bấm duyệt Cấp mình — xác nhận đây là UX chấp nhận được (đúng ý spec "comment vào ô duyệt CỦA CHÍNH HỌ"). diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-C.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-C.md new file mode 100644 index 0000000..82f026a --- /dev/null +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-invest-C.md @@ -0,0 +1,75 @@ +**HEADLINE:** Giữ "save = advance" (1 nút) — sửa đúng 1 chỗ BE: gỡ/gate ApplyDrafterBypassOnSubmitAsync để creator ĐÁP xuống ô Cấp-1 của mình tự comment+ký thay vì bị auto-bypass ký hộ; FE gần như 0 đổi. Self-approval = bước ĐỀ-XUẤT hợp lệ, CHỈ rủi ro nếu slot creator bật AllowApproverFinalize/SkipToFinal. + +## Kết luận cốt lõi +Yêu cầu "creator comment vào ô duyệt của chính họ → save → advance" **KHÔNG cần đổi save-semantics và KHÔNG cần tách 2 nút**. Cơ chế "comment-in-own-box → save → advance" đã tồn tại nguyên vẹn trong dialog Duyệt (`Textarea comment` → "Xác nhận" → `ApproveV2Async` UPSERT LevelOpinion + advance). Khiếm khuyết THẬT không nằm ở save-semantics mà ở **auto-bypass im lặng**: khi drafter (=PRO=Cấp-1) gửi duyệt, `ApplyDrafterBypassOnSubmitAsync` (service:571-681, gọi tại :260-261) **tự ký hộ** ô Cấp-1 với comment đóng hộp `"(duyệt tự động — người soạn phiếu là người duyệt cấp này)"` (:619) rồi advance qua luôn → creator KHÔNG bao giờ đáp xuống ô mình để gõ ghi-chú ngân-sách thật (đúng cái screenshot Cấp1 Bình muốn). + +## Sơ đồ luồng — AS-IS vs TO-BE +``` +AS-IS (auto-bypass nuốt Cấp-1 của creator): + Nháp --gửi duyệt--> ChoDuyet, pointer=(Step0,Cấp1) + └─ ApplyDrafterBypassOnSubmit: creator là Cấp1 → UPSERT opinion CANNED + "(duyệt tự động…)" + Approval row AutoApprove + advance → Cấp2 (Trà) + => creator MẤT ô comment của mình. Screenshot "Bình comment ngân-sách" KHÔNG đạt. + +TO-BE (creator tự ký ô mình): + Nháp --gửi duyệt--> ChoDuyet, pointer ĐỨNG tại (Step0, Cấp thấp nhất của creator) + => currentApproval populated → banner "✓ Đến lượt bạn duyệt" + nút "✓ Duyệt" + => creator mở dialog, gõ ghi-chú NS thật, "Xác nhận" + → ApproveV2Async UPSERT opinion (comment THẬT, SignedByUserId=creator) + advance → Trà + => Trà ký → Bước2 Bình lại ký → Chương "ok" … (đúng screenshot) +``` + +## Khuyến nghị save-semantics: GIỮ 1 NÚT ("save = advance") +| Tiêu chí | 1 nút (save=advance) — **KHUYẾN NGHỊ** | 2 nút (lưu nháp ý-kiến + submit) | +|---|---|---| +| Khớp yêu-cầu | ✅ dialog gõ comment → Xác nhận = "comment→save→advance" y hệt | Thừa — user không đòi giai-đoạn nháp | +| Chi phí | 1 method BE (gate bypass), FE ~0 | Schema mới (opinion cần state Draft / `SignedAt` nullable), endpoint mới, nút+handler FE mới, DTO field mới | +| Audit | Chữ-ký atomic (Approval row + opinion cùng lúc) | Nửa-trạng-thái "opinion chưa advance" làm rối reject/return + terminal | +| Sửa-lại-comment | Đã có: return→UPSERT overwrite (latest-write-wins, entity doc:17-19) | Không lợi thêm | + +"Lưu nháp rồi mới đẩy" đã có sẵn xuyên **chu-kỳ Trả-lại**: opinion tìm theo `(PEId, ApprovalWorkflowLevelId)` và bị OVERWRITE mỗi lần ký lại (service:766-790) → không cần draft-state riêng. + +## Bảng thay đổi BE +| # | File:line | Method/field | Đổi gì | +|---|---|---|---| +| B1 (chính) | `PurchaseEvaluationWorkflowService.cs:571-681` | `ApplyDrafterBypassOnSubmitAsync` | **NGỪNG tự ký ô CHÍNH CHỦ của creator.** Phương án **A1 (khuyến nghị, giữ S60 skip NV cấp dưới):** đổi `k=drafterSlots.Max` (:592) + `bypassedOrders = order<=k` (:595) → chỉ bypass `order < drafterSlots.Min` (thuần NV dưới creator); set pointer ĐỨNG tại `minOwnOrder` (thay `:647-680` advance qua k). Nếu creator là Cấp-1 (phổ biến/screenshot) → subordinate rỗng → pointer giữ (0,1), 0 auto-sign. **Phương án A2 (đơn giản nhất):** xóa call `:260-261` → creator luôn đáp (0,1) tự ký mọi cấp, NHƯNG mất skip-NV-cấp-dưới của S60. | +| B2 (governance guard — nên thêm) | `ApproveV2Async` ~:805 (skipToFinal) + ~:867 (`AllowApproverFinalize`) | thêm điều kiện `actorUserId == evaluation.DrafterUserId` | Chặn **creator TỰ kết-thúc/vượt-cấp phiếu mình**: nếu actor là người soạn mà slot bật finalize/skip → `throw Forbidden("Người soạn không được tự kết thúc/duyệt vượt cấp phiếu của mình — phải để cấp trên duyệt.")`. Đây là kiểm-soát self-approval THẬT (xem đánh giá dưới). | +| B3 (test — BẮT BUỘC, spec-change) | `tests/…/Services/PeSubmitGuardAndBypassTests.cs:28` | test bypass cũ | Cập-nhật kỳ-vọng: creator=Cấp1 sau submit → pointer ĐỨNG tại Cấp1 (không advance, không opinion canned); creator=Cấp2+NV-dưới → NV-dưới bypass, pointer=Cấp2 (rule §7 spec-change = update test cùng commit). | + +**Match-approver KHÔNG chặn creator:** `ApproveV2Async:728-739` yêu-cầu `actorUserId ∈ pendingLevelGroup.ApproverUserId`. Creator LÀ ApproverUserId của Cấp-1 mình → pass. Không cần đụng. + +## Bảng thay đổi FE (2 app byte-identical — diff exit 0, 903 dòng — sửa gì PHẢI mirror cả 2) +| # | File:line | Component/handler | Đổi gì | +|---|---|---|---| +| F0 | — | — | **Happy-path: 0 đổi chức-năng.** Banner `isV2Pending`+`actorInV2Level` (:98-103,:407-426) + nút Duyệt (:441,:449-472) + dialog comment→Xác nhận→`transition.mutate` (:153-207) đã chạy đúng khi pointer đứng ở ô creator. Nút Trả-lại/Từ-chối vốn ĐÃ ẩn cho creator (`:446` `drafterUserId===currentUser.id`); Duyệt KHÔNG ẩn. | +| F1 (polish, tùy chọn) | `PeWorkflowPanel.tsx:414-424` + dialog title `:492-496` | banner/label | Khi `drafterUserId===currentUser.id && actorInV2Level`: đổi chữ thành "Ý kiến của bạn (người soạn — Cấp {order})" để creator hiểu đang ký ô đề-xuất của mình. Thuần cosmetic. | + +## SignedByUserId vs ApproverUserId — ảnh hưởng +- `ApprovalWorkflowLevel.ApproverUserId` (design-time, entity:88) = NV được cấu hình. `LevelOpinion.SignedByUserId` (runtime, entity:31) = người ký thật. +- Creator ký ô mình → `SignedByUserId == ApproverUserId` → **KHÔNG** hiện banner "Admin duyệt thay" (chỉ hiện khi lệch, service:762). Sạch, không side-effect. +- TO-BE cho opinion THẬT: `SignedByUserId=creator, Comment=ghi-chú-NS-thật` — chữ-ký audit GENUINE, **tốt hơn** canned comment của auto-bypass. Đây là cải-thiện governance, không phải nới lỏng. + +## Đánh giá SELF-APPROVAL +**Là bước ĐỀ-XUẤT hợp lệ, KHÔNG phải lỗ-hổng kiểm-soát — với 2 điều kiện:** +1. Cấp-1 của creator = chữ-ký ĐỀ-XUẤT/chuẩn-bị (PRO trình phương-án + ngân-sách); thẩm-quyền DUYỆT thật nằm ở cấp trên (CCM Cấp-2, BGĐ/CEO Bước cuối). Creator **không thể** tự đẩy tới `DaDuyet` (cấp mình không phải terminal) → phân-tách nhiệm-vụ giữ nguyên. +2. Có vết audit đầy đủ: `PurchaseEvaluationApproval` row + `LevelOpinion.SignedByUserId=creator` phân-biệt rõ chữ-ký. + +**THÀNH lỗ-hổng CHỈ KHI** slot Cấp-1 của creator bật `AllowApproverFinalize` (F5, entity:142) hoặc `AllowApproverSkipToFinal` (F2, entity:131) → creator một mình duyệt phiếu mình lên `DaDuyet`, bỏ CEO. **Hiện KHÔNG có guard** chặn việc này (finalize/skip chỉ gate theo admin-config slot, không xét actor==drafter). → Đề-xuất guard B2. + +**Ghi nhận tích-cực:** chuyển từ auto-bypass-im-lặng (approve hộ không ai xác-nhận) sang ký-tay-tường-minh (bắt creator chủ-động nêu lý-do + tạo chữ-ký) là **nâng-cấp** minh-bạch, không giảm. + +## Yêu cầu (3) Reject/Trả-lại về đúng người → re-comment → forward lại: ĐÃ ĐỦ, 0 code mới +Multi-mode Trả-lại (service `ApplyReturnModeAsync:348-548`) `OneLevel/OneStep/Assignee` giữ `Phase=ChoDuyet` + lùi pointer về đúng Cấp/người; `Assignee` (:485-510) pick từ NV đã ký (gồm creator). Người nhận lại → banner "Đến lượt bạn" → Duyệt → UPSERT overwrite comment + advance. Chỉ cần **admin bật** `AllowReturnToAssignee` (entity:110) trên slot cấp trên — config, không phải code. + +## Edge cases +- Creator xuất-hiện nhiều cấp (screenshot: Bình ở Bước1-Cấp1 VÀ Bước2-Cấp1): A1/A2 xử đúng — creator tự ký từng cấp khi pointer chạm; không auto-sign chéo. +- Notify: creator không nhận chuông "cần bạn duyệt" phiếu mình (service:1164-1169 S86) — giữ nguyên, thấy ở "Phiếu của tôi". Không double-notify. +- Submit-guard Section-3 (:175-229) chạy TRƯỚC bypass → không bị ảnh hưởng. +- A2 đảo ngược ý S60 "TP tạo phiếu vẫn bắt NV cấp dưới duyệt lại" → dùng A1 nếu muốn giữ skip-NV. + +Token cost estimate: ~62K + +## Open Questions +1. **A1 hay A2?** A1 = giữ skip-NV-cấp-dưới của S60 (creator là TP → NV dưới vẫn auto-qua, creator tự ký ô mình) — phức tạp hơn ~15 dòng. A2 = xóa hẳn bypass, creator tự ký MỌI cấp mình chiếm (kể cả có NV dưới) — đơn giản nhất nhưng bỏ tính-năng S60. Owner chốt còn muốn skip-NV-cấp-dưới không. +2. **Guard B2 self-finalize:** có chặn creator-slot bật AllowApproverFinalize/SkipToFinal không? Nếu Designer cho phép cố-ý (vd phiếu giá-trị nhỏ creator được tự-chốt) thì B2 phải thành cảnh-báo mềm thay vì Forbidden cứng. +3. **Xác nhận KHÔNG cần 2-nút (lưu-nháp-ý-kiến riêng):** nếu owner thực-sự muốn creator "lưu comment, để đó, lát mới đẩy" (không advance ngay) thì mới cần Option B (schema+endpoint+DTO). Cần owner xác-nhận yêu-cầu chỉ là "tự ký ô mình" (1 nút đủ) hay có nhu-cầu draft-rời-advance thật. diff --git a/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-reviewer.md b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-reviewer.md new file mode 100644 index 0000000..29cd1b3 --- /dev/null +++ b/.claude/workflows/runs/2026-07-12-S112-pe-signoff-save-advance/sub-reviewer.md @@ -0,0 +1,108 @@ +**HEADLINE:** PASS_WITH_CONCERNS — 3 lane mô tả code KHỚP source thật (mọi file:line verify đúng); thiết kế "gỡ/gate bypass, giữ 1 nút save=advance" KHÔNG vỡ 5-state; NHƯNG xác nhận độc lập EDGE-5 (mở lại phiếu DaDuyet qua forge Reject+TraLai) là lỗ THẬT reachable ở handler + self-finalize thiếu guard. + + +## Phạm vi verify độc lập +Đã đọc source thật (không tin mù lane): `PurchaseEvaluationWorkflowService.cs` (1207 dòng full), `PurchaseEvaluationFeatures.cs` (create + transition handler), `ApprovalWorkflow.cs` (entity V2), `PurchaseEvaluationLevelOpinion.cs`, `PurchaseEvaluationPhase.cs`, `PurchaseEvaluationsController.cs`, 2× `PeWorkflowPanel.tsx`, grep toàn backend `.Remove/RemoveRange`, glob test files. **Không có deploy claim → không chạy curl (đúng: đây là run điều tra + thiết kế, chưa có code).** + +--- + +## (1) Luồng hiện tại 3 lane mô tả có KHỚP code thật? + +**KẾT LUẬN: KHỚP 100% ở mọi neo file:line quan trọng.** Bảng đối chiếu: + +| Claim (lane) | Verify | Source thật | +|---|---|---| +| ApproveV2Async = UPSERT opinion + advance CÙNG 1 call | ✅ | UPSERT `:763-790`, advance `:927-957`, cùng method, `SaveChangesAsync` ở caller `:285` | +| Gate `:728-739` là gate THUẬN (không chặn creator) | ✅ | `:728-739` chỉ check `actor.Id ∈ pendingLevelGroup` — creator LÀ approver Cấp-1 → pass | +| `ApplyDrafterBypassOnSubmitAsync` tự ký ô creator + comment CANNED + advance qua | ✅ | `:571-681`; canned `"(duyệt tự động…)"` `:619`; advance `:647-680`; gọi tại `:260-261` | +| DrafterUserId = creator, set lúc create | ✅ | `FEAT:147` `DrafterUserId = currentUser.UserId`; Service dùng DrafterUserId (KHÔNG CreatedBy) mọi identity-logic | +| Gỡ bypass → luồng mong-muốn chạy với ZERO đổi ApproveV2Async | ✅ | Pointer giữ `(0,1)` → creator ∈ pendingLevelGroup → UPSERT comment THẬT + advance `:927`. Cơ chế đã có sẵn | +| Opinion KHÔNG BAO GIỜ bị xoá (chỉ UPSERT overwrite) | ✅ | grep `.Remove/RemoveRange` toàn backend = **0 match trên LevelOpinions** (có trên 20+ bảng khác, KHÔNG có LevelOpinions) | +| 4 mode Trả lại, default Drafter, Allow* per-slot | ✅ | `effectiveMode = returnMode ?? Drafter` `:117`; validate flag `:397-410`; entity `AllowReturnToDrafter` default `true` `:113`, 3 mode kia default `false` | +| FE 2 app byte-identical + Trả lại/Từ chối ẩn cho creator, Duyệt KHÔNG ẩn | ✅ | `git diff --no-index` exit 0, cả 2 = 903 dòng; `PeWorkflowPanel.tsx:446` `if((isSendBack\|\|isCancel) && drafterUserId===currentUser.id) return null` | +| currentApproval phản ánh pointer HIỆN TẠI → creator thấy nút Duyệt enabled | ✅ | `FEAT:1161-1182` build approvers từ `steps[idxCur].Levels.Where(Order==lvlCur)`; FE `actorInV2Level` `:99-100`, `isDisabled=blockedByV2Level` `:449` | + +**2 điểm lane hơi lệch (đều MINOR, không sai bản chất):** +- **Entity comment stale:** `ApprovalWorkflowLevel` doc `:81-82` ghi *"Approver = ApproverUserId chính xác (KHÔNG OR-of-many)"* — nhưng Service `:718-732` THỰC THI OR-of-N (group Levels by Order). Các lane mô tả OR-of-N (đúng behavior thật); comment entity là tài-liệu cũ chưa cập-nhật. Không phải bug, chỉ doc-drift. +- **Lane B gộp `skipToFinal` chung rủi ro với `AllowApproverFinalize` (B2):** verify `:828-845` — `skipToFinal` GIỮ `Phase=ChoDuyet`, chỉ đẩy pointer tới Cấp cuối; **CEO/NV cuối vẫn phải ký thật** để lên DaDuyet. Chỉ `AllowApproverFinalize` `:867-886` mới set `Phase=DaDuyet` TRỰC TIẾP. → guard B2 cho `AllowApproverFinalize` là hợp lệ; áp cho `skipToFinal` gần như thừa (không phải lỗ self-finalize-terminal). Implementer đừng over-engineer nhánh skipToFinal. + +--- + +## (2) Thiết kế thay đổi có ĐÚNG state-machine (không vỡ 5-state, không kẹt phiếu)? + +**KẾT LUẬN: SOUND — không vỡ 5 trạng thái, không sinh class kẹt phiếu mới.** + +5-state: `DangSoanThao(1) · ChoDuyet(10) · DaDuyet(7) · TraLai(98) · TuChoi(99)` (verify `PurchaseEvaluationPhase.cs:15-27`). + +- Thay đổi CHỈ chạm nhánh SUBMIT (`:260-261` gọi bypass). Gỡ/gate bypass → sau submit pointer ĐỨNG tại Cấp creator thay vì advance qua. Transition `DangSoanThao/TraLai → ChoDuyet` giữ nguyên; **KHÔNG thêm state, KHÔNG bỏ state**. Nhánh approve/reject downstream không đụng. +- **Không kẹt phiếu:** nếu creator không tự bấm Duyệt → phiếu nằm ChoDuyet tại Cấp họ (giống mọi approver chưa thao tác — SLA `:35` xử lý; creator luôn thấy ở "Phiếu của tôi"). KHÔNG phải class-kẹt mới. +- **A1 vs A2 — cảnh báo về tính nhất-quán A1 (nâng lên openQuestions):** A1 ("chỉ bypass `order < drafterSlots.Min`") CHỈ nhất-quán khi creator chiếm các Cấp LIÊN-TỤC từ dưới. Nếu creator chiếm Cấp **không liên-tục** (vd Cấp 1 + Cấp 3, Cấp 2 là NV khác): A1 bypass `order