task f.3
This commit is contained in:
81
docs/implementation/task-f2-approval-matrix-foundation.md
Normal file
81
docs/implementation/task-f2-approval-matrix-foundation.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Task F.2: Approval Matrix Foundation
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This task adds approval-matrix based workflow resolution for quotation submission so the system no longer relies on a single static workflow code.
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- Added `crm_approval_matrices` persistence in [src/db/schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts).
|
||||||
|
- Added SQL migration in [drizzle/0002_calm_approval_matrix.sql](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0002_calm_approval_matrix.sql).
|
||||||
|
- Seeded a default quotation matrix in [src/db/seeds/foundation.seed.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts) to preserve current behavior.
|
||||||
|
- Added matrix validation in [src/features/crm/approval/schemas/approval-matrix.schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/schemas/approval-matrix.schema.ts).
|
||||||
|
- Added matrix CRUD and resolver services in [src/features/crm/approval/server/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/server/service.ts).
|
||||||
|
- Added settings APIs:
|
||||||
|
- [src/app/api/crm/settings/approval-matrices/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-matrices/route.ts)
|
||||||
|
- [src/app/api/crm/settings/approval-matrices/[id]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-matrices/[id]/route.ts)
|
||||||
|
- Updated quotation submit approval flow in [src/app/api/crm/quotations/[id]/submit-approval/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/submit-approval/route.ts) to resolve workflow by matrix before creating the approval request.
|
||||||
|
- Extended foundation approval submit input in:
|
||||||
|
- [src/features/foundation/approval/types.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/types.ts)
|
||||||
|
- [src/features/foundation/approval/server/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/server/service.ts)
|
||||||
|
- Added permissions in [src/lib/auth/rbac.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts):
|
||||||
|
- `crm.approval.matrix.read`
|
||||||
|
- `crm.approval.matrix.create`
|
||||||
|
- `crm.approval.matrix.update`
|
||||||
|
- `crm.approval.matrix.delete`
|
||||||
|
|
||||||
|
## Matching Rules
|
||||||
|
|
||||||
|
- Scope filters consider organization, entity type, active state, and soft delete.
|
||||||
|
- `productType`, `branchId`, and `currency` match exact values or `null` wildcard.
|
||||||
|
- `amount` respects `minAmount` and `maxAmount` when those bounds exist.
|
||||||
|
- Specific rules outrank default rules.
|
||||||
|
- Specificity scoring prefers:
|
||||||
|
- product type match
|
||||||
|
- branch match
|
||||||
|
- currency match
|
||||||
|
- amount-range match
|
||||||
|
- Lower `priority` value wins after specificity.
|
||||||
|
- If specificity and priority tie, the newest active rule wins.
|
||||||
|
- If candidates still tie on the same creation timestamp, the resolver throws a deterministic configuration conflict.
|
||||||
|
- If no specific rule matches, the resolver falls back to an active default rule.
|
||||||
|
|
||||||
|
## Submit Approval Integration
|
||||||
|
|
||||||
|
- Quotation submit approval now:
|
||||||
|
1. loads the quotation
|
||||||
|
2. enforces CRM scoped access
|
||||||
|
3. resolves the approval matrix
|
||||||
|
4. submits approval using the resolved `workflowId`
|
||||||
|
5. writes audit data for the resolution
|
||||||
|
- Existing approve, reject, return, and cancel flows remain unchanged.
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
- Matrix CRUD uses `entityType = crm_approval_matrix`.
|
||||||
|
- Matrix maintenance actions:
|
||||||
|
- `create_approval_matrix`
|
||||||
|
- `update_approval_matrix`
|
||||||
|
- `delete_approval_matrix`
|
||||||
|
- Quotation submission writes `resolve_approval_matrix` with:
|
||||||
|
- `quotationId`
|
||||||
|
- `approvalRequestId`
|
||||||
|
- `matrixId`
|
||||||
|
- `workflowId`
|
||||||
|
- `matchReason`
|
||||||
|
- `productType`
|
||||||
|
- `branchId`
|
||||||
|
- `amount`
|
||||||
|
- `currency`
|
||||||
|
|
||||||
|
## Seed Behavior
|
||||||
|
|
||||||
|
- Foundation seed creates or refreshes one active default quotation matrix pointing to `quotation_standard_approval`.
|
||||||
|
- This preserves legacy quotation approval behavior until organization-specific rules are added.
|
||||||
|
- Sample non-default matrices were intentionally not seeded because additional workflows such as executive approval are not yet guaranteed in every environment.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- F.2 adds API and service foundations only. No matrix admin UI is included.
|
||||||
|
- Overlapping active rules are allowed. Resolver handles them deterministically, but configuration hygiene still matters.
|
||||||
|
- Matrix branch, product, and currency values currently assume the same ids already stored on quotation records.
|
||||||
20
drizzle/0002_calm_approval_matrix.sql
Normal file
20
drizzle/0002_calm_approval_matrix.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "crm_approval_matrices" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"workflow_id" text NOT NULL,
|
||||||
|
"branch_id" text,
|
||||||
|
"product_type" text,
|
||||||
|
"min_amount" double precision,
|
||||||
|
"max_amount" double precision,
|
||||||
|
"currency" text,
|
||||||
|
"priority" integer DEFAULT 100 NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"updated_by" text NOT NULL
|
||||||
|
);
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1782381033930,
|
"when": 1782381033930,
|
||||||
"tag": "0001_lazy_otto_octavius",
|
"tag": "0001_lazy_otto_octavius",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782604800000,
|
||||||
|
"tag": "0002_calm_approval_matrix",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
501
plans/task-f.2.md
Normal file
501
plans/task-f.2.md
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
# Task F.2: Approval Matrix Foundation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Planned
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build the Approval Matrix foundation so the system can select the correct approval workflow automatically when a quotation is submitted for approval.
|
||||||
|
|
||||||
|
This task upgrades approval from a single static workflow to rule-based workflow resolution.
|
||||||
|
|
||||||
|
The goal is to support approval flow selection by:
|
||||||
|
|
||||||
|
* entity type
|
||||||
|
* product type
|
||||||
|
* branch
|
||||||
|
* quotation amount
|
||||||
|
* default fallback
|
||||||
|
* priority when multiple rules match
|
||||||
|
|
||||||
|
This task does not build the full Workflow Builder UI or Notification system yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mandatory Review
|
||||||
|
|
||||||
|
Review before implementation:
|
||||||
|
|
||||||
|
* `AGENTS.md`
|
||||||
|
* `docs/standards/project-foundations.md`
|
||||||
|
* `docs/standards/task-catalog.md`
|
||||||
|
* existing Approval implementation
|
||||||
|
* existing Quotation submit approval flow
|
||||||
|
* `src/db/schema.ts`
|
||||||
|
* `src/features/crm/approval/**`
|
||||||
|
* `src/features/crm/quotations/**`
|
||||||
|
* `src/features/crm/opportunities/**`
|
||||||
|
* `src/features/foundation/audit-log/**`
|
||||||
|
* `src/features/foundation/master-options/**`
|
||||||
|
* `docs/security/crm-authorization-boundaries.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Foundation
|
||||||
|
|
||||||
|
Already available:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_workflows
|
||||||
|
crm_approval_steps
|
||||||
|
crm_approval_requests
|
||||||
|
crm_approval_actions
|
||||||
|
|
||||||
|
quotation_standard_approval
|
||||||
|
quotation submit approval flow
|
||||||
|
quotation approval actions
|
||||||
|
audit foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
Current limitation:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Quotation approval uses a fixed or first active workflow.
|
||||||
|
```
|
||||||
|
|
||||||
|
This task introduces matrix-based workflow resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Requirement
|
||||||
|
|
||||||
|
When a quotation is submitted for approval, the system must determine:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Which approval workflow should this quotation use?
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow should be selected based on business conditions.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Crane quotation under 500,000
|
||||||
|
→ Crane Standard Approval
|
||||||
|
|
||||||
|
Crane quotation over 5,000,000
|
||||||
|
→ Crane Executive Approval
|
||||||
|
|
||||||
|
Dock Door quotation
|
||||||
|
→ Dock Door Approval
|
||||||
|
|
||||||
|
No specific match
|
||||||
|
→ Default Quotation Approval
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.1 Approval Matrix Schema
|
||||||
|
|
||||||
|
Add table:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_matrices
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended columns:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organization_id
|
||||||
|
entity_type
|
||||||
|
workflow_id
|
||||||
|
branch_id
|
||||||
|
product_type
|
||||||
|
min_amount
|
||||||
|
max_amount
|
||||||
|
currency
|
||||||
|
priority
|
||||||
|
is_default
|
||||||
|
is_active
|
||||||
|
description
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
deleted_at
|
||||||
|
created_by
|
||||||
|
updated_by
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended types:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
entity_type text not null
|
||||||
|
workflow_id text not null
|
||||||
|
branch_id text null
|
||||||
|
product_type text null
|
||||||
|
min_amount numeric(14,2) null
|
||||||
|
max_amount numeric(14,2) null
|
||||||
|
currency text null
|
||||||
|
priority integer not null default 100
|
||||||
|
is_default boolean not null default false
|
||||||
|
is_active boolean not null default true
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* `entity_type = quotation` for quotation approval
|
||||||
|
* `product_type = null` means all product types
|
||||||
|
* `branch_id = null` means all branches
|
||||||
|
* `min_amount = null` means no lower bound
|
||||||
|
* `max_amount = null` means no upper bound
|
||||||
|
* `currency = null` means all currencies
|
||||||
|
* lower priority number wins, or document project convention if already defined
|
||||||
|
* only active rules are considered
|
||||||
|
* soft-deleted rules are ignored
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.2 Matrix Matching Rules
|
||||||
|
|
||||||
|
Implement resolver:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/approval/server/matrix-resolver.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolveApprovalWorkflowForEntity(input)
|
||||||
|
```
|
||||||
|
|
||||||
|
Input:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
organizationId: string;
|
||||||
|
entityType: 'quotation';
|
||||||
|
branchId?: string | null;
|
||||||
|
productType?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
currency?: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
matrixId: string;
|
||||||
|
workflowId: string;
|
||||||
|
matchReason: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Matching rules:
|
||||||
|
|
||||||
|
1. Match same organization
|
||||||
|
2. Match same entity type
|
||||||
|
3. Ignore inactive or deleted matrix rows
|
||||||
|
4. Product type must match exact or be null
|
||||||
|
5. Branch must match exact or be null
|
||||||
|
6. Currency must match exact or be null
|
||||||
|
7. Amount must be within range:
|
||||||
|
|
||||||
|
* `minAmount <= amount` if min exists
|
||||||
|
* `amount <= maxAmount` if max exists
|
||||||
|
8. Choose most specific rule
|
||||||
|
9. If specificity ties, choose highest priority
|
||||||
|
10. If still tied, choose newest active rule or throw deterministic conflict error
|
||||||
|
11. If no specific rule matches, use default rule
|
||||||
|
12. If no default rule exists, throw clear configuration error
|
||||||
|
|
||||||
|
Specificity scoring suggestion:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
productType match = +10
|
||||||
|
branch match = +10
|
||||||
|
currency match = +5
|
||||||
|
amount range match = +5
|
||||||
|
default rule = -100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.3 Submit Approval Integration
|
||||||
|
|
||||||
|
Update quotation submit approval flow.
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
use static quotation_standard_approval
|
||||||
|
```
|
||||||
|
|
||||||
|
New behavior:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation submit
|
||||||
|
↓
|
||||||
|
build approval matrix input from quotation
|
||||||
|
↓
|
||||||
|
resolve workflow
|
||||||
|
↓
|
||||||
|
create approval request with resolved workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
Quotation fields used:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
organizationId
|
||||||
|
branchId
|
||||||
|
productType / quotationType
|
||||||
|
totalAmount
|
||||||
|
currency
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* do not select workflow in route handler
|
||||||
|
* service must call matrix resolver
|
||||||
|
* approval request must store selected workflowId
|
||||||
|
* audit must include selected matrixId and workflowId
|
||||||
|
* existing approval action flow should continue unchanged after request is created
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.4 Default Matrix Seed
|
||||||
|
|
||||||
|
Update seed to create at least one default quotation approval matrix.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
entity_type = quotation
|
||||||
|
workflow = quotation_standard_approval
|
||||||
|
product_type = null
|
||||||
|
branch_id = null
|
||||||
|
min_amount = null
|
||||||
|
max_amount = null
|
||||||
|
currency = null
|
||||||
|
priority = 999
|
||||||
|
is_default = true
|
||||||
|
is_active = true
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves current behavior while enabling future product-specific rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.5 Product Type Matrix Examples
|
||||||
|
|
||||||
|
Seed optional sample rules only if project seed policy allows.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation_crane_standard
|
||||||
|
product_type = crane
|
||||||
|
amount <= 500000
|
||||||
|
workflow = quotation_standard_approval
|
||||||
|
|
||||||
|
quotation_crane_executive
|
||||||
|
product_type = crane
|
||||||
|
amount > 5000000
|
||||||
|
workflow = quotation_executive_approval
|
||||||
|
```
|
||||||
|
|
||||||
|
If required workflows do not exist, do not seed these sample rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.6 Matrix Admin API Foundation
|
||||||
|
|
||||||
|
Create API foundation for managing matrix rows.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/approval/matrices
|
||||||
|
POST /api/crm/approval/matrices
|
||||||
|
GET /api/crm/approval/matrices/[id]
|
||||||
|
PATCH /api/crm/approval/matrices/[id]
|
||||||
|
DELETE /api/crm/approval/matrices/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* no UI required in F.2
|
||||||
|
* enforce approval admin permission
|
||||||
|
* validate workflow exists
|
||||||
|
* validate no invalid range
|
||||||
|
* soft delete only
|
||||||
|
* prevent deleting default matrix if it is the only active default for entity type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.7 Validation Rules
|
||||||
|
|
||||||
|
Create schemas:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/approval/schemas/approval-matrix.schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
* entityType required
|
||||||
|
* workflowId required
|
||||||
|
* minAmount must be <= maxAmount when both exist
|
||||||
|
* priority must be integer
|
||||||
|
* productType optional
|
||||||
|
* branchId optional
|
||||||
|
* currency optional
|
||||||
|
* isDefault boolean
|
||||||
|
* isActive boolean
|
||||||
|
|
||||||
|
Conflict warning:
|
||||||
|
|
||||||
|
* multiple active matrix rows can overlap
|
||||||
|
* resolver must handle deterministic selection
|
||||||
|
* API may warn but does not need to fully block overlap in F.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.8 Audit Logging
|
||||||
|
|
||||||
|
Audit actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
create_approval_matrix
|
||||||
|
update_approval_matrix
|
||||||
|
delete_approval_matrix
|
||||||
|
resolve_approval_matrix
|
||||||
|
```
|
||||||
|
|
||||||
|
For submit approval, audit must capture:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotationId
|
||||||
|
matrixId
|
||||||
|
workflowId
|
||||||
|
matchReason
|
||||||
|
productType
|
||||||
|
branchId
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.9 Permission Rules
|
||||||
|
|
||||||
|
Add or verify permissions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.approval.matrix.read
|
||||||
|
crm.approval.matrix.create
|
||||||
|
crm.approval.matrix.update
|
||||||
|
crm.approval.matrix.delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Submit approval should still use existing quotation approval submit permission.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* route handlers must not check role strings directly
|
||||||
|
* use existing CRM authorization foundation
|
||||||
|
* admin-only API for matrix maintenance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope F.2.10 Documentation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-f2-approval-matrix-foundation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
schema
|
||||||
|
matching rules
|
||||||
|
priority rules
|
||||||
|
default fallback
|
||||||
|
submit approval integration
|
||||||
|
seed behavior
|
||||||
|
known limitations
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicit Non-Scope
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
* full Workflow Builder UI
|
||||||
|
* drag-and-drop step editor
|
||||||
|
* Notification Center
|
||||||
|
* Email sending
|
||||||
|
* LINE / webhook sending
|
||||||
|
* reminder / escalation
|
||||||
|
* approval dashboard
|
||||||
|
* approval delegation
|
||||||
|
* parallel approval
|
||||||
|
* any-one approval
|
||||||
|
* dynamic approver lookup by manager hierarchy
|
||||||
|
|
||||||
|
These belong to later F tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual verification:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Default matrix exists after seed
|
||||||
|
Submit quotation approval still works
|
||||||
|
Approval request stores resolved workflowId
|
||||||
|
Matrix resolver chooses product-specific rule over default
|
||||||
|
Matrix resolver chooses branch-specific rule over all-branch rule
|
||||||
|
Matrix resolver chooses amount-range rule correctly
|
||||||
|
Matrix resolver handles no-match with clear error
|
||||||
|
Matrix CRUD APIs work
|
||||||
|
Soft delete matrix works
|
||||||
|
Audit logs include matrix resolution
|
||||||
|
Existing approval approve/reject actions still work
|
||||||
|
Existing quotation flow still works
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Task is complete when:
|
||||||
|
|
||||||
|
* `crm_approval_matrices` exists
|
||||||
|
* default quotation approval matrix is seeded
|
||||||
|
* quotation submit approval uses matrix resolver
|
||||||
|
* workflow selection supports product type, branch, amount, currency, default fallback, and priority
|
||||||
|
* matrix CRUD APIs exist
|
||||||
|
* audit logs capture matrix changes and resolution
|
||||||
|
* existing approval actions continue working
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Matrix Foundation = Established
|
||||||
|
Workflow Resolution = Dynamic
|
||||||
|
Ready for F.3 Approval Workflow Builder
|
||||||
|
```
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { submitForApproval } from '@/features/foundation/approval/server/service';
|
import { submitQuotationForApproval } from '@/features/crm/approval/server/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -15,20 +16,26 @@ const submitSchema = z.object({
|
|||||||
export async function POST(request: NextRequest, { params }: Params) {
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmApprovalSubmit
|
permission: PERMISSIONS.crmApprovalSubmit
|
||||||
});
|
});
|
||||||
const payload = submitSchema.parse(await request.json());
|
const payload = submitSchema.parse(await request.json());
|
||||||
await submitForApproval(organization.id, session.user.id, {
|
|
||||||
workflowCode: 'quotation_standard_approval',
|
await submitQuotationForApproval({
|
||||||
entityType: 'quotation',
|
organizationId: organization.id,
|
||||||
entityId: id,
|
userId: session.user.id,
|
||||||
remark: payload.remark
|
quotationId: id,
|
||||||
|
remark: payload.remark,
|
||||||
|
accessContext: buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Quotation submitted for approval successfully'
|
message: 'Quotation submitted approval successfully'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -43,6 +50,6 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ message: 'Unable to submit quotation for approval' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable submit quotation for approval' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
src/app/api/crm/settings/approval-matrices/[id]/route.ts
Normal file
119
src/app/api/crm/settings/approval-matrices/[id]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
updateApprovalMatrixSchema,
|
||||||
|
type UpdateApprovalMatrixInput
|
||||||
|
} from '@/features/crm/approval/schemas/approval-matrix.schema';
|
||||||
|
import {
|
||||||
|
getApprovalMatrix,
|
||||||
|
softDeleteApprovalMatrix,
|
||||||
|
updateApprovalMatrix
|
||||||
|
} from '@/features/crm/approval/server/service';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalMatrixRead
|
||||||
|
});
|
||||||
|
const matrix = await getApprovalMatrix(id, organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Approval matrix loaded successfully',
|
||||||
|
matrix
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to load approval matrix' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalMatrixUpdate
|
||||||
|
});
|
||||||
|
const payload = updateApprovalMatrixSchema.parse(
|
||||||
|
await request.json()
|
||||||
|
) as UpdateApprovalMatrixInput;
|
||||||
|
const before = await getApprovalMatrix(id, organization.id);
|
||||||
|
const updated = await updateApprovalMatrix(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_approval_matrix',
|
||||||
|
entityId: id,
|
||||||
|
action: 'update_approval_matrix',
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval matrix updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to update approval matrix' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalMatrixDelete
|
||||||
|
});
|
||||||
|
const result = await softDeleteApprovalMatrix(id, organization.id, session.user.id);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_approval_matrix',
|
||||||
|
entityId: id,
|
||||||
|
action: 'delete_approval_matrix',
|
||||||
|
beforeData: result.before,
|
||||||
|
afterData: result.after
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval matrix deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to delete approval matrix' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/api/crm/settings/approval-matrices/route.ts
Normal file
77
src/app/api/crm/settings/approval-matrices/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
createApprovalMatrixSchema,
|
||||||
|
type CreateApprovalMatrixInput
|
||||||
|
} from '@/features/crm/approval/schemas/approval-matrix.schema';
|
||||||
|
import {
|
||||||
|
createApprovalMatrix,
|
||||||
|
listApprovalMatrices
|
||||||
|
} from '@/features/crm/approval/server/service';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalMatrixRead
|
||||||
|
});
|
||||||
|
const items = await listApprovalMatrices(organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Approval matrices loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to load approval matrices' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalMatrixCreate
|
||||||
|
});
|
||||||
|
const payload = createApprovalMatrixSchema.parse(
|
||||||
|
await request.json()
|
||||||
|
) as CreateApprovalMatrixInput;
|
||||||
|
const created = await createApprovalMatrix(organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_approval_matrix',
|
||||||
|
entityId: created.id,
|
||||||
|
action: 'create_approval_matrix',
|
||||||
|
afterData: created
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval matrix created successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to create approval matrix' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +1,6 @@
|
|||||||
import { NavGroup } from "@/types";
|
import { NavGroup } from "@/types";
|
||||||
/** * Navigation configuration with RBAC support * * This configuration is used for both the sidebar navigation and Cmd+K bar. * Items are organized into groups, each rendered with a SidebarGroupLabel. * * RBAC Access Control: * Each navigation item can have an `access` property that controls visibility * based on permissions, plans, features, roles, and organization context. * * Examples: * * 1. Require organization: * access: { requireOrg: true } * * 2. Require specific permission: * access: { requireOrg: true, permission: 'org:teams:manage' } * * 3. Require specific plan: * access: { plan: 'pro' } * * 4. Require specific feature: * access: { feature: 'premium_access' } * * 5. Require specific role: * access: { role: 'admin' } * * 6. Multiple conditions (all must be true): * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } * * Note: The `visible` function is deprecated but still supported for backward compatibility. * Use the `access` property for new items. */ export const navGroups: NavGroup[] =
|
/** * Navigation configuration with RBAC support * * This configuration is used for both the sidebar navigation and Cmd+K bar. * Items are organized into groups, each rendered with a SidebarGroupLabel. * * RBAC Access Control: * Each navigation item can have an `access` property that controls visibility * based on permissions, plans, features, roles, and organization context. * * Examples: * * 1. Require organization: * access: { requireOrg: true } * * 2. Require specific permission: * access: { requireOrg: true, permission: 'org:teams:manage' } * * 3. Require specific plan: * access: { plan: 'pro' } * * 4. Require specific feature: * access: { feature: 'premium_access' } * * 5. Require specific role: * access: { role: 'admin' } * * 6. Multiple conditions (all must be true): * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } * * Note: The `visible` function is deprecated but still supported for backward compatibility. * Use the `access` property for new items. */ export const navGroups: NavGroup[] =
|
||||||
[
|
[
|
||||||
// {
|
|
||||||
// label: "Overview",
|
|
||||||
// items: [
|
|
||||||
// {
|
|
||||||
// title: "Dashboard Overview",
|
|
||||||
// url: "/dashboard",
|
|
||||||
// icon: "dashboard",
|
|
||||||
// isActive: false,
|
|
||||||
// shortcut: ["d", "d"],
|
|
||||||
// items: [],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Workspaces",
|
|
||||||
// url: "/dashboard/workspaces",
|
|
||||||
// icon: "workspace",
|
|
||||||
// isActive: false,
|
|
||||||
// items: [],
|
|
||||||
// access: { systemRole: "super_admin" },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Teams",
|
|
||||||
// url: "/dashboard/workspaces/team",
|
|
||||||
// icon: "teams",
|
|
||||||
// isActive: false,
|
|
||||||
// items: [],
|
|
||||||
// access: { requireOrg: true, permission: "crm.quotation.read" },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Users",
|
|
||||||
// url: "/dashboard/users",
|
|
||||||
// icon: "teams",
|
|
||||||
// shortcut: ["u", "u"],
|
|
||||||
// isActive: false,
|
|
||||||
// items: [],
|
|
||||||
// access: { requireOrg: true, permission: "users:manage" },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: "Kanban",
|
|
||||||
// url: "/dashboard/kanban",
|
|
||||||
// icon: "kanban",
|
|
||||||
// shortcut: ["k", "k"],
|
|
||||||
// isActive: false,
|
|
||||||
// items: [],
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
label: "CRM",
|
label: "CRM",
|
||||||
items: [
|
items: [
|
||||||
@@ -181,4 +135,50 @@ import { NavGroup } from "@/types";
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Admin",
|
||||||
|
items: [
|
||||||
|
// {
|
||||||
|
// title: "Dashboard Overview",
|
||||||
|
// url: "/dashboard",
|
||||||
|
// icon: "dashboard",
|
||||||
|
// isActive: false,
|
||||||
|
// shortcut: ["d", "d"],
|
||||||
|
// items: [],
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
title: "Workspaces",
|
||||||
|
url: "/dashboard/workspaces",
|
||||||
|
icon: "workspace",
|
||||||
|
isActive: false,
|
||||||
|
items: [],
|
||||||
|
access: { systemRole: "super_admin" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Teams",
|
||||||
|
url: "/dashboard/workspaces/team",
|
||||||
|
icon: "teams",
|
||||||
|
isActive: false,
|
||||||
|
items: [],
|
||||||
|
access: { requireOrg: true, permission: "crm.quotation.read" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Users",
|
||||||
|
url: "/dashboard/users",
|
||||||
|
icon: "teams",
|
||||||
|
shortcut: ["u", "u"],
|
||||||
|
isActive: false,
|
||||||
|
items: [],
|
||||||
|
access: { requireOrg: true, permission: "users:manage" },
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// title: "Kanban",
|
||||||
|
// url: "/dashboard/kanban",
|
||||||
|
// icon: "kanban",
|
||||||
|
// shortcut: ["k", "k"],
|
||||||
|
// isActive: false,
|
||||||
|
// items: [],
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -680,6 +680,27 @@ export const crmApprovalSteps = pgTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const crmApprovalMatrices = pgTable('crm_approval_matrices', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
workflowId: text('workflow_id').notNull(),
|
||||||
|
branchId: text('branch_id'),
|
||||||
|
productType: text('product_type'),
|
||||||
|
minAmount: doublePrecision('min_amount'),
|
||||||
|
maxAmount: doublePrecision('max_amount'),
|
||||||
|
currency: text('currency'),
|
||||||
|
priority: integer('priority').default(100).notNull(),
|
||||||
|
isDefault: boolean('is_default').default(false).notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
updatedBy: text('updated_by').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const crmApprovalRequests = pgTable('crm_approval_requests', {
|
export const crmApprovalRequests = pgTable('crm_approval_requests', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
|
|||||||
@@ -666,6 +666,22 @@ const APPROVAL_WORKFLOWS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const APPROVAL_MATRIX_DEFAULTS = [
|
||||||
|
{
|
||||||
|
entityType: 'quotation',
|
||||||
|
workflowCode: 'quotation_standard_approval',
|
||||||
|
branchId: null,
|
||||||
|
productType: null,
|
||||||
|
minAmount: null,
|
||||||
|
maxAmount: null,
|
||||||
|
currency: null,
|
||||||
|
priority: 999,
|
||||||
|
isDefault: true,
|
||||||
|
isActive: true,
|
||||||
|
description: 'Default quotation approval matrix'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
||||||
{
|
{
|
||||||
placeholderKey: 'customer_name',
|
placeholderKey: 'customer_name',
|
||||||
@@ -1031,6 +1047,95 @@ async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertApprovalMatricesForOrganization(
|
||||||
|
sql: SqlClient,
|
||||||
|
organization: { id: string; createdBy: string }
|
||||||
|
) {
|
||||||
|
const workflows = await sql`
|
||||||
|
select id, code
|
||||||
|
from crm_approval_workflows
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and deleted_at is null
|
||||||
|
`;
|
||||||
|
|
||||||
|
const workflowIdByCode = new Map(
|
||||||
|
workflows.map((workflow: { code: string; id: string }) => [workflow.code, workflow.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const matrix of APPROVAL_MATRIX_DEFAULTS) {
|
||||||
|
const workflowId = workflowIdByCode.get(matrix.workflowCode);
|
||||||
|
if (!workflowId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await sql`
|
||||||
|
select id
|
||||||
|
from crm_approval_matrices
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and entity_type = ${matrix.entityType}
|
||||||
|
and is_default = ${matrix.isDefault}
|
||||||
|
and deleted_at is null
|
||||||
|
order by created_at asc
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
await sql`
|
||||||
|
update crm_approval_matrices
|
||||||
|
set workflow_id = ${workflowId},
|
||||||
|
branch_id = ${matrix.branchId},
|
||||||
|
product_type = ${matrix.productType},
|
||||||
|
min_amount = ${matrix.minAmount},
|
||||||
|
max_amount = ${matrix.maxAmount},
|
||||||
|
currency = ${matrix.currency},
|
||||||
|
priority = ${matrix.priority},
|
||||||
|
is_active = ${matrix.isActive},
|
||||||
|
description = ${matrix.description},
|
||||||
|
updated_at = now(),
|
||||||
|
updated_by = ${organization.createdBy}
|
||||||
|
where id = ${existing.id}
|
||||||
|
`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
insert into crm_approval_matrices (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
entity_type,
|
||||||
|
workflow_id,
|
||||||
|
branch_id,
|
||||||
|
product_type,
|
||||||
|
min_amount,
|
||||||
|
max_amount,
|
||||||
|
currency,
|
||||||
|
priority,
|
||||||
|
is_default,
|
||||||
|
is_active,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organization.id},
|
||||||
|
${matrix.entityType},
|
||||||
|
${workflowId},
|
||||||
|
${matrix.branchId},
|
||||||
|
${matrix.productType},
|
||||||
|
${matrix.minAmount},
|
||||||
|
${matrix.maxAmount},
|
||||||
|
${matrix.currency},
|
||||||
|
${matrix.priority},
|
||||||
|
${matrix.isDefault},
|
||||||
|
${matrix.isActive},
|
||||||
|
${matrix.description},
|
||||||
|
${organization.createdBy},
|
||||||
|
${organization.createdBy}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertDocumentTemplatesForOrganization(
|
async function upsertDocumentTemplatesForOrganization(
|
||||||
sql: SqlClient,
|
sql: SqlClient,
|
||||||
organization: { id: string; name: string; createdBy: string }
|
organization: { id: string; name: string; createdBy: string }
|
||||||
@@ -1340,6 +1445,7 @@ async function main() {
|
|||||||
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
|
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
|
||||||
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
||||||
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
||||||
|
await upsertApprovalMatricesForOrganization(sql, organization);
|
||||||
await upsertDocumentTemplatesForOrganization(sql, organization);
|
await upsertDocumentTemplatesForOrganization(sql, organization);
|
||||||
await upsertReportDefinitionsForOrganization(sql, organization);
|
await upsertReportDefinitionsForOrganization(sql, organization);
|
||||||
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
||||||
|
|||||||
85
src/features/crm/approval/schemas/approval-matrix.schema.ts
Normal file
85
src/features/crm/approval/schemas/approval-matrix.schema.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const nullableTrimmedString = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => value ?? null);
|
||||||
|
|
||||||
|
const nullableNumber = z
|
||||||
|
.coerce.number()
|
||||||
|
.finite()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => value ?? null);
|
||||||
|
|
||||||
|
const nullableBoolean = z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => value ?? null);
|
||||||
|
|
||||||
|
export const approvalMatrixBaseSchema = z
|
||||||
|
.object({
|
||||||
|
entityType: z.string().trim().min(1),
|
||||||
|
workflowId: z.string().trim().min(1),
|
||||||
|
branchId: nullableTrimmedString,
|
||||||
|
productType: nullableTrimmedString,
|
||||||
|
minAmount: nullableNumber,
|
||||||
|
maxAmount: nullableNumber,
|
||||||
|
currency: nullableTrimmedString,
|
||||||
|
priority: z.number().int(),
|
||||||
|
isDefault: z.boolean(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
description: nullableTrimmedString
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (
|
||||||
|
value.minAmount !== null &&
|
||||||
|
value.maxAmount !== null &&
|
||||||
|
value.minAmount > value.maxAmount
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['minAmount'],
|
||||||
|
message: 'minAmount must be less than or equal to maxAmount'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createApprovalMatrixSchema = approvalMatrixBaseSchema;
|
||||||
|
|
||||||
|
export const updateApprovalMatrixSchema = z
|
||||||
|
.object({
|
||||||
|
entityType: z.string().trim().min(1).optional(),
|
||||||
|
workflowId: z.string().trim().min(1).optional(),
|
||||||
|
branchId: nullableTrimmedString.optional(),
|
||||||
|
productType: nullableTrimmedString.optional(),
|
||||||
|
minAmount: nullableNumber.optional(),
|
||||||
|
maxAmount: nullableNumber.optional(),
|
||||||
|
currency: nullableTrimmedString.optional(),
|
||||||
|
priority: z.number().int().optional(),
|
||||||
|
isDefault: nullableBoolean.optional(),
|
||||||
|
isActive: nullableBoolean.optional(),
|
||||||
|
description: nullableTrimmedString.optional()
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (
|
||||||
|
value.minAmount !== undefined &&
|
||||||
|
value.maxAmount !== undefined &&
|
||||||
|
value.minAmount !== null &&
|
||||||
|
value.maxAmount !== null &&
|
||||||
|
value.minAmount > value.maxAmount
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['minAmount'],
|
||||||
|
message: 'minAmount must be less than or equal to maxAmount'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateApprovalMatrixInput = z.infer<typeof createApprovalMatrixSchema>;
|
||||||
|
export type UpdateApprovalMatrixInput = z.infer<typeof updateApprovalMatrixSchema>;
|
||||||
497
src/features/crm/approval/server/service.ts
Normal file
497
src/features/crm/approval/server/service.ts
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import { and, asc, count, desc, eq, isNull, ne, or } from 'drizzle-orm';
|
||||||
|
import { crmApprovalMatrices, crmApprovalWorkflows, crmQuotations } from '@/db/schema';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { submitForApproval } from '@/features/foundation/approval/server/service';
|
||||||
|
import {
|
||||||
|
type CrmSecurityContext,
|
||||||
|
auditCrmSecurityEvent,
|
||||||
|
canAccessScopedCrmRecord
|
||||||
|
} from '@/features/crm/security/server/service';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import type {
|
||||||
|
CreateApprovalMatrixInput,
|
||||||
|
UpdateApprovalMatrixInput
|
||||||
|
} from '../schemas/approval-matrix.schema';
|
||||||
|
|
||||||
|
export interface ApprovalMatrixRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
entityType: string;
|
||||||
|
workflowId: string;
|
||||||
|
branchId: string | null;
|
||||||
|
productType: string | null;
|
||||||
|
minAmount: number | null;
|
||||||
|
maxAmount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
priority: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
description: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveApprovalWorkflowInput {
|
||||||
|
organizationId: string;
|
||||||
|
entityType: 'quotation';
|
||||||
|
branchId?: string | null;
|
||||||
|
productType?: string | null;
|
||||||
|
amount?: number | null;
|
||||||
|
currency?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalWorkflowResolution {
|
||||||
|
matrixId: string;
|
||||||
|
workflowId: string;
|
||||||
|
matchReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatrixCandidate {
|
||||||
|
row: typeof crmApprovalMatrices.$inferSelect;
|
||||||
|
specificity: number;
|
||||||
|
matchReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApprovalMatrixRecord(row: typeof crmApprovalMatrices.$inferSelect): ApprovalMatrixRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
entityType: row.entityType,
|
||||||
|
workflowId: row.workflowId,
|
||||||
|
branchId: row.branchId ?? null,
|
||||||
|
productType: row.productType ?? null,
|
||||||
|
minAmount: row.minAmount ?? null,
|
||||||
|
maxAmount: row.maxAmount ?? null,
|
||||||
|
currency: row.currency ?? null,
|
||||||
|
priority: row.priority,
|
||||||
|
isDefault: row.isDefault,
|
||||||
|
isActive: row.isActive,
|
||||||
|
description: row.description ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertWorkflowExists(workflowId: string, organizationId: string, entityType?: string) {
|
||||||
|
const [workflow] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalWorkflows)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalWorkflows.id, workflowId),
|
||||||
|
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||||
|
eq(crmApprovalWorkflows.isActive, true),
|
||||||
|
isNull(crmApprovalWorkflows.deletedAt),
|
||||||
|
...(entityType ? [eq(crmApprovalWorkflows.entityType, entityType)] : [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!workflow) {
|
||||||
|
throw new AuthError('Approval workflow not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertApprovalMatrix(id: string, organizationId: string) {
|
||||||
|
const [matrix] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalMatrices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalMatrices.id, id),
|
||||||
|
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||||
|
isNull(crmApprovalMatrices.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!matrix) {
|
||||||
|
throw new AuthError('Approval matrix not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatchReason(parts: string[], row: typeof crmApprovalMatrices.$inferSelect) {
|
||||||
|
const reasons = [...parts];
|
||||||
|
if (row.isDefault) {
|
||||||
|
reasons.push('default fallback');
|
||||||
|
}
|
||||||
|
reasons.push(`priority ${row.priority}`);
|
||||||
|
return reasons.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidate(
|
||||||
|
row: typeof crmApprovalMatrices.$inferSelect,
|
||||||
|
input: ResolveApprovalWorkflowInput
|
||||||
|
): MatrixCandidate | null {
|
||||||
|
if (!row.isActive || row.deletedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.productType && row.productType !== input.productType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.branchId && row.branchId !== input.branchId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.currency && row.currency !== input.currency) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.minAmount !== null && row.minAmount !== undefined) {
|
||||||
|
if (input.amount === null || input.amount === undefined || input.amount < row.minAmount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.maxAmount !== null && row.maxAmount !== undefined) {
|
||||||
|
if (input.amount === null || input.amount === undefined || input.amount > row.maxAmount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let specificity = row.isDefault ? -100 : 0;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
if (row.productType) {
|
||||||
|
specificity += 10;
|
||||||
|
reasons.push('product type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.branchId) {
|
||||||
|
specificity += 10;
|
||||||
|
reasons.push('branch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.currency) {
|
||||||
|
specificity += 5;
|
||||||
|
reasons.push('currency');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.minAmount !== null || row.maxAmount !== null) {
|
||||||
|
specificity += 5;
|
||||||
|
reasons.push('amount range');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
specificity,
|
||||||
|
matchReason: buildMatchReason(reasons, row)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listApprovalMatrices(organizationId: string): Promise<ApprovalMatrixRecord[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalMatrices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||||
|
isNull(crmApprovalMatrices.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(crmApprovalMatrices.entityType),
|
||||||
|
asc(crmApprovalMatrices.priority),
|
||||||
|
desc(crmApprovalMatrices.updatedAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(mapApprovalMatrixRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalMatrix(id: string, organizationId: string): Promise<ApprovalMatrixRecord> {
|
||||||
|
const matrix = await assertApprovalMatrix(id, organizationId);
|
||||||
|
return mapApprovalMatrixRecord(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApprovalMatrix(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: CreateApprovalMatrixInput
|
||||||
|
): Promise<ApprovalMatrixRecord> {
|
||||||
|
await assertWorkflowExists(payload.workflowId, organizationId, payload.entityType);
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmApprovalMatrices)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
entityType: payload.entityType.trim(),
|
||||||
|
workflowId: payload.workflowId.trim(),
|
||||||
|
branchId: payload.branchId,
|
||||||
|
productType: payload.productType,
|
||||||
|
minAmount: payload.minAmount,
|
||||||
|
maxAmount: payload.maxAmount,
|
||||||
|
currency: payload.currency,
|
||||||
|
priority: payload.priority,
|
||||||
|
isDefault: payload.isDefault,
|
||||||
|
isActive: payload.isActive,
|
||||||
|
description: payload.description,
|
||||||
|
createdBy: userId,
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapApprovalMatrixRecord(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApprovalMatrix(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: UpdateApprovalMatrixInput
|
||||||
|
): Promise<ApprovalMatrixRecord> {
|
||||||
|
const current = await assertApprovalMatrix(id, organizationId);
|
||||||
|
|
||||||
|
const nextEntityType = payload.entityType?.trim() ?? current.entityType;
|
||||||
|
const nextWorkflowId = payload.workflowId?.trim() ?? current.workflowId;
|
||||||
|
const nextMinAmount = payload.minAmount !== undefined ? payload.minAmount : current.minAmount;
|
||||||
|
const nextMaxAmount = payload.maxAmount !== undefined ? payload.maxAmount : current.maxAmount;
|
||||||
|
|
||||||
|
if (nextMinAmount !== null && nextMaxAmount !== null && nextMinAmount > nextMaxAmount) {
|
||||||
|
throw new AuthError('minAmount must be less than or equal to maxAmount', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertWorkflowExists(nextWorkflowId, organizationId, nextEntityType);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmApprovalMatrices)
|
||||||
|
.set({
|
||||||
|
entityType: nextEntityType,
|
||||||
|
workflowId: nextWorkflowId,
|
||||||
|
branchId: payload.branchId !== undefined ? payload.branchId : current.branchId,
|
||||||
|
productType: payload.productType !== undefined ? payload.productType : current.productType,
|
||||||
|
minAmount: nextMinAmount,
|
||||||
|
maxAmount: nextMaxAmount,
|
||||||
|
currency: payload.currency !== undefined ? payload.currency : current.currency,
|
||||||
|
priority: payload.priority ?? current.priority,
|
||||||
|
isDefault: payload.isDefault ?? current.isDefault,
|
||||||
|
isActive: payload.isActive ?? current.isActive,
|
||||||
|
description: payload.description !== undefined ? payload.description : current.description,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmApprovalMatrices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapApprovalMatrixRecord(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function softDeleteApprovalMatrix(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<{ before: ApprovalMatrixRecord; after: ApprovalMatrixRecord }> {
|
||||||
|
const current = await assertApprovalMatrix(id, organizationId);
|
||||||
|
|
||||||
|
if (current.isDefault && current.isActive) {
|
||||||
|
const [defaultCount] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(crmApprovalMatrices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||||
|
eq(crmApprovalMatrices.entityType, current.entityType),
|
||||||
|
eq(crmApprovalMatrices.isDefault, true),
|
||||||
|
eq(crmApprovalMatrices.isActive, true),
|
||||||
|
isNull(crmApprovalMatrices.deletedAt),
|
||||||
|
ne(crmApprovalMatrices.id, id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!defaultCount || defaultCount.value === 0) {
|
||||||
|
throw new AuthError('At least one active default matrix is required for this entity type', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmApprovalMatrices)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmApprovalMatrices.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: mapApprovalMatrixRecord(current),
|
||||||
|
after: mapApprovalMatrixRecord(updated)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveApprovalWorkflowForEntity(
|
||||||
|
input: ResolveApprovalWorkflowInput
|
||||||
|
): Promise<ApprovalWorkflowResolution> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalMatrices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalMatrices.organizationId, input.organizationId),
|
||||||
|
eq(crmApprovalMatrices.entityType, input.entityType),
|
||||||
|
eq(crmApprovalMatrices.isActive, true),
|
||||||
|
isNull(crmApprovalMatrices.deletedAt),
|
||||||
|
or(eq(crmApprovalMatrices.branchId, input.branchId ?? ''), isNull(crmApprovalMatrices.branchId)),
|
||||||
|
or(
|
||||||
|
eq(crmApprovalMatrices.productType, input.productType ?? ''),
|
||||||
|
isNull(crmApprovalMatrices.productType)
|
||||||
|
),
|
||||||
|
or(eq(crmApprovalMatrices.currency, input.currency ?? ''), isNull(crmApprovalMatrices.currency))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmApprovalMatrices.createdAt), desc(crmApprovalMatrices.id));
|
||||||
|
|
||||||
|
const candidates = rows
|
||||||
|
.map((row) => buildCandidate(row, input))
|
||||||
|
.filter((candidate): candidate is MatrixCandidate => candidate !== null);
|
||||||
|
|
||||||
|
if (!candidates.length) {
|
||||||
|
throw new AuthError('No approval matrix matched and no default matrix is configured', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specificCandidates = candidates.filter((candidate) => !candidate.row.isDefault);
|
||||||
|
const defaultCandidates = candidates.filter((candidate) => candidate.row.isDefault);
|
||||||
|
const workingSet = specificCandidates.length > 0 ? specificCandidates : defaultCandidates;
|
||||||
|
|
||||||
|
if (!workingSet.length) {
|
||||||
|
throw new AuthError('No default approval matrix is configured for this entity type', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = [...workingSet].sort((left, right) => {
|
||||||
|
if (left.specificity !== right.specificity) {
|
||||||
|
return right.specificity - left.specificity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.row.priority !== right.row.priority) {
|
||||||
|
return left.row.priority - right.row.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdDiff = right.row.createdAt.getTime() - left.row.createdAt.getTime();
|
||||||
|
if (createdDiff !== 0) {
|
||||||
|
return createdDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.row.id.localeCompare(left.row.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [winner, runnerUp] = ranked;
|
||||||
|
|
||||||
|
if (
|
||||||
|
runnerUp &&
|
||||||
|
winner.specificity === runnerUp.specificity &&
|
||||||
|
winner.row.priority === runnerUp.row.priority &&
|
||||||
|
winner.row.createdAt.getTime() === runnerUp.row.createdAt.getTime()
|
||||||
|
) {
|
||||||
|
throw new AuthError(
|
||||||
|
`Approval matrix conflict between ${winner.row.id} and ${runnerUp.row.id}`,
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertWorkflowExists(winner.row.workflowId, input.organizationId, input.entityType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matrixId: winner.row.id,
|
||||||
|
workflowId: winner.row.workflowId,
|
||||||
|
matchReason: winner.matchReason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitQuotationForApproval(
|
||||||
|
input: {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
quotationId: string;
|
||||||
|
remark?: string;
|
||||||
|
accessContext: CrmSecurityContext;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const [quotation] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.id, input.quotationId),
|
||||||
|
eq(crmQuotations.organizationId, input.organizationId),
|
||||||
|
isNull(crmQuotations.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!quotation) {
|
||||||
|
throw new AuthError('Quotation not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccess = canAccessScopedCrmRecord(input.accessContext, {
|
||||||
|
branchId: quotation.branchId,
|
||||||
|
productType: quotation.quotationType,
|
||||||
|
createdBy: quotation.createdBy,
|
||||||
|
ownerUserId: quotation.salesmanId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
await auditCrmSecurityEvent({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
userId: input.userId,
|
||||||
|
action: 'approval_scope_violation',
|
||||||
|
entityType: 'crm_quotation',
|
||||||
|
entityId: input.quotationId,
|
||||||
|
reason: 'User attempted to submit approval outside resolved CRM scope',
|
||||||
|
metadata: {
|
||||||
|
branchId: quotation.branchId,
|
||||||
|
productType: quotation.quotationType
|
||||||
|
}
|
||||||
|
});
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolution = await resolveApprovalWorkflowForEntity({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
entityType: 'quotation',
|
||||||
|
branchId: quotation.branchId,
|
||||||
|
productType: quotation.quotationType,
|
||||||
|
amount: quotation.totalAmount,
|
||||||
|
currency: quotation.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = await submitForApproval(input.organizationId, input.userId, {
|
||||||
|
workflowId: resolution.workflowId,
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: input.quotationId,
|
||||||
|
remark: input.remark
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
userId: input.userId,
|
||||||
|
entityType: 'crm_approval_matrix',
|
||||||
|
entityId: resolution.matrixId,
|
||||||
|
action: 'resolve_approval_matrix',
|
||||||
|
afterData: {
|
||||||
|
quotationId: input.quotationId,
|
||||||
|
approvalRequestId: request.id,
|
||||||
|
matrixId: resolution.matrixId,
|
||||||
|
workflowId: resolution.workflowId,
|
||||||
|
matchReason: resolution.matchReason,
|
||||||
|
productType: quotation.quotationType,
|
||||||
|
branchId: quotation.branchId,
|
||||||
|
amount: quotation.totalAmount,
|
||||||
|
currency: quotation.currency
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { request, resolution };
|
||||||
|
}
|
||||||
@@ -987,7 +987,9 @@ export async function submitForApproval(
|
|||||||
userId: string,
|
userId: string,
|
||||||
payload: SubmitApprovalPayload
|
payload: SubmitApprovalPayload
|
||||||
) {
|
) {
|
||||||
const workflow = await assertWorkflowByCode(
|
const workflow = payload.workflowId
|
||||||
|
? await assertWorkflowById(payload.workflowId, organizationId)
|
||||||
|
: await assertWorkflowByCode(
|
||||||
organizationId,
|
organizationId,
|
||||||
payload.workflowCode ?? `${payload.entityType}_standard_approval`,
|
payload.workflowCode ?? `${payload.entityType}_standard_approval`,
|
||||||
payload.entityType
|
payload.entityType
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface ApprovalFilters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitApprovalPayload {
|
export interface SubmitApprovalPayload {
|
||||||
|
workflowId?: string;
|
||||||
workflowCode?: string;
|
workflowCode?: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export const PERMISSIONS = {
|
|||||||
crmApprovalApprove: 'crm.approval.approve',
|
crmApprovalApprove: 'crm.approval.approve',
|
||||||
crmApprovalReject: 'crm.approval.reject',
|
crmApprovalReject: 'crm.approval.reject',
|
||||||
crmApprovalReturn: 'crm.approval.return',
|
crmApprovalReturn: 'crm.approval.return',
|
||||||
|
crmApprovalMatrixRead: 'crm.approval.matrix.read',
|
||||||
|
crmApprovalMatrixCreate: 'crm.approval.matrix.create',
|
||||||
|
crmApprovalMatrixUpdate: 'crm.approval.matrix.update',
|
||||||
|
crmApprovalMatrixDelete: 'crm.approval.matrix.delete',
|
||||||
crmApprovalWorkflowRead: 'crm.approval.workflow.read',
|
crmApprovalWorkflowRead: 'crm.approval.workflow.read',
|
||||||
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
|
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
|
||||||
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
|
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
|
||||||
@@ -441,6 +445,10 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmMasterOptionCreate,
|
PERMISSIONS.crmMasterOptionCreate,
|
||||||
PERMISSIONS.crmMasterOptionUpdate,
|
PERMISSIONS.crmMasterOptionUpdate,
|
||||||
PERMISSIONS.crmMasterOptionDelete,
|
PERMISSIONS.crmMasterOptionDelete,
|
||||||
|
PERMISSIONS.crmApprovalMatrixRead,
|
||||||
|
PERMISSIONS.crmApprovalMatrixCreate,
|
||||||
|
PERMISSIONS.crmApprovalMatrixUpdate,
|
||||||
|
PERMISSIONS.crmApprovalMatrixDelete,
|
||||||
PERMISSIONS.crmApprovalWorkflowRead,
|
PERMISSIONS.crmApprovalWorkflowRead,
|
||||||
PERMISSIONS.crmApprovalWorkflowCreate,
|
PERMISSIONS.crmApprovalWorkflowCreate,
|
||||||
PERMISSIONS.crmApprovalWorkflowUpdate,
|
PERMISSIONS.crmApprovalWorkflowUpdate,
|
||||||
@@ -597,6 +605,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
|
{ key: PERMISSIONS.crmMasterOptionCreate, label: 'Create master options' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
|
{ key: PERMISSIONS.crmMasterOptionUpdate, label: 'Update master options' },
|
||||||
{ key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' },
|
{ key: PERMISSIONS.crmMasterOptionDelete, label: 'Delete master options' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalMatrixRead, label: 'Read approval matrices' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalMatrixCreate, label: 'Create approval matrices' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalMatrixUpdate, label: 'Update approval matrices' },
|
||||||
|
{ key: PERMISSIONS.crmApprovalMatrixDelete, label: 'Delete approval matrices' },
|
||||||
{ key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' },
|
{ key: PERMISSIONS.crmApprovalWorkflowRead, label: 'Read approval workflows' },
|
||||||
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
|
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
|
||||||
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
|
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
|
||||||
|
|||||||
Reference in New Issue
Block a user