task-h.4
This commit is contained in:
63
docs/adr/0012-approval-signature-strategy.md
Normal file
63
docs/adr/0012-approval-signature-strategy.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# ADR 0012: Approval Signature Strategy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Quotation PDF templates already contain the signature placeholders `app1`, `app2`, `app3` and their position fields, but earlier tasks only seeded safe fallback values. The production document flow now needs a deterministic rule for:
|
||||||
|
|
||||||
|
- who is shown as `Prepared By`
|
||||||
|
- who is shown as `Approved By`
|
||||||
|
- who is shown as `Authorized By`
|
||||||
|
- how each person’s position is resolved
|
||||||
|
- how approved documents stay immutable after approval
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Prepared By
|
||||||
|
|
||||||
|
- resolve from `crm_quotations.salesman_id`
|
||||||
|
- fallback to `crm_quotations.created_by`
|
||||||
|
- always render the user display name, never a raw id
|
||||||
|
- use quotation `created_at` as the available timestamp for the prepared block
|
||||||
|
|
||||||
|
### Approved By
|
||||||
|
|
||||||
|
- resolve from live approval history in `crm_approval_actions`
|
||||||
|
- consider only `approve` actions
|
||||||
|
- choose the latest approved action whose workflow role is one of:
|
||||||
|
- `sales_manager`
|
||||||
|
- `department_manager`
|
||||||
|
- exclude the workflow’s final approval role from this slot
|
||||||
|
|
||||||
|
### Authorized By
|
||||||
|
|
||||||
|
- resolve from the latest approved action for the workflow’s final step
|
||||||
|
- in the standard workflow this is expected to be `top_manager`
|
||||||
|
|
||||||
|
### Position Resolution
|
||||||
|
|
||||||
|
- primary source is `memberships.business_role`
|
||||||
|
- display label should come from active `ms_options` in category `crm_job_title` when available
|
||||||
|
- if the master option is missing, fallback labels are:
|
||||||
|
- `sales` -> `Sales Engineer`
|
||||||
|
- `sales_support` -> `Sales Support`
|
||||||
|
- `sales_manager` -> `Sales Manager`
|
||||||
|
- `department_manager` -> `Department Manager`
|
||||||
|
- `top_manager` -> `General Manager`
|
||||||
|
|
||||||
|
### Snapshot Immutability
|
||||||
|
|
||||||
|
- preview PDF resolves signatures from live quotation and approval data
|
||||||
|
- approved snapshot stores the resolved signature block and resolved template input
|
||||||
|
- approved PDF generation prefers snapshot signature input when a snapshot already exists
|
||||||
|
- locked approved artifacts remain the immutable historical source
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- signature placeholder mapping is now explicit and organization-aware
|
||||||
|
- template output no longer depends on hardcoded placeholder text in the PDFME template
|
||||||
|
- position labels can be governed centrally through master options without changing generator code
|
||||||
|
- approved documents preserve approval identity even if user names, memberships, or master options change later
|
||||||
78
docs/implementation/task-h3-approval-signature-strategy.md
Normal file
78
docs/implementation/task-h3-approval-signature-strategy.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Task H.3: Approval Signature Strategy
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
- `src/features/crm/quotations/document/signature.types.ts`
|
||||||
|
- `src/features/crm/quotations/document/signature-position.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/signature-resolver.ts`
|
||||||
|
- `scripts/verify-task-h3.js`
|
||||||
|
- `docs/adr/0012-approval-signature-strategy.md`
|
||||||
|
- `docs/implementation/task-h3-approval-signature-strategy.md`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `src/features/crm/quotations/document/types.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/service.ts`
|
||||||
|
- `src/features/crm/quotations/components/quotation-document-preview.tsx`
|
||||||
|
- `src/features/foundation/pdf-generator/server/service.ts`
|
||||||
|
- `src/db/seeds/foundation.seed.ts`
|
||||||
|
- `docs/implementation/technical-debt.md`
|
||||||
|
- `package.json`
|
||||||
|
|
||||||
|
## Signature Resolution Strategy
|
||||||
|
|
||||||
|
- `Prepared By` resolves from `quotation.salesmanId`, then falls back to `quotation.createdBy`
|
||||||
|
- `Approved By` resolves from the latest approved workflow action for `sales_manager` or `department_manager`, excluding the final authorization step
|
||||||
|
- `Authorized By` resolves from the latest approved workflow action for the final workflow step, which is `top_manager` in the standard flow
|
||||||
|
- each signature block now carries:
|
||||||
|
- `name`
|
||||||
|
- `position`
|
||||||
|
- `actedAt`
|
||||||
|
|
||||||
|
## Position Resolution Strategy
|
||||||
|
|
||||||
|
- primary source is `memberships.businessRole`
|
||||||
|
- display label first checks active `crm_job_title` master options
|
||||||
|
- fallback labels are:
|
||||||
|
- `sales` -> `Sales Engineer`
|
||||||
|
- `sales_support` -> `Sales Support`
|
||||||
|
- `sales_manager` -> `Sales Manager`
|
||||||
|
- `department_manager` -> `Department Manager`
|
||||||
|
- `top_manager` -> `General Manager`
|
||||||
|
|
||||||
|
## PDF Mapping Changes
|
||||||
|
|
||||||
|
- `app1` -> `signatures.preparedBy.name`
|
||||||
|
- `app1_position` -> `signatures.preparedBy.position`
|
||||||
|
- `app2` -> `signatures.approvedBy.name`
|
||||||
|
- `app2_position` -> `signatures.approvedBy.position`
|
||||||
|
- `app3` -> `signatures.authorizedBy.name`
|
||||||
|
- `app3_position` -> `signatures.authorizedBy.position`
|
||||||
|
|
||||||
|
## Snapshot Changes
|
||||||
|
|
||||||
|
- `prepareApprovedQuotationSnapshot()` now stores the resolved signature block through `documentData`
|
||||||
|
- persisted `templateInput` now preserves the text values used for `app1/app2/app3`
|
||||||
|
- approved PDF generation prefers snapshot input when a snapshot already exists
|
||||||
|
|
||||||
|
## UI Changes
|
||||||
|
|
||||||
|
- quotation document preview now shows an `Approval Signatures` section
|
||||||
|
- each block displays:
|
||||||
|
- name
|
||||||
|
- position
|
||||||
|
- timestamp when available
|
||||||
|
|
||||||
|
## Verification Result
|
||||||
|
|
||||||
|
- `scripts/verify-task-h3.js` validates:
|
||||||
|
- DB mappings for `app1/app2/app3`
|
||||||
|
- `crm_job_title` master options
|
||||||
|
- approved snapshot signature payload
|
||||||
|
- approved snapshot template input alignment
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
|
||||||
|
- organizations with custom approval roles outside the current sales-manager / department-manager / final-step model may need an extended resolver rule later
|
||||||
|
- `Prepared By` currently uses quotation `createdAt` as the available timestamp because the system does not yet store a separate “prepared at” event
|
||||||
|
- end-to-end PDF binary text extraction is still not automated; verification currently proves snapshot and template-input integrity
|
||||||
@@ -206,13 +206,11 @@ Future:
|
|||||||
Current:
|
Current:
|
||||||
|
|
||||||
- payment, warranty, delivery, and extra topic sections now flow through the dynamic `topic` / `data_topic` engine on page 2 instead of fixed placeholder fields
|
- payment, warranty, delivery, and extra topic sections now flow through the dynamic `topic` / `data_topic` engine on page 2 instead of fixed placeholder fields
|
||||||
- approval signature fields are not mapped yet
|
- approval signature placeholders now resolve through the H.3 signature strategy and `crm_job_title` position lookup
|
||||||
- signature placeholders `app1/app2/app3` currently rely on safe fallback mapping and still need Task H.3 strategy for real names and positions
|
|
||||||
|
|
||||||
Future:
|
Future:
|
||||||
|
|
||||||
- keep validating pagination and closing-block placement against production designer expectations for large topic sets
|
- keep validating pagination and closing-block placement against production designer expectations for large topic sets
|
||||||
- map approval signature fields when approval-to-document ownership is formalized
|
|
||||||
- add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available
|
- add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available
|
||||||
|
|
||||||
## After Task I
|
## After Task I
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
||||||
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
|
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||||
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
|
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
530
plans/task-h.3.md
Normal file
530
plans/task-h.3.md
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
# Task H.3: Approval Signature Strategy
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
ทำให้ PDF ใบเสนอราคาสามารถแสดงข้อมูลผู้จัดทำ ผู้อนุมัติ และผู้มีอำนาจอนุมัติได้อย่างถูกต้อง
|
||||||
|
|
||||||
|
รองรับทั้ง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Preview PDF
|
||||||
|
Approved PDF
|
||||||
|
Approved Snapshot
|
||||||
|
Document Artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
โดยข้อมูลลายเซ็นต้องมาจาก Approval Workflow จริง ไม่ใช่ค่าคงที่ใน Template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Task H.2 เพิ่ม:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Dynamic Topic Engine
|
||||||
|
PDFME Runtime Mapping
|
||||||
|
Template Mapping Resolver
|
||||||
|
```
|
||||||
|
|
||||||
|
Task H.1 เพิ่ม:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approved Snapshot
|
||||||
|
Artifact Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
Task H.3 จะเติมส่วนที่ยังขาด:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Prepared By
|
||||||
|
Approved By
|
||||||
|
Authorized By
|
||||||
|
Position
|
||||||
|
Approval Timeline Resolution
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Problem
|
||||||
|
|
||||||
|
Template ปัจจุบันมี placeholder เช่น:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app1
|
||||||
|
app1_position
|
||||||
|
|
||||||
|
app2
|
||||||
|
app2_position
|
||||||
|
|
||||||
|
app3
|
||||||
|
app3_position
|
||||||
|
```
|
||||||
|
|
||||||
|
แต่ยังไม่มี strategy กลางสำหรับดึงข้อมูลจริงจาก:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation
|
||||||
|
approval workflow
|
||||||
|
approval actions
|
||||||
|
membership
|
||||||
|
business role
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.1 Signature Data Contract
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/signature.types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Define:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SignatureBlock = {
|
||||||
|
preparedBy: {
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
approvedBy: {
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
authorizedBy: {
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.2 Position Resolution Strategy
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/signature-position.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve position from:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
membership.businessRole
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended mapping:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
sales
|
||||||
|
→ Sales Engineer
|
||||||
|
|
||||||
|
sales_support
|
||||||
|
→ Sales Support
|
||||||
|
|
||||||
|
sales_manager
|
||||||
|
→ Sales Manager
|
||||||
|
|
||||||
|
department_manager
|
||||||
|
→ Department Manager
|
||||||
|
|
||||||
|
top_manager
|
||||||
|
→ General Manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Do NOT hardcode display strings inside PDF generator.
|
||||||
|
|
||||||
|
Source from:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_job_title
|
||||||
|
```
|
||||||
|
|
||||||
|
master option when available.
|
||||||
|
|
||||||
|
Fallback:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
businessRole label
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.3 Prepared By Resolution
|
||||||
|
|
||||||
|
Prepared By source:
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation.salesmanId
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation.createdBy
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
preparedBy.name
|
||||||
|
preparedBy.position
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Must resolve display name.
|
||||||
|
|
||||||
|
Never use raw user id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.4 Approved By Resolution
|
||||||
|
|
||||||
|
Resolve from approval workflow history.
|
||||||
|
|
||||||
|
Source:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Find latest approved action from:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
sales_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```txt
|
||||||
|
department_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
depending on workflow configuration.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
approvedBy.name
|
||||||
|
approvedBy.position
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.5 Authorized By Resolution
|
||||||
|
|
||||||
|
Resolve from final approval step.
|
||||||
|
|
||||||
|
Source:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Find final approved action.
|
||||||
|
|
||||||
|
Expected role:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
top_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
authorizedBy.name
|
||||||
|
authorizedBy.position
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.6 Signature Resolver
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/signature-resolver.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expose:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolveQuotationSignatures(
|
||||||
|
quotationId,
|
||||||
|
organizationId
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
SignatureBlock
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
load quotation
|
||||||
|
load approval history
|
||||||
|
resolve users
|
||||||
|
resolve positions
|
||||||
|
build final signature object
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.7 PDF Mapping Integration
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Inject:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
signatures.preparedBy.name
|
||||||
|
signatures.preparedBy.position
|
||||||
|
|
||||||
|
signatures.approvedBy.name
|
||||||
|
signatures.approvedBy.position
|
||||||
|
|
||||||
|
signatures.authorizedBy.name
|
||||||
|
signatures.authorizedBy.position
|
||||||
|
```
|
||||||
|
|
||||||
|
Into:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
documentData
|
||||||
|
templateInput
|
||||||
|
approvedSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.8 PDFME Placeholder Mapping
|
||||||
|
|
||||||
|
Update seed mappings.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app1
|
||||||
|
→ signatures.preparedBy.name
|
||||||
|
|
||||||
|
app1_position
|
||||||
|
→ signatures.preparedBy.position
|
||||||
|
|
||||||
|
app2
|
||||||
|
→ signatures.approvedBy.name
|
||||||
|
|
||||||
|
app2_position
|
||||||
|
→ signatures.approvedBy.position
|
||||||
|
|
||||||
|
app3
|
||||||
|
→ signatures.authorizedBy.name
|
||||||
|
|
||||||
|
app3_position
|
||||||
|
→ signatures.authorizedBy.position
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Idempotent seed.
|
||||||
|
|
||||||
|
Update existing mappings if incorrect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.9 Approved Snapshot Integration
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
prepareApprovedQuotationSnapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshot must store:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
resolved signatures
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Future PDF regeneration must preserve:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
who approved
|
||||||
|
who authorized
|
||||||
|
```
|
||||||
|
|
||||||
|
even if users later change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.10 Preview Behavior
|
||||||
|
|
||||||
|
Preview PDF:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
resolve live signatures
|
||||||
|
```
|
||||||
|
|
||||||
|
Approved PDF:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
prefer approved snapshot signatures
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
Approved artifact must remain immutable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.11 UI Visibility
|
||||||
|
|
||||||
|
Update quotation detail.
|
||||||
|
|
||||||
|
Add section:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Signatures
|
||||||
|
```
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Prepared By
|
||||||
|
Approved By
|
||||||
|
Authorized By
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
name
|
||||||
|
position
|
||||||
|
approval timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
if available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H3.12 Verification
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scripts/verify-task-h3.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
preparedBy exists
|
||||||
|
approvedBy exists
|
||||||
|
authorizedBy exists
|
||||||
|
|
||||||
|
app1 mapped
|
||||||
|
app2 mapped
|
||||||
|
app3 mapped
|
||||||
|
|
||||||
|
snapshot contains signatures
|
||||||
|
|
||||||
|
approved PDF contains names
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/adr/0012-approval-signature-strategy.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Freeze:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Prepared By rule
|
||||||
|
Approved By rule
|
||||||
|
Authorized By rule
|
||||||
|
Position resolution rule
|
||||||
|
Snapshot immutability rule
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Digital signature
|
||||||
|
Image signature
|
||||||
|
Certificate signature
|
||||||
|
PKI
|
||||||
|
e-Signature
|
||||||
|
QR verification
|
||||||
|
Stamp image upload
|
||||||
|
```
|
||||||
|
|
||||||
|
Only text-based approval identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Signature Resolution Strategy
|
||||||
|
4. Position Resolution Strategy
|
||||||
|
5. PDF Mapping Changes
|
||||||
|
6. Snapshot Changes
|
||||||
|
7. Verification Result
|
||||||
|
8. ADR Added
|
||||||
|
9. Remaining Risks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
✔ Prepared By resolves correctly
|
||||||
|
|
||||||
|
✔ Approved By resolves correctly
|
||||||
|
|
||||||
|
✔ Authorized By resolves correctly
|
||||||
|
|
||||||
|
✔ Position resolves correctly
|
||||||
|
|
||||||
|
✔ PDF Preview displays signatures
|
||||||
|
|
||||||
|
✔ Approved PDF displays signatures
|
||||||
|
|
||||||
|
✔ Approved Snapshot stores signatures
|
||||||
|
|
||||||
|
✔ app1/app2/app3 mappings work
|
||||||
|
|
||||||
|
✔ Artifact remains immutable
|
||||||
|
|
||||||
|
✔ ADR created
|
||||||
198
scripts/verify-task-h3.js
Normal file
198
scripts/verify-task-h3.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const postgres = require('postgres');
|
||||||
|
|
||||||
|
function loadEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
|
||||||
|
if (!line || line.startsWith('#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = line.indexOf('=');
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = line.slice(0, separatorIndex).trim();
|
||||||
|
let value = line.slice(separatorIndex + 1).trim();
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(key in process.env)) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalEnv() {
|
||||||
|
loadEnvFile(path.resolve(process.cwd(), '.env.local'));
|
||||||
|
loadEnvFile(path.resolve(process.cwd(), '.env'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSnapshotValue(snapshot, pathSegments) {
|
||||||
|
return pathSegments.reduce((value, segment) => {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value[segment];
|
||||||
|
}, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadLocalEnv();
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
|
||||||
|
assert(databaseUrl, 'Missing DATABASE_URL');
|
||||||
|
|
||||||
|
const sql = postgres(databaseUrl, { max: 1 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mappingRows = await sql`
|
||||||
|
select placeholder_key, source_path
|
||||||
|
from crm_document_template_mappings
|
||||||
|
where placeholder_key in ('app1', 'app1_position', 'app2', 'app2_position', 'app3', 'app3_position')
|
||||||
|
and deleted_at is null
|
||||||
|
order by placeholder_key asc
|
||||||
|
`;
|
||||||
|
const mappingMap = new Map(mappingRows.map((row) => [row.placeholder_key, row.source_path]));
|
||||||
|
|
||||||
|
assert(mappingMap.get('app1') === 'signatures.preparedBy.name', 'app1 mapping is incorrect');
|
||||||
|
assert(
|
||||||
|
mappingMap.get('app1_position') === 'signatures.preparedBy.position',
|
||||||
|
'app1_position mapping is incorrect'
|
||||||
|
);
|
||||||
|
assert(mappingMap.get('app2') === 'signatures.approvedBy.name', 'app2 mapping is incorrect');
|
||||||
|
assert(
|
||||||
|
mappingMap.get('app2_position') === 'signatures.approvedBy.position',
|
||||||
|
'app2_position mapping is incorrect'
|
||||||
|
);
|
||||||
|
assert(mappingMap.get('app3') === 'signatures.authorizedBy.name', 'app3 mapping is incorrect');
|
||||||
|
assert(
|
||||||
|
mappingMap.get('app3_position') === 'signatures.authorizedBy.position',
|
||||||
|
'app3_position mapping is incorrect'
|
||||||
|
);
|
||||||
|
|
||||||
|
const [snapshotRow] = await sql`
|
||||||
|
select
|
||||||
|
q.id,
|
||||||
|
q.code,
|
||||||
|
q.organization_id,
|
||||||
|
q.approved_snapshot
|
||||||
|
from crm_quotations q
|
||||||
|
where q.approved_snapshot is not null
|
||||||
|
and q.deleted_at is null
|
||||||
|
order by q.updated_at desc
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert(
|
||||||
|
snapshotRow,
|
||||||
|
'No quotation with approved_snapshot was found. Generate at least one approved PDF before running verify-task-h3.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = snapshotRow.approved_snapshot;
|
||||||
|
const signatures = pickSnapshotValue(snapshot, ['documentData', 'signatures']);
|
||||||
|
const templateInput = pickSnapshotValue(snapshot, ['templateInput']);
|
||||||
|
|
||||||
|
assert(signatures && typeof signatures === 'object', 'approvedSnapshot is missing signatures');
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof signatures.preparedBy === 'string' ||
|
||||||
|
typeof signatures.salesManager === 'string' ||
|
||||||
|
typeof signatures.topManager === 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'approvedSnapshot still uses the pre-H3 signature shape. Regenerate one approved PDF to backfill snapshot signatures.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ['preparedBy', 'approvedBy', 'authorizedBy']) {
|
||||||
|
const block = signatures[key];
|
||||||
|
|
||||||
|
assert(block && typeof block === 'object', `approvedSnapshot is missing ${key}`);
|
||||||
|
assert(
|
||||||
|
typeof block.name === 'string' && block.name.trim().length > 0 && block.name !== '-',
|
||||||
|
`approvedSnapshot ${key}.name is missing`
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
typeof block.position === 'string' && block.position.trim().length > 0 && block.position !== '-',
|
||||||
|
`approvedSnapshot ${key}.position is missing`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(templateInput && typeof templateInput === 'object', 'approvedSnapshot is missing templateInput');
|
||||||
|
assert(templateInput.app1 === signatures.preparedBy.name, 'templateInput.app1 does not match preparedBy');
|
||||||
|
assert(
|
||||||
|
templateInput.app1_position === signatures.preparedBy.position,
|
||||||
|
'templateInput.app1_position does not match preparedBy.position'
|
||||||
|
);
|
||||||
|
assert(templateInput.app2 === signatures.approvedBy.name, 'templateInput.app2 does not match approvedBy');
|
||||||
|
assert(
|
||||||
|
templateInput.app2_position === signatures.approvedBy.position,
|
||||||
|
'templateInput.app2_position does not match approvedBy.position'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
templateInput.app3 === signatures.authorizedBy.name,
|
||||||
|
'templateInput.app3 does not match authorizedBy'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
templateInput.app3_position === signatures.authorizedBy.position,
|
||||||
|
'templateInput.app3_position does not match authorizedBy.position'
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobTitleRows = await sql`
|
||||||
|
select code, label
|
||||||
|
from ms_options
|
||||||
|
where organization_id = ${snapshotRow.organization_id}
|
||||||
|
and category = 'crm_job_title'
|
||||||
|
and deleted_at is null
|
||||||
|
and is_active = true
|
||||||
|
order by sort_order asc
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert(jobTitleRows.length >= 5, 'crm_job_title master options are incomplete');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
quotationId: snapshotRow.id,
|
||||||
|
quotationCode: snapshotRow.code,
|
||||||
|
verifiedMappings: [...mappingMap.entries()],
|
||||||
|
verifiedJobTitles: jobTitleRows
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`[verify-task-h3] ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -285,6 +285,18 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
|
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
|
||||||
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
|
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
|
||||||
],
|
],
|
||||||
|
crm_job_title: [
|
||||||
|
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
|
||||||
|
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },
|
||||||
|
{ code: 'sales_manager', label: 'Sales Manager', value: 'sales_manager', sortOrder: 3 },
|
||||||
|
{
|
||||||
|
code: 'department_manager',
|
||||||
|
label: 'Department Manager',
|
||||||
|
value: 'department_manager',
|
||||||
|
sortOrder: 4
|
||||||
|
},
|
||||||
|
{ code: 'top_manager', label: 'General Manager', value: 'top_manager', sortOrder: 5 }
|
||||||
|
],
|
||||||
crm_lead_channel: [
|
crm_lead_channel: [
|
||||||
{ code: 'website', label: 'Website', value: 'website', sortOrder: 1 },
|
{ code: 'website', label: 'Website', value: 'website', sortOrder: 1 },
|
||||||
{ code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 },
|
{ code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 },
|
||||||
@@ -393,42 +405,42 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'app1',
|
placeholderKey: 'app1',
|
||||||
sourcePath: 'signatures.preparedBy',
|
sourcePath: 'signatures.preparedBy.name',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
defaultValue: '-',
|
defaultValue: '-',
|
||||||
sortOrder: 13
|
sortOrder: 13
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'app1_position',
|
placeholderKey: 'app1_position',
|
||||||
sourcePath: 'signatures.preparedByPosition',
|
sourcePath: 'signatures.preparedBy.position',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
defaultValue: '-',
|
defaultValue: '-',
|
||||||
sortOrder: 14
|
sortOrder: 14
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'app2',
|
placeholderKey: 'app2',
|
||||||
sourcePath: 'signatures.salesManager',
|
sourcePath: 'signatures.approvedBy.name',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
defaultValue: '-',
|
defaultValue: '-',
|
||||||
sortOrder: 15
|
sortOrder: 15
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'app2_position',
|
placeholderKey: 'app2_position',
|
||||||
sourcePath: 'signatures.salesManagerPosition',
|
sourcePath: 'signatures.approvedBy.position',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
defaultValue: '-',
|
defaultValue: '-',
|
||||||
sortOrder: 16
|
sortOrder: 16
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'app3',
|
placeholderKey: 'app3',
|
||||||
sourcePath: 'signatures.topManager',
|
sourcePath: 'signatures.authorizedBy.name',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
defaultValue: '-',
|
defaultValue: '-',
|
||||||
sortOrder: 17
|
sortOrder: 17
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'app3_position',
|
placeholderKey: 'app3_position',
|
||||||
sourcePath: 'signatures.topManagerPosition',
|
sourcePath: 'signatures.authorizedBy.position',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
defaultValue: '-',
|
defaultValue: '-',
|
||||||
sortOrder: 18
|
sortOrder: 18
|
||||||
@@ -503,7 +515,7 @@ async function upsertMasterOptionsForOrganization(
|
|||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
const parentRow =
|
const parentRow =
|
||||||
option.parentCode && option.parentCategory
|
option.parentCode && option.parentCategory
|
||||||
? (
|
? ((
|
||||||
await sql`
|
await sql`
|
||||||
select id
|
select id
|
||||||
from ms_options
|
from ms_options
|
||||||
@@ -513,7 +525,7 @@ async function upsertMasterOptionsForOrganization(
|
|||||||
and deleted_at is null
|
and deleted_at is null
|
||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
)[0] ?? null
|
)[0] ?? null)
|
||||||
: null;
|
: null;
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
insert into ms_options (
|
insert into ms_options (
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
|
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ตัวอย่างใบเสนอราคา จากเทมเพลต {template.template.templateName} v{template.version.version}
|
ตัวอย่างใบเสนอราคา จากเทมเพลต {template.template.templateName} v
|
||||||
|
{template.version.version}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
@@ -124,7 +125,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<div>{item.itemNumber}</div>
|
<div>{item.itemNumber}</div>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<div>{item.description}</div>
|
<div>{item.description}</div>
|
||||||
<div className='text-muted-foreground text-xs'>{item.productTypeLabel || '-'}</div>
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{item.productTypeLabel || '-'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{item.quantity}</div>
|
<div>{item.quantity}</div>
|
||||||
<div>{item.unitLabel || '-'}</div>
|
<div>{item.unitLabel || '-'}</div>
|
||||||
@@ -139,7 +142,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>หัวข้อเอกสาร</CardTitle>
|
<CardTitle>หัวข้อเอกสาร</CardTitle>
|
||||||
<CardDescription>ขอบเขตงาน ข้อยกเว้น และเงื่อนไขการชำระเงินจากหัวข้อในระบบ</CardDescription>
|
<CardDescription>
|
||||||
|
ขอบเขตงาน ข้อยกเว้น และเงื่อนไขการชำระเงินจากหัวข้อในระบบ
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-6'>
|
<CardContent className='space-y-6'>
|
||||||
{topicGroups.map((group) => {
|
{topicGroups.map((group) => {
|
||||||
@@ -151,7 +156,10 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<div className='text-muted-foreground text-sm'>ไม่มีข้อมูล</div>
|
<div className='text-muted-foreground text-sm'>ไม่มีข้อมูล</div>
|
||||||
) : (
|
) : (
|
||||||
entries.map((entry) => (
|
entries.map((entry) => (
|
||||||
<div key={`${group.key}-${entry.title}`} className='rounded-lg border p-4'>
|
<div
|
||||||
|
key={`${group.key}-${entry.title}`}
|
||||||
|
className='rounded-lg border p-4'
|
||||||
|
>
|
||||||
<div className='mb-2 text-sm font-medium'>{entry.title}</div>
|
<div className='mb-2 text-sm font-medium'>{entry.title}</div>
|
||||||
<ul className='list-disc space-y-1 pl-5 text-sm'>
|
<ul className='list-disc space-y-1 pl-5 text-sm'>
|
||||||
{entry.items.map((item) => (
|
{entry.items.map((item) => (
|
||||||
@@ -183,7 +191,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='text-muted-foreground'>รวมทั้งสิ้น</span>
|
<span className='text-muted-foreground'>รวมทั้งสิ้น</span>
|
||||||
<span className='font-semibold'>{documentData.quotation.totalAmount.toLocaleString()}</span>
|
<span className='font-semibold'>
|
||||||
|
{documentData.quotation.totalAmount.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -202,12 +212,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<div className='text-muted-foreground text-sm'>ยังไม่มีประวัติการอนุมัติ</div>
|
<div className='text-muted-foreground text-sm'>ยังไม่มีประวัติการอนุมัติ</div>
|
||||||
) : (
|
) : (
|
||||||
documentData.approval.approvers.map((item) => (
|
documentData.approval.approvers.map((item) => (
|
||||||
<div key={`${item.stepNumber}-${item.roleCode}`} className='rounded-lg border p-3 text-sm'>
|
<div
|
||||||
|
key={`${item.stepNumber}-${item.roleCode}`}
|
||||||
|
className='rounded-lg border p-3 text-sm'
|
||||||
|
>
|
||||||
<div className='font-medium'>
|
<div className='font-medium'>
|
||||||
ขั้น {item.stepNumber} - {item.roleName}
|
ขั้น {item.stepNumber} - {item.roleName}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground'>
|
<div className='text-muted-foreground'>
|
||||||
{item.actorName || '-'} {item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
|
{item.actorName || '-'}{' '}
|
||||||
|
{item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -217,17 +231,20 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>ตำแหน่งลายเซ็น</CardTitle>
|
<CardTitle>Approval Signatures</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3 text-sm'>
|
<CardContent className='space-y-3 text-sm'>
|
||||||
<Field label='ผู้จัดทำ' value={documentData.signatures.preparedBy} />
|
{[
|
||||||
<Field label='ผู้ขออนุมัติ' value={documentData.signatures.requestedBy} />
|
{ label: 'Prepared By', value: documentData.signatures.preparedBy },
|
||||||
<Field label='ผู้จัดการฝ่ายขาย' value={documentData.signatures.salesManager} />
|
{ label: 'Approved By', value: documentData.signatures.approvedBy },
|
||||||
<Field
|
{ label: 'Authorized By', value: documentData.signatures.authorizedBy }
|
||||||
label='ผู้จัดการฝ่าย'
|
].map((item) => (
|
||||||
value={documentData.signatures.departmentManager}
|
<div key={item.label} className='rounded-lg border p-3'>
|
||||||
/>
|
<Field label={item.label} value={item.value.name} />
|
||||||
<Field label='ผู้บริหารสูงสุด' value={documentData.signatures.topManager} />
|
<Field label='Position' value={item.value.position} />
|
||||||
|
<Field label='Timestamp' value={item.value.actedAt} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
import {
|
import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema';
|
||||||
crmCustomerContacts,
|
|
||||||
crmCustomers,
|
|
||||||
crmQuotations,
|
|
||||||
organizations,
|
|
||||||
users
|
|
||||||
} from '@/db/schema';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import { listApprovalRequests, getApprovalRequest } from '@/features/foundation/approval/server/service';
|
import {
|
||||||
|
listApprovalRequests,
|
||||||
|
getApprovalRequest
|
||||||
|
} from '@/features/foundation/approval/server/service';
|
||||||
import {
|
import {
|
||||||
mapDocumentDataToTemplateInput,
|
mapDocumentDataToTemplateInput,
|
||||||
resolveTemplateForDocument,
|
resolveTemplateForDocument,
|
||||||
@@ -27,12 +24,9 @@ import type {
|
|||||||
QuotationDocumentData
|
QuotationDocumentData
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { PdfTopic } from './pdf-topic.type';
|
import type { PdfTopic } from './pdf-topic.type';
|
||||||
import {
|
import { formatPdfCurrency, formatPdfDate, formatTopicItems } from './pdfme-transforms';
|
||||||
formatPdfCurrency,
|
|
||||||
formatPdfDate,
|
|
||||||
formatTopicItems
|
|
||||||
} from './pdfme-transforms';
|
|
||||||
import { buildPdfTopicTemplate } from './pdf-topic-engine';
|
import { buildPdfTopicTemplate } from './pdf-topic-engine';
|
||||||
|
import { resolveQuotationSignatures } from './signature-resolver';
|
||||||
import { resolveQuotationTopicTypeMapping } from './topic-mapping';
|
import { resolveQuotationTopicTypeMapping } from './topic-mapping';
|
||||||
import type { Template } from '@pdfme/common';
|
import type { Template } from '@pdfme/common';
|
||||||
|
|
||||||
@@ -76,14 +70,14 @@ function findOptionLabel(
|
|||||||
options: Array<{ id: string; code: string; label: string }>,
|
options: Array<{ id: string; code: string; label: string }>,
|
||||||
id: string | null | undefined
|
id: string | null | undefined
|
||||||
) {
|
) {
|
||||||
return id ? options.find((option) => option.id === id)?.label ?? null : null;
|
return id ? (options.find((option) => option.id === id)?.label ?? null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findOptionCode(
|
function findOptionCode(
|
||||||
options: Array<{ id: string; code: string; label: string }>,
|
options: Array<{ id: string; code: string; label: string }>,
|
||||||
id: string | null | undefined
|
id: string | null | undefined
|
||||||
) {
|
) {
|
||||||
return id ? options.find((option) => option.id === id)?.code ?? null : null;
|
return id ? (options.find((option) => option.id === id)?.code ?? null) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSinglePlaceholder(value: string) {
|
function extractSinglePlaceholder(value: string) {
|
||||||
@@ -94,7 +88,12 @@ function extractSinglePlaceholder(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTopicKey(value: string | null | undefined) {
|
function normalizeTopicKey(value: string | null | undefined) {
|
||||||
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
|
return (
|
||||||
|
value
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[\s_-]+/g, '') ?? ''
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
|
function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
|
||||||
@@ -127,14 +126,13 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode:
|
|||||||
['warranty', 'solarcellwarranty']
|
['warranty', 'solarcellwarranty']
|
||||||
];
|
];
|
||||||
|
|
||||||
return aliasGroups.some(
|
return aliasGroups.some((group) => group.includes(actual) && group.includes(expected));
|
||||||
(group) => group.includes(actual) && group.includes(expected)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
|
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
|
||||||
return [...items].sort((left, right) => {
|
return [...items].sort((left, right) => {
|
||||||
const delta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
const delta =
|
||||||
|
(left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
if (delta !== 0) {
|
if (delta !== 0) {
|
||||||
return delta;
|
return delta;
|
||||||
@@ -144,10 +142,7 @@ function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePdfmeTemplateInput(
|
function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<string, unknown>) {
|
||||||
schemaJson: unknown,
|
|
||||||
templateInput: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
const pages =
|
const pages =
|
||||||
schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson
|
schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson
|
||||||
? (schemaJson as { schemas?: unknown }).schemas
|
? (schemaJson as { schemas?: unknown }).schemas
|
||||||
@@ -243,14 +238,18 @@ export async function buildQuotationDocumentData(
|
|||||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
|
||||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
|
||||||
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
|
||||||
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
|
listApprovalRequests(organizationId, {
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: quotationId,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!company) {
|
if (!company) {
|
||||||
throw new AuthError('Organization not found', 404);
|
throw new AuthError('Organization not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [customer, contact, relatedCustomers, preparer] = await Promise.all([
|
const [customer, contact, relatedCustomers, signatures] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmCustomers)
|
.from(crmCustomers)
|
||||||
@@ -290,18 +289,17 @@ export async function buildQuotationDocumentData(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
db
|
resolveQuotationSignatures(quotationId, organizationId)
|
||||||
.select({ id: users.id, name: users.name })
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, quotation.createdBy))
|
|
||||||
.then((rows) => rows[0] ?? null)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new AuthError('Customer not found', 404);
|
throw new AuthError('Customer not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvalRequest = approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items[0] ?? null;
|
const approvalRequest =
|
||||||
|
approvalRequests.items.find((item) => item.status === 'approved') ??
|
||||||
|
approvalRequests.items[0] ??
|
||||||
|
null;
|
||||||
const approvalDetail = approvalRequest
|
const approvalDetail = approvalRequest
|
||||||
? await getApprovalRequest(approvalRequest.id, organizationId)
|
? await getApprovalRequest(approvalRequest.id, organizationId)
|
||||||
: null;
|
: null;
|
||||||
@@ -364,9 +362,13 @@ export async function buildQuotationDocumentData(
|
|||||||
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
|
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
|
||||||
);
|
);
|
||||||
const otherTopics = resolvedTopics.filter((item) => {
|
const otherTopics = resolvedTopics.filter((item) => {
|
||||||
return ![topicTypeMapping.scope, topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, topicTypeMapping.delivery].some(
|
return ![
|
||||||
(expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)
|
topicTypeMapping.scope,
|
||||||
);
|
topicTypeMapping.exclusion,
|
||||||
|
topicTypeMapping.payment,
|
||||||
|
topicTypeMapping.warranty,
|
||||||
|
topicTypeMapping.delivery
|
||||||
|
].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode));
|
||||||
});
|
});
|
||||||
const exclusionTopic = exclusionTopics[0];
|
const exclusionTopic = exclusionTopics[0];
|
||||||
const paymentTopic = paymentTopics[0];
|
const paymentTopic = paymentTopics[0];
|
||||||
@@ -376,7 +378,9 @@ export async function buildQuotationDocumentData(
|
|||||||
approvalDetail?.timeline
|
approvalDetail?.timeline
|
||||||
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber);
|
const step = approvalDetail.steps.find(
|
||||||
|
(candidate) => candidate.stepNumber === item.stepNumber
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
stepNumber: item.stepNumber,
|
stepNumber: item.stepNumber,
|
||||||
roleCode: step?.roleCode ?? '',
|
roleCode: step?.roleCode ?? '',
|
||||||
@@ -527,27 +531,12 @@ export async function buildQuotationDocumentData(
|
|||||||
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
|
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
|
||||||
approvers: approvalApprovers
|
approvers: approvalApprovers
|
||||||
},
|
},
|
||||||
signatures: {
|
signatures,
|
||||||
preparedBy: preparer?.name ?? null,
|
watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null
|
||||||
preparedByPosition: null,
|
|
||||||
requestedBy: contact?.name ?? customer.name,
|
|
||||||
salesManager:
|
|
||||||
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
|
|
||||||
salesManagerPosition: null,
|
|
||||||
departmentManager:
|
|
||||||
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
|
|
||||||
departmentManagerPosition: null,
|
|
||||||
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null,
|
|
||||||
topManagerPosition: null
|
|
||||||
},
|
|
||||||
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQuotationDocumentPreviewData(
|
export async function getQuotationDocumentPreviewData(quotationId: string, organizationId: string) {
|
||||||
quotationId: string,
|
|
||||||
organizationId: string
|
|
||||||
) {
|
|
||||||
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
|
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
|
||||||
const template = await resolveTemplateForDocument(organizationId, {
|
const template = await resolveTemplateForDocument(organizationId, {
|
||||||
documentType: 'quotation',
|
documentType: 'quotation',
|
||||||
@@ -557,7 +546,10 @@ export async function getQuotationDocumentPreviewData(
|
|||||||
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
|
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
|
||||||
|
|
||||||
if (!mappings.length) {
|
if (!mappings.length) {
|
||||||
throw new AuthError('Document template mappings are not configured for this template version', 409);
|
throw new AuthError(
|
||||||
|
'Document template mappings are not configured for this template version',
|
||||||
|
409
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateInput = normalizePdfmeTemplateInput(
|
const templateInput = normalizePdfmeTemplateInput(
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
|
import { crmQuotations, memberships, users } from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import {
|
||||||
|
getApprovalRequest,
|
||||||
|
listApprovalRequests
|
||||||
|
} from '@/features/foundation/approval/server/service';
|
||||||
|
import type {
|
||||||
|
ApprovalDetailRecord,
|
||||||
|
ApprovalTimelineItem
|
||||||
|
} from '@/features/foundation/approval/types';
|
||||||
|
import {
|
||||||
|
getSignaturePositionLookup,
|
||||||
|
resolveSignaturePositionLabel
|
||||||
|
} from '@/features/crm/quotations/document/signature-position';
|
||||||
|
import type {
|
||||||
|
SignatureBlock,
|
||||||
|
SignatureParty
|
||||||
|
} from '@/features/crm/quotations/document/signature.types';
|
||||||
|
|
||||||
|
const EMPTY_SIGNATURE_PARTY: SignatureParty = {
|
||||||
|
name: '-',
|
||||||
|
position: '-',
|
||||||
|
actedAt: null,
|
||||||
|
userId: null,
|
||||||
|
roleCode: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const APPROVED_BY_ROLE_CODES = new Set(['sales_manager', 'department_manager']);
|
||||||
|
|
||||||
|
type ApprovalActionWithRole = ApprovalTimelineItem & {
|
||||||
|
roleCode: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toSignatureParty(input?: Partial<SignatureParty> | null): SignatureParty {
|
||||||
|
return {
|
||||||
|
name: input?.name?.trim() || '-',
|
||||||
|
position: input?.position?.trim() || '-',
|
||||||
|
actedAt: input?.actedAt ?? null,
|
||||||
|
userId: input?.userId ?? null,
|
||||||
|
roleCode: input?.roleCode ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApprovalActions(detail: ApprovalDetailRecord | null): ApprovalActionWithRole[] {
|
||||||
|
if (!detail) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail.timeline
|
||||||
|
.filter((item) => item.action === 'approve')
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
roleCode: detail.steps.find((step) => step.stepNumber === item.stepNumber)?.roleCode ?? null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApprovedByAction(actions: ApprovalActionWithRole[], finalRoleCode: string | null) {
|
||||||
|
const candidates = actions.filter(
|
||||||
|
(item) =>
|
||||||
|
item.roleCode !== finalRoleCode &&
|
||||||
|
item.roleCode !== null &&
|
||||||
|
APPROVED_BY_ROLE_CODES.has(item.roleCode)
|
||||||
|
);
|
||||||
|
|
||||||
|
return candidates.at(-1) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthorizedByAction(
|
||||||
|
actions: ApprovalActionWithRole[],
|
||||||
|
finalRoleCode: string | null
|
||||||
|
) {
|
||||||
|
if (!finalRoleCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.filter((item) => item.roleCode === finalRoleCode).at(-1) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveQuotationSignatures(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<SignatureBlock> {
|
||||||
|
const [quotation] = await db
|
||||||
|
.select({
|
||||||
|
id: crmQuotations.id,
|
||||||
|
organizationId: crmQuotations.organizationId,
|
||||||
|
salesmanId: crmQuotations.salesmanId,
|
||||||
|
createdBy: crmQuotations.createdBy,
|
||||||
|
createdAt: crmQuotations.createdAt
|
||||||
|
})
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.id, quotationId),
|
||||||
|
eq(crmQuotations.organizationId, organizationId),
|
||||||
|
isNull(crmQuotations.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!quotation) {
|
||||||
|
throw new AuthError('Quotation not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalRequests = await listApprovalRequests(organizationId, {
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: quotationId,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
const approvalRequest =
|
||||||
|
approvalRequests.items.find((item) => item.status === 'approved') ??
|
||||||
|
approvalRequests.items[0] ??
|
||||||
|
null;
|
||||||
|
const approvalDetail = approvalRequest
|
||||||
|
? await getApprovalRequest(approvalRequest.id, organizationId)
|
||||||
|
: null;
|
||||||
|
const finalStep = approvalDetail?.steps.at(-1) ?? null;
|
||||||
|
const approvalActions = mapApprovalActions(approvalDetail);
|
||||||
|
const approvedByAction = resolveApprovedByAction(approvalActions, finalStep?.roleCode ?? null);
|
||||||
|
const authorizedByAction = resolveAuthorizedByAction(
|
||||||
|
approvalActions,
|
||||||
|
finalStep?.roleCode ?? null
|
||||||
|
);
|
||||||
|
const preparedByUserId = quotation.salesmanId ?? quotation.createdBy;
|
||||||
|
const userIds = [
|
||||||
|
preparedByUserId,
|
||||||
|
approvedByAction?.actedBy ?? null,
|
||||||
|
authorizedByAction?.actedBy ?? null
|
||||||
|
].filter((value): value is string => Boolean(value));
|
||||||
|
const uniqueUserIds = [...new Set(userIds)];
|
||||||
|
const [userRows, membershipRows, positionLookup] = await Promise.all([
|
||||||
|
uniqueUserIds.length
|
||||||
|
? db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, uniqueUserIds))
|
||||||
|
: Promise.resolve([]),
|
||||||
|
uniqueUserIds.length
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
userId: memberships.userId,
|
||||||
|
businessRole: memberships.businessRole
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memberships.organizationId, organizationId),
|
||||||
|
inArray(memberships.userId, uniqueUserIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
getSignaturePositionLookup(organizationId)
|
||||||
|
]);
|
||||||
|
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||||
|
const membershipMap = new Map(membershipRows.map((row) => [row.userId, row.businessRole]));
|
||||||
|
|
||||||
|
const createPartyFromUser = (args: {
|
||||||
|
userId: string | null | undefined;
|
||||||
|
actedAt?: string | null;
|
||||||
|
roleCode?: string | null;
|
||||||
|
fallbackName?: string | null;
|
||||||
|
}) => {
|
||||||
|
if (!args.userId && !args.fallbackName) {
|
||||||
|
return toSignatureParty(EMPTY_SIGNATURE_PARTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessRole =
|
||||||
|
(args.userId ? membershipMap.get(args.userId) : null) ?? args.roleCode ?? null;
|
||||||
|
|
||||||
|
return toSignatureParty({
|
||||||
|
name:
|
||||||
|
(args.userId ? userMap.get(args.userId) : null) ??
|
||||||
|
args.fallbackName?.trim() ??
|
||||||
|
EMPTY_SIGNATURE_PARTY.name,
|
||||||
|
position: resolveSignaturePositionLabel(businessRole, positionLookup),
|
||||||
|
actedAt: args.actedAt ?? null,
|
||||||
|
userId: args.userId ?? null,
|
||||||
|
roleCode: businessRole
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
preparedBy: createPartyFromUser({
|
||||||
|
userId: preparedByUserId,
|
||||||
|
actedAt: quotation.createdAt.toISOString()
|
||||||
|
}),
|
||||||
|
approvedBy: createPartyFromUser({
|
||||||
|
userId: approvedByAction?.actedBy,
|
||||||
|
actedAt: approvedByAction?.actedAt ?? null,
|
||||||
|
roleCode: approvedByAction?.roleCode ?? null,
|
||||||
|
fallbackName: approvedByAction?.actorName ?? null
|
||||||
|
}),
|
||||||
|
authorizedBy: createPartyFromUser({
|
||||||
|
userId: authorizedByAction?.actedBy,
|
||||||
|
actedAt: authorizedByAction?.actedAt ?? null,
|
||||||
|
roleCode: authorizedByAction?.roleCode ?? finalStep?.roleCode ?? null,
|
||||||
|
fallbackName: authorizedByAction?.actorName ?? null
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/features/crm/quotations/document/signature-position.ts
Normal file
43
src/features/crm/quotations/document/signature-position.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
|
||||||
|
const JOB_TITLE_OPTION_CATEGORY = 'crm_job_title';
|
||||||
|
|
||||||
|
const BUSINESS_ROLE_FALLBACK_LABELS: Record<string, string> = {
|
||||||
|
sales: 'Sales Engineer',
|
||||||
|
sales_support: 'Sales Support',
|
||||||
|
sales_manager: 'Sales Manager',
|
||||||
|
department_manager: 'Department Manager',
|
||||||
|
top_manager: 'General Manager'
|
||||||
|
};
|
||||||
|
|
||||||
|
function toStartCase(value: string) {
|
||||||
|
return value
|
||||||
|
.split('_')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSignaturePositionLookup(organizationId: string) {
|
||||||
|
const options = await getActiveOptionsByCategory(JOB_TITLE_OPTION_CATEGORY, { organizationId });
|
||||||
|
return new Map(options.map((option) => [option.code, option.label]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBusinessRoleFallbackLabel(businessRole: string | null | undefined) {
|
||||||
|
if (!businessRole) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return BUSINESS_ROLE_FALLBACK_LABELS[businessRole] ?? toStartCase(businessRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSignaturePositionLabel(
|
||||||
|
businessRole: string | null | undefined,
|
||||||
|
positionLookup: Map<string, string>
|
||||||
|
) {
|
||||||
|
if (!businessRole) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return positionLookup.get(businessRole) ?? getBusinessRoleFallbackLabel(businessRole);
|
||||||
|
}
|
||||||
13
src/features/crm/quotations/document/signature.types.ts
Normal file
13
src/features/crm/quotations/document/signature.types.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface SignatureParty {
|
||||||
|
name: string;
|
||||||
|
position: string;
|
||||||
|
actedAt?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
roleCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureBlock {
|
||||||
|
preparedBy: SignatureParty;
|
||||||
|
approvedBy: SignatureParty;
|
||||||
|
authorizedBy: SignatureParty;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
DocumentTemplateMappingWithColumns,
|
DocumentTemplateMappingWithColumns,
|
||||||
ResolvedDocumentTemplate
|
ResolvedDocumentTemplate
|
||||||
} from '@/features/foundation/document-template/types';
|
} from '@/features/foundation/document-template/types';
|
||||||
|
import type { SignatureBlock } from './signature.types';
|
||||||
import type { PdfTopic } from './server/pdf-topic.type';
|
import type { PdfTopic } from './server/pdf-topic.type';
|
||||||
|
|
||||||
export interface QuotationDocumentTopicGroup {
|
export interface QuotationDocumentTopicGroup {
|
||||||
@@ -132,17 +133,7 @@ export interface QuotationDocumentData {
|
|||||||
currentStepRoleName: string | null;
|
currentStepRoleName: string | null;
|
||||||
approvers: QuotationDocumentApprovalStep[];
|
approvers: QuotationDocumentApprovalStep[];
|
||||||
};
|
};
|
||||||
signatures: {
|
signatures: SignatureBlock;
|
||||||
preparedBy: string | null;
|
|
||||||
preparedByPosition?: string | null;
|
|
||||||
requestedBy: string | null;
|
|
||||||
salesManager: string | null;
|
|
||||||
salesManagerPosition?: string | null;
|
|
||||||
departmentManager: string | null;
|
|
||||||
departmentManagerPosition?: string | null;
|
|
||||||
topManager: string | null;
|
|
||||||
topManagerPosition?: string | null;
|
|
||||||
};
|
|
||||||
watermarkStatus: string | null;
|
watermarkStatus: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
getQuotationDocumentPreviewData,
|
getQuotationDocumentPreviewData,
|
||||||
prepareApprovedQuotationSnapshot
|
prepareApprovedQuotationSnapshot
|
||||||
} from '@/features/crm/quotations/document/server/service';
|
} from '@/features/crm/quotations/document/server/service';
|
||||||
|
import type { ApprovedQuotationSnapshot } from '@/features/crm/quotations/document/types';
|
||||||
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
|
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
|
||||||
|
|
||||||
type GeneratePdfFromTemplateInput = {
|
type GeneratePdfFromTemplateInput = {
|
||||||
@@ -323,14 +324,23 @@ export async function generateApprovedQuotationPdf(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<GeneratedQuotationPdf> {
|
): Promise<GeneratedQuotationPdf> {
|
||||||
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||||
|
const quotation = await getQuotationDetail(quotationId, organizationId);
|
||||||
|
const approvedSnapshot = quotation.approvedSnapshot as ApprovedQuotationSnapshot | null;
|
||||||
|
|
||||||
if (preview.documentData.quotation.statusCode !== 'approved') {
|
if (preview.documentData.quotation.statusCode !== 'approved') {
|
||||||
throw new AuthError('Only approved quotations can generate approved PDF', 400);
|
throw new AuthError('Only approved quotations can generate approved PDF', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapshotTemplateInput =
|
||||||
|
approvedSnapshot &&
|
||||||
|
approvedSnapshot.quotationId === quotationId &&
|
||||||
|
approvedSnapshot.templateInput &&
|
||||||
|
typeof approvedSnapshot.templateInput === 'object'
|
||||||
|
? approvedSnapshot.templateInput
|
||||||
|
: null;
|
||||||
const buffer = await generatePdfFromTemplate({
|
const buffer = await generatePdfFromTemplate({
|
||||||
template: preview.template.version.schemaJson as Template,
|
template: preview.template.version.schemaJson as Template,
|
||||||
inputs: [preview.templateInput]
|
inputs: [snapshotTemplateInput ?? preview.templateInput]
|
||||||
});
|
});
|
||||||
const fileName = `${preview.documentData.quotation.code}-approved.pdf`;
|
const fileName = `${preview.documentData.quotation.code}-approved.pdf`;
|
||||||
const artifact = await persistApprovedQuotationArtifact({
|
const artifact = await persistApprovedQuotationArtifact({
|
||||||
|
|||||||
Reference in New Issue
Block a user