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