From a85d24235c63c477628f9206f7ae7f9d5ec8649b Mon Sep 17 00:00:00 2001 From: phaichayon Date: Fri, 19 Jun 2026 12:30:26 +0700 Subject: [PATCH] task-h.4 --- docs/adr/0012-approval-signature-strategy.md | 63 +++ .../task-h3-approval-signature-strategy.md | 78 +++ docs/implementation/technical-debt.md | 4 +- package.json | 1 + plans/task-h.3.md | 530 ++++++++++++++++++ scripts/verify-task-h3.js | 198 +++++++ src/db/seeds/foundation.seed.ts | 28 +- .../components/quotation-document-preview.tsx | 65 ++- .../crm/quotations/document/server/service.ts | 102 ++-- .../document/server/signature-resolver.ts | 202 +++++++ .../quotations/document/signature-position.ts | 43 ++ .../quotations/document/signature.types.ts | 13 + src/features/crm/quotations/document/types.ts | 13 +- .../pdf-generator/server/service.ts | 12 +- 14 files changed, 1250 insertions(+), 102 deletions(-) create mode 100644 docs/adr/0012-approval-signature-strategy.md create mode 100644 docs/implementation/task-h3-approval-signature-strategy.md create mode 100644 plans/task-h.3.md create mode 100644 scripts/verify-task-h3.js create mode 100644 src/features/crm/quotations/document/server/signature-resolver.ts create mode 100644 src/features/crm/quotations/document/signature-position.ts create mode 100644 src/features/crm/quotations/document/signature.types.ts diff --git a/docs/adr/0012-approval-signature-strategy.md b/docs/adr/0012-approval-signature-strategy.md new file mode 100644 index 0000000..257a960 --- /dev/null +++ b/docs/adr/0012-approval-signature-strategy.md @@ -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 diff --git a/docs/implementation/task-h3-approval-signature-strategy.md b/docs/implementation/task-h3-approval-signature-strategy.md new file mode 100644 index 0000000..542a3af --- /dev/null +++ b/docs/implementation/task-h3-approval-signature-strategy.md @@ -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 diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index 2c875fb..913c4c7 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -206,13 +206,11 @@ Future: 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 -- approval signature fields are not mapped yet -- signature placeholders `app1/app2/app3` currently rely on safe fallback mapping and still need Task H.3 strategy for real names and positions +- approval signature placeholders now resolve through the H.3 signature strategy and `crm_job_title` position lookup Future: - 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 ## After Task I diff --git a/package.json b/package.json index ff82064..7749c12 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.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" }, "dependencies": { diff --git a/plans/task-h.3.md b/plans/task-h.3.md new file mode 100644 index 0000000..0528ce8 --- /dev/null +++ b/plans/task-h.3.md @@ -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 diff --git a/scripts/verify-task-h3.js b/scripts/verify-task-h3.js new file mode 100644 index 0000000..cac0855 --- /dev/null +++ b/scripts/verify-task-h3.js @@ -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); +}); diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 8b3e9b8..5fcad7d 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -285,6 +285,18 @@ const FOUNDATION_OPTIONS = { { code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, { 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: [ { code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, @@ -393,42 +405,42 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [ }, { placeholderKey: 'app1', - sourcePath: 'signatures.preparedBy', + sourcePath: 'signatures.preparedBy.name', dataType: 'scalar', defaultValue: '-', sortOrder: 13 }, { placeholderKey: 'app1_position', - sourcePath: 'signatures.preparedByPosition', + sourcePath: 'signatures.preparedBy.position', dataType: 'scalar', defaultValue: '-', sortOrder: 14 }, { placeholderKey: 'app2', - sourcePath: 'signatures.salesManager', + sourcePath: 'signatures.approvedBy.name', dataType: 'scalar', defaultValue: '-', sortOrder: 15 }, { placeholderKey: 'app2_position', - sourcePath: 'signatures.salesManagerPosition', + sourcePath: 'signatures.approvedBy.position', dataType: 'scalar', defaultValue: '-', sortOrder: 16 }, { placeholderKey: 'app3', - sourcePath: 'signatures.topManager', + sourcePath: 'signatures.authorizedBy.name', dataType: 'scalar', defaultValue: '-', sortOrder: 17 }, { placeholderKey: 'app3_position', - sourcePath: 'signatures.topManagerPosition', + sourcePath: 'signatures.authorizedBy.position', dataType: 'scalar', defaultValue: '-', sortOrder: 18 @@ -503,7 +515,7 @@ async function upsertMasterOptionsForOrganization( for (const option of options) { const parentRow = option.parentCode && option.parentCategory - ? ( + ? (( await sql` select id from ms_options @@ -513,7 +525,7 @@ async function upsertMasterOptionsForOrganization( and deleted_at is null limit 1 ` - )[0] ?? null + )[0] ?? null) : null; const [row] = await sql` insert into ms_options ( diff --git a/src/features/crm/quotations/components/quotation-document-preview.tsx b/src/features/crm/quotations/components/quotation-document-preview.tsx index 6726c9d..31dbed7 100644 --- a/src/features/crm/quotations/components/quotation-document-preview.tsx +++ b/src/features/crm/quotations/components/quotation-document-preview.tsx @@ -32,7 +32,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
{documentData.company.name} - ตัวอย่างใบเสนอราคา จากเทมเพลต {template.template.templateName} v{template.version.version} + ตัวอย่างใบเสนอราคา จากเทมเพลต {template.template.templateName} v + {template.version.version}
@@ -91,8 +92,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string - ข้อมูลโครงการ - สรุปข้อมูลอ้างอิงของโครงการสำหรับลูกค้า + ข้อมูลโครงการ + สรุปข้อมูลอ้างอิงของโครงการสำหรับลูกค้า @@ -104,8 +105,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string - ตารางรายการ - รายการที่จัดรูปแบบแล้วเพื่อเตรียมใช้กับ template mapping + ตารางรายการ + รายการที่จัดรูปแบบแล้วเพื่อเตรียมใช้กับ template mapping
@@ -124,7 +125,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
{item.itemNumber}
{item.description}
-
{item.productTypeLabel || '-'}
+
+ {item.productTypeLabel || '-'} +
{item.quantity}
{item.unitLabel || '-'}
@@ -138,8 +141,10 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
- หัวข้อเอกสาร - ขอบเขตงาน ข้อยกเว้น และเงื่อนไขการชำระเงินจากหัวข้อในระบบ + หัวข้อเอกสาร + + ขอบเขตงาน ข้อยกเว้น และเงื่อนไขการชำระเงินจากหัวข้อในระบบ + {topicGroups.map((group) => { @@ -151,7 +156,10 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
ไม่มีข้อมูล
) : ( entries.map((entry) => ( -
+
{entry.title}
    {entry.items.map((item) => ( @@ -170,7 +178,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
    - สรุปมูลค่า + สรุปมูลค่า
    @@ -183,15 +191,17 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
    รวมทั้งสิ้น - {documentData.quotation.totalAmount.toLocaleString()} + + {documentData.quotation.totalAmount.toLocaleString()} +
    - ข้อมูลอนุมัติเอกสาร - ข้อมูลที่ใช้ประกอบ watermark และ approved snapshot + ข้อมูลอนุมัติเอกสาร + ข้อมูลที่ใช้ประกอบ watermark และ approved snapshot @@ -202,12 +212,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
    ยังไม่มีประวัติการอนุมัติ
    ) : ( documentData.approval.approvers.map((item) => ( -
    +
    ขั้น {item.stepNumber} - {item.roleName}
    - {item.actorName || '-'} {item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''} + {item.actorName || '-'}{' '} + {item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''}
    )) @@ -217,17 +231,20 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string - ตำแหน่งลายเซ็น + Approval Signatures - - - - - + {[ + { label: 'Prepared By', value: documentData.signatures.preparedBy }, + { label: 'Approved By', value: documentData.signatures.approvedBy }, + { label: 'Authorized By', value: documentData.signatures.authorizedBy } + ].map((item) => ( +
    + + + +
    + ))}
    diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index 1f55678..5a87056 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -1,14 +1,11 @@ import { and, eq, inArray, isNull } from 'drizzle-orm'; -import { - crmCustomerContacts, - crmCustomers, - crmQuotations, - organizations, - users -} from '@/db/schema'; +import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema'; import { db } from '@/lib/db'; 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 { mapDocumentDataToTemplateInput, resolveTemplateForDocument, @@ -27,12 +24,9 @@ import type { QuotationDocumentData } from '../types'; import type { PdfTopic } from './pdf-topic.type'; -import { - formatPdfCurrency, - formatPdfDate, - formatTopicItems -} from './pdfme-transforms'; +import { formatPdfCurrency, formatPdfDate, formatTopicItems } from './pdfme-transforms'; import { buildPdfTopicTemplate } from './pdf-topic-engine'; +import { resolveQuotationSignatures } from './signature-resolver'; import { resolveQuotationTopicTypeMapping } from './topic-mapping'; import type { Template } from '@pdfme/common'; @@ -76,14 +70,14 @@ function findOptionLabel( options: Array<{ id: string; code: string; label: string }>, 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( options: Array<{ id: string; code: string; label: string }>, 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) { @@ -94,7 +88,12 @@ function extractSinglePlaceholder(value: string) { } 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) { @@ -127,14 +126,13 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: ['warranty', 'solarcellwarranty'] ]; - return aliasGroups.some( - (group) => group.includes(actual) && group.includes(expected) - ); + return aliasGroups.some((group) => group.includes(actual) && group.includes(expected)); } function sortBySortOrder(items: T[]) { 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) { return delta; @@ -144,10 +142,7 @@ function sortBySortOrder(items: T[]) { }); } -function normalizePdfmeTemplateInput( - schemaJson: unknown, - templateInput: Record -) { +function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record) { const pages = schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson ? (schemaJson as { schemas?: unknown }).schemas @@ -243,14 +238,18 @@ export async function buildQuotationDocumentData( getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { 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) { throw new AuthError('Organization not found', 404); } - const [customer, contact, relatedCustomers, preparer] = await Promise.all([ + const [customer, contact, relatedCustomers, signatures] = await Promise.all([ db .select() .from(crmCustomers) @@ -290,18 +289,17 @@ export async function buildQuotationDocumentData( ) ) : Promise.resolve([]), - db - .select({ id: users.id, name: users.name }) - .from(users) - .where(eq(users.id, quotation.createdBy)) - .then((rows) => rows[0] ?? null) + resolveQuotationSignatures(quotationId, organizationId) ]); if (!customer) { 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 ? await getApprovalRequest(approvalRequest.id, organizationId) : null; @@ -364,9 +362,13 @@ export async function buildQuotationDocumentData( matchesTopicAlias(item.topicCode, topicTypeMapping.delivery) ); const otherTopics = resolvedTopics.filter((item) => { - return ![topicTypeMapping.scope, topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, topicTypeMapping.delivery].some( - (expectedCode) => matchesTopicAlias(item.topicCode, expectedCode) - ); + return ![ + topicTypeMapping.scope, + topicTypeMapping.exclusion, + topicTypeMapping.payment, + topicTypeMapping.warranty, + topicTypeMapping.delivery + ].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)); }); const exclusionTopic = exclusionTopics[0]; const paymentTopic = paymentTopics[0]; @@ -376,7 +378,9 @@ export async function buildQuotationDocumentData( approvalDetail?.timeline .filter((item) => ['approve', 'reject', 'return'].includes(item.action)) .map((item) => { - const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber); + const step = approvalDetail.steps.find( + (candidate) => candidate.stepNumber === item.stepNumber + ); return { stepNumber: item.stepNumber, roleCode: step?.roleCode ?? '', @@ -527,27 +531,12 @@ export async function buildQuotationDocumentData( currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null, approvers: approvalApprovers }, - signatures: { - preparedBy: preparer?.name ?? 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 + signatures, + watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null }; } -export async function getQuotationDocumentPreviewData( - quotationId: string, - organizationId: string -) { +export async function getQuotationDocumentPreviewData(quotationId: string, organizationId: string) { const documentData = await buildQuotationDocumentData(quotationId, organizationId); const template = await resolveTemplateForDocument(organizationId, { documentType: 'quotation', @@ -557,7 +546,10 @@ export async function getQuotationDocumentPreviewData( const mappings = await resolveTemplateMappings(template.version.id, organizationId); 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( diff --git a/src/features/crm/quotations/document/server/signature-resolver.ts b/src/features/crm/quotations/document/server/signature-resolver.ts new file mode 100644 index 0000000..2e9900e --- /dev/null +++ b/src/features/crm/quotations/document/server/signature-resolver.ts @@ -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 | 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 { + 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 + }) + }; +} diff --git a/src/features/crm/quotations/document/signature-position.ts b/src/features/crm/quotations/document/signature-position.ts new file mode 100644 index 0000000..a667e68 --- /dev/null +++ b/src/features/crm/quotations/document/signature-position.ts @@ -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 = { + 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 +) { + if (!businessRole) { + return '-'; + } + + return positionLookup.get(businessRole) ?? getBusinessRoleFallbackLabel(businessRole); +} diff --git a/src/features/crm/quotations/document/signature.types.ts b/src/features/crm/quotations/document/signature.types.ts new file mode 100644 index 0000000..51a7c5e --- /dev/null +++ b/src/features/crm/quotations/document/signature.types.ts @@ -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; +} diff --git a/src/features/crm/quotations/document/types.ts b/src/features/crm/quotations/document/types.ts index ef3e898..6c48bb5 100644 --- a/src/features/crm/quotations/document/types.ts +++ b/src/features/crm/quotations/document/types.ts @@ -2,6 +2,7 @@ import type { DocumentTemplateMappingWithColumns, ResolvedDocumentTemplate } from '@/features/foundation/document-template/types'; +import type { SignatureBlock } from './signature.types'; import type { PdfTopic } from './server/pdf-topic.type'; export interface QuotationDocumentTopicGroup { @@ -132,17 +133,7 @@ export interface QuotationDocumentData { currentStepRoleName: string | null; approvers: QuotationDocumentApprovalStep[]; }; - signatures: { - 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; - }; + signatures: SignatureBlock; watermarkStatus: string | null; } diff --git a/src/features/foundation/pdf-generator/server/service.ts b/src/features/foundation/pdf-generator/server/service.ts index 0e35578..a55dac5 100644 --- a/src/features/foundation/pdf-generator/server/service.ts +++ b/src/features/foundation/pdf-generator/server/service.ts @@ -22,6 +22,7 @@ import { getQuotationDocumentPreviewData, prepareApprovedQuotationSnapshot } from '@/features/crm/quotations/document/server/service'; +import type { ApprovedQuotationSnapshot } from '@/features/crm/quotations/document/types'; import { getQuotationDetail } from '@/features/crm/quotations/server/service'; type GeneratePdfFromTemplateInput = { @@ -323,14 +324,23 @@ export async function generateApprovedQuotationPdf( userId: string ): Promise { 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') { 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({ template: preview.template.version.schemaJson as Template, - inputs: [preview.templateInput] + inputs: [snapshotTemplateInput ?? preview.templateInput] }); const fileName = `${preview.documentData.quotation.code}-approved.pdf`; const artifact = await persistApprovedQuotationArtifact({