This commit is contained in:
phaichayon
2026-06-19 12:30:26 +07:00
parent 721653f692
commit a85d24235c
14 changed files with 1250 additions and 102 deletions

View 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 persons 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 workflows final approval role from this slot
### Authorized By
- resolve from the latest approved action for the workflows 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

View 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

View File

@@ -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

View File

@@ -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": {

530
plans/task-h.3.md Normal file
View 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
View 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);
});

View File

@@ -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 (

View File

@@ -32,7 +32,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='space-y-1'>
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
<CardDescription>
{template.template.templateName} v{template.version.version}
{template.template.templateName} v
{template.version.version}
</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
@@ -124,7 +125,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div>{item.itemNumber}</div>
<div className='space-y-1'>
<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>{item.quantity}</div>
<div>{item.unitLabel || '-'}</div>
@@ -139,7 +142,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> </CardDescription>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{topicGroups.map((group) => {
@@ -151,7 +156,10 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='text-muted-foreground text-sm'></div>
) : (
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>
<ul className='list-disc space-y-1 pl-5 text-sm'>
{entry.items.map((item) => (
@@ -183,7 +191,9 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
</div>
<div className='flex items-center justify-between'>
<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>
</CardContent>
</Card>
@@ -202,12 +212,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<div className='text-muted-foreground text-sm'></div>
) : (
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'>
{item.stepNumber} - {item.roleName}
</div>
<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>
))
@@ -217,17 +231,20 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>Approval Signatures</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
<Field label='ผู้จัดทำ' value={documentData.signatures.preparedBy} />
<Field label='ผู้ขออนุมัติ' value={documentData.signatures.requestedBy} />
<Field label='ผู้จัดการฝ่ายขาย' value={documentData.signatures.salesManager} />
<Field
label='ผู้จัดการฝ่าย'
value={documentData.signatures.departmentManager}
/>
<Field label='ผู้บริหารสูงสุด' value={documentData.signatures.topManager} />
{[
{ label: 'Prepared By', value: documentData.signatures.preparedBy },
{ label: 'Approved By', value: documentData.signatures.approvedBy },
{ label: 'Authorized By', value: documentData.signatures.authorizedBy }
].map((item) => (
<div key={item.label} className='rounded-lg border p-3'>
<Field label={item.label} value={item.value.name} />
<Field label='Position' value={item.value.position} />
<Field label='Timestamp' value={item.value.actedAt} />
</div>
))}
</CardContent>
</Card>
</div>

View File

@@ -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<T extends { sortOrder?: number | null }>(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<T extends { sortOrder?: number | null }>(items: T[]) {
});
}
function normalizePdfmeTemplateInput(
schemaJson: unknown,
templateInput: Record<string, unknown>
) {
function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<string, unknown>) {
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(

View File

@@ -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
})
};
}

View 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);
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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<GeneratedQuotationPdf> {
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({