-
Runtime Issues
+
Template Health Issues
- {validation.runtimeIssues.map((issue) => (
-
-
+ {validation.issues.map((issue) => (
+
+
{issue.severity}
{issue.code}
+ {issue.location}
-
{issue.message}
+
{issue.message}
+ {issue.suggestedFix ? (
+
{issue.suggestedFix}
+ ) : null}
))}
@@ -125,6 +136,45 @@ function RuntimeIssueList({
);
}
+function AuditSummary({
+ audit,
+ visual,
+}: {
+ audit: DocumentTemplateVersionAuditSummary | null;
+ visual: DocumentTemplateVersionVisualSummary | null | undefined;
+}) {
+ if (!audit) {
+ return (
+
+ No audit summary available.
+
+ );
+ }
+
+ return (
+
+ {audit.status}}
+ />
+
+ {audit.visualRegressionStatus}
+
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
+
function CompareDialog({
open,
onOpenChange,
@@ -141,6 +191,10 @@ function CompareDialog({
const [rightVersionId, setRightVersionId] = React.useState(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
+ const [comparison, setComparison] = React.useState<
+ DocumentTemplateVersionCompareResponse['comparison'] | null
+ >(null);
+
const compareMutation = useMutation({
...compareDocumentTemplateVersionsMutation,
onError: (error) =>
@@ -149,6 +203,7 @@ function CompareDialog({
React.useEffect(() => {
if (open) {
+ setComparison(null);
setRightVersionId(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
@@ -161,29 +216,21 @@ function CompareDialog({
leftVersionId: currentVersion.id,
rightVersionId,
});
-
- const metadataCount = result.comparison.metadataChanges.length;
- const placeholderCount =
- result.comparison.placeholderOnlyLeft.length +
- result.comparison.placeholderOnlyRight.length;
- const mappingCount = result.comparison.mappingDifferences.length;
-
- toast.success(
- `Compare complete: ${metadataCount} metadata, ${placeholderCount} placeholders, ${mappingCount} mappings`,
- );
- onOpenChange(false);
+ setComparison(result.comparison);
}
return (
+
Preview
+
-
-
-
+
+
-
+
+
+
) : (
@@ -456,7 +618,7 @@ export function TemplateVersionManagementPanel({
Audit Summary
- {renderAuditSummary(audit)}
+
diff --git a/src/features/foundation/document-template/mutations.ts b/src/features/foundation/document-template/mutations.ts
index deda8e7..e7ddb1b 100644
--- a/src/features/foundation/document-template/mutations.ts
+++ b/src/features/foundation/document-template/mutations.ts
@@ -9,6 +9,7 @@ import {
createDocumentTemplateVersion,
deleteDocumentTemplate,
deleteDocumentTemplateMapping,
+ duplicateDocumentTemplateVersion,
previewDocumentTemplateVersion,
publishDocumentTemplateVersion,
rollbackDocumentTemplateVersion,
@@ -254,6 +255,25 @@ export const archiveDocumentTemplateVersionMutation = mutationOptions({
},
});
+export const duplicateDocumentTemplateVersionMutation = mutationOptions({
+ mutationFn: ({
+ templateId,
+ versionId,
+ }: {
+ templateId: string;
+ versionId: string;
+ }) => duplicateDocumentTemplateVersion(versionId),
+ onSettled: async (_data, error, variables) => {
+ if (!error) {
+ await Promise.all([
+ invalidateTemplateLists(),
+ invalidateTemplateDetail(variables.templateId),
+ invalidateVersionManagement(variables.versionId),
+ ]);
+ }
+ },
+});
+
export const previewDocumentTemplateVersionMutation = mutationOptions({
mutationFn: (versionId: string) => previewDocumentTemplateVersion(versionId),
});
diff --git a/src/features/foundation/document-template/server/management-service.ts b/src/features/foundation/document-template/server/management-service.ts
index 594897d..87f4e67 100644
--- a/src/features/foundation/document-template/server/management-service.ts
+++ b/src/features/foundation/document-template/server/management-service.ts
@@ -11,20 +11,6 @@ import { AuthError } from '@/lib/auth/session';
import { generatePdfFromTemplate } from '@/features/foundation/pdf-generator/server/service';
import { buildQuotationPdfRuntime } from '@/features/crm/quotations/document/server/quotation-pdf-runtime';
import type { QuotationDocumentData } from '@/features/crm/quotations/document/types';
-import {
- getDocumentTemplate,
- mapDocumentDataToTemplateInput,
- resolveTemplateMappings,
-} from './service';
-import type {
- DocumentTemplateDetail,
- DocumentTemplateLifecycleStatus,
- DocumentTemplateVersionAuditSummary,
- DocumentTemplateVersionCompareResponse,
- DocumentTemplateVersionDetail,
- DocumentTemplateVersionPreviewResponse,
- DocumentTemplateVersionValidationSummary,
-} from '../types';
import {
PDFME_STATIC_LABEL_FIELD_KEYS,
buildAuditPayload,
@@ -33,27 +19,30 @@ import {
getEffectiveMappedKeys,
normalizeTemplateInputAliases,
summarizeIssues,
-} from '../../../../../scripts/pdf-audit-utils';
+} from '@/features/foundation/pdf-audit/server/shared';
+import {
+ getDocumentTemplate,
+ mapDocumentDataToTemplateInput,
+ resolveTemplateMappings,
+} from './service';
+import {
+ CURRENT_TEMPLATE_RUNTIME_VERSION,
+ getManagementMetadata,
+ mergeManagementMetadata,
+ sanitizeTemplateSchemaJson,
+} from './metadata';
+import type {
+ DocumentTemplateDetail,
+ DocumentTemplateLifecycleStatus,
+ DocumentTemplateVersionAuditSummary,
+ DocumentTemplateVersionCompareResponse,
+ DocumentTemplateVersionDetail,
+ DocumentTemplateVersionPreviewResponse,
+ DocumentTemplateVersionVisualSummary,
+ DocumentTemplateVersionValidationSummary,
+} from '../types';
type VersionRow = typeof crmDocumentTemplateVersions.$inferSelect;
-
-type DocumentTemplateManagementMetadata = {
- lifecycleStatus?: DocumentTemplateLifecycleStatus;
- runtimeVersion?: string | null;
- templateVariant?: string | null;
- brand?: string | null;
- publishedAt?: string | null;
- publishedBy?: string | null;
- activatedAt?: string | null;
- activatedBy?: string | null;
- archivedAt?: string | null;
- archivedBy?: string | null;
- previousVersionId?: string | null;
- nextVersionId?: string | null;
- validatedAt?: string | null;
-};
-
-const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [
'customer_name',
'quotation_code_data',
@@ -63,38 +52,14 @@ const REQUIRED_TEMPLATE_PLACEHOLDERS: string[] = [
'app3',
] as const;
-function getSchemaRecord(schemaJson: unknown): Record
{
- return schemaJson && typeof schemaJson === 'object'
- ? { ...(schemaJson as Record) }
- : {};
-}
-
-function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
- const schema = getSchemaRecord(schemaJson);
- const metadata = schema.__templateManagement;
- return metadata && typeof metadata === 'object'
- ? { ...(metadata as DocumentTemplateManagementMetadata) }
- : {};
-}
-
-function withManagementMetadata(
- schemaJson: unknown,
- metadata: DocumentTemplateManagementMetadata,
-): unknown {
- return {
- ...getSchemaRecord(schemaJson),
- __templateManagement: {
- ...getManagementMetadata(schemaJson),
- ...metadata,
- },
- };
-}
-
function inferLifecycleStatus(row: VersionRow): DocumentTemplateLifecycleStatus {
if (row.isActive) {
return 'active';
}
- return getManagementMetadata(row.schemaJson).lifecycleStatus ?? 'draft';
+ return getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ }).lifecycleStatus ?? 'draft';
}
async function getVersionRow(versionId: string, organizationId: string) {
@@ -136,7 +101,10 @@ function decorateVersionDetail(args: {
auditSummary: DocumentTemplateVersionAuditSummary | null;
validationSummary?: DocumentTemplateVersionValidationSummary | null;
}): DocumentTemplateVersionDetail {
- const metadata = getManagementMetadata(args.version.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: args.version.metadataJson,
+ schemaJson: args.version.schemaJson,
+ });
const siblingIds = args.siblings.map((item) => item.id);
const currentIndex = siblingIds.indexOf(args.version.id);
@@ -168,8 +136,9 @@ function decorateVersionDetail(args: {
(currentIndex >= 0 && currentIndex < siblingIds.length - 1
? siblingIds[currentIndex + 1]
: null),
- auditSummary: args.auditSummary,
- validationSummary: args.validationSummary ?? null,
+ auditSummary: args.auditSummary ?? metadata.auditSummary ?? null,
+ validationSummary: args.validationSummary ?? metadata.validationSummary ?? null,
+ visualSummary: metadata.visualSummary ?? null,
};
}
@@ -217,6 +186,40 @@ async function loadAuditSummary(
return auditSummary;
}
+async function loadVisualSummary(): Promise {
+ const visualReportPath = path.resolve(
+ process.cwd(),
+ 'artifacts/pdf-visual/visual-regression-report.json',
+ );
+
+ try {
+ const visualReport = JSON.parse(await readFile(visualReportPath, 'utf8')) as {
+ generatedAt?: string;
+ overallStatus?: 'PASS' | 'WARNING' | 'FAIL';
+ comparisons?: Array<{ changedPages?: number; diffCount?: number }>;
+ };
+
+ const comparisons = Array.isArray(visualReport.comparisons)
+ ? visualReport.comparisons
+ : [];
+
+ return {
+ status: visualReport.overallStatus ?? 'NOT_RUN',
+ generatedAt: visualReport.generatedAt ?? null,
+ changedPages: comparisons.reduce(
+ (sum, item) => sum + (item.changedPages ?? 0),
+ 0,
+ ),
+ layoutDifferences: comparisons.reduce(
+ (sum, item) => sum + (item.diffCount ?? 0),
+ 0,
+ ),
+ };
+ } catch {
+ return null;
+ }
+}
+
async function buildFixtureDocumentData(): Promise {
const sql = createSqlClient();
try {
@@ -252,10 +255,7 @@ export async function validateDocumentTemplateVersion(
versionId: string,
organizationId: string,
): Promise {
- const { template, version, siblings } = await getTemplateContext(
- versionId,
- organizationId,
- );
+ const { template, version } = await getTemplateContext(versionId, organizationId);
const fields = extractTemplateFields(version.schemaJson);
const mappedKeys = getEffectiveMappedKeys(fields, version.mappings);
const schemaFieldNames = new Set(fields.map((field) => field.name).filter(Boolean));
@@ -285,6 +285,11 @@ export async function validateDocumentTemplateVersion(
.map(([name]) => name)
.sort();
+ const orphanMappings = version.mappings
+ .filter((mapping) => !schemaFieldNames.has(mapping.placeholderKey))
+ .map((mapping) => mapping.placeholderKey)
+ .sort();
+
const unmappedPlaceholders = [...schemaFieldNames]
.filter(
(fieldName) =>
@@ -299,6 +304,30 @@ export async function validateDocumentTemplateVersion(
)
.sort();
+ const unsupportedSchemaTypes = fields
+ .filter(
+ (field) =>
+ ![
+ 'text',
+ 'line',
+ 'rectangle',
+ 'ellipse',
+ 'table',
+ 'image',
+ 'pdf',
+ ].includes(field.type),
+ )
+ .map((field) => `${field.name}:${field.type}`)
+ .sort();
+
+ const unknownPlugins: string[] = [];
+ const sectionMarkerIssues: string[] = [];
+ if (hasProductSection && !schemaFieldNames.has('items_table')) {
+ sectionMarkerIssues.push(
+ 'Product section marker exists but items_table placeholder is missing',
+ );
+ }
+
const documentData = await buildFixtureDocumentData();
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record,
@@ -315,6 +344,74 @@ export async function validateDocumentTemplateVersion(
templateInput: normalizedTemplateInput,
});
+ const issues: DocumentTemplateVersionValidationSummary['issues'] = [];
+ for (const field of requiredFieldsMissing) {
+ issues.push({
+ code: 'missing_required_placeholder',
+ severity: 'error',
+ location: field,
+ message: `Required placeholder ${field} is missing`,
+ suggestedFix: 'Add the placeholder into the template schema.',
+ });
+ }
+ for (const field of duplicateFields) {
+ issues.push({
+ code: 'duplicate_field_name',
+ severity: 'error',
+ location: field,
+ message: `Field ${field} is duplicated in the template schema`,
+ suggestedFix: 'Rename or remove duplicated fields so each placeholder is unique.',
+ });
+ }
+ for (const field of orphanMappings) {
+ issues.push({
+ code: 'orphan_mapping',
+ severity: 'warning',
+ location: field,
+ message: `Mapping ${field} does not exist in the current schema`,
+ suggestedFix: 'Remove the mapping or restore the matching placeholder in the schema.',
+ });
+ }
+ for (const field of unmappedPlaceholders) {
+ issues.push({
+ code: 'unmapped_placeholder',
+ severity: 'warning',
+ location: field,
+ message: `Placeholder ${field} does not have a mapping`,
+ suggestedFix: 'Add a mapping or convert the field into a static designer label.',
+ });
+ }
+ for (const field of unsupportedSchemaTypes) {
+ issues.push({
+ code: 'unsupported_schema_type',
+ severity: 'error',
+ location: field,
+ message: `Unsupported schema field type detected: ${field}`,
+ suggestedFix: 'Use only supported PDFMe field types for runtime rendering.',
+ });
+ }
+ for (const issue of sectionMarkerIssues) {
+ issues.push({
+ code: 'section_marker_invalid',
+ severity: 'error',
+ location: 'items_table',
+ message: issue,
+ suggestedFix: 'Add items_table to the template and map it to quotation items.',
+ });
+ }
+ for (const issue of runtime.issues) {
+ issues.push({
+ code: issue.code,
+ severity: issue.severity,
+ location: 'runtime',
+ message: issue.message,
+ suggestedFix:
+ issue.severity === 'error'
+ ? 'Resolve runtime compatibility before publish.'
+ : 'Review runtime warning and confirm it is acceptable.',
+ });
+ }
+
const statuses: Array<'PASS' | 'WARNING' | 'FAIL'> = [];
if (requiredFieldsMissing.length) {
statuses.push('FAIL');
@@ -322,19 +419,40 @@ export async function validateDocumentTemplateVersion(
if (duplicateFields.length) {
statuses.push('FAIL');
}
+ if (orphanMappings.length) {
+ statuses.push('WARNING');
+ }
if (unmappedPlaceholders.length) {
statuses.push('WARNING');
}
+ if (unsupportedSchemaTypes.length || sectionMarkerIssues.length || unknownPlugins.length) {
+ statuses.push('FAIL');
+ }
statuses.push(
...runtime.issues.map((issue) => (issue.severity === 'error' ? 'FAIL' : 'WARNING')),
);
+ const status = summarizeIssues(statuses.length ? statuses : ['PASS']);
+
return {
- status: summarizeIssues(statuses.length ? statuses : ['PASS']),
+ status,
validatedAt: new Date().toISOString(),
requiredFieldsMissing,
duplicateFields,
unmappedPlaceholders,
+ orphanMappings,
+ unsupportedSchemaTypes,
+ unknownPlugins,
+ sectionMarkerIssues,
+ healthStatus:
+ status === 'FAIL' ? 'fail' : status === 'WARNING' ? 'warning' : 'pass',
+ issues,
+ suggestedNextStep:
+ status === 'FAIL'
+ ? 'Fix blocking template issues before publish.'
+ : status === 'WARNING'
+ ? 'Review warnings and confirm template intent.'
+ : 'Template is ready for publish.',
runtimeIssues: runtime.issues.map((issue) => ({
code: issue.code,
severity: issue.severity,
@@ -346,13 +464,15 @@ export async function validateDocumentTemplateVersion(
async function persistVersionManagementMetadata(args: {
versionId: string;
organizationId: string;
- schemaJson: unknown;
+ schemaJson?: unknown;
+ metadataJson?: unknown;
isActive?: boolean;
}) {
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
- schemaJson: args.schemaJson,
+ ...(args.schemaJson === undefined ? {} : { schemaJson: args.schemaJson }),
+ ...(args.metadataJson === undefined ? {} : { metadataJson: args.metadataJson }),
...(args.isActive === undefined ? {} : { isActive: args.isActive }),
updatedAt: new Date(),
})
@@ -373,8 +493,12 @@ export async function getManagedDocumentTemplateVersion(
) {
const { version, siblings } = await getTemplateContext(versionId, organizationId);
const auditSummary = await loadAuditSummary(version);
+ const visualSummary = await loadVisualSummary();
return decorateVersionDetail({
- version,
+ version: {
+ ...version,
+ visualSummary,
+ },
siblings,
auditSummary,
});
@@ -388,18 +512,30 @@ export async function validateAndPersistDocumentTemplateVersion(args: {
args.versionId,
args.organizationId,
);
+ const { version } = await getTemplateContext(args.versionId, args.organizationId);
const row = await getVersionRow(args.versionId, args.organizationId);
- const metadata = getManagementMetadata(row.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ });
+ const auditSummary = await loadAuditSummary(version);
+ const visualSummary = await loadVisualSummary();
if (validation.status !== 'FAIL') {
await persistVersionManagementMetadata({
versionId: args.versionId,
organizationId: args.organizationId,
- schemaJson: withManagementMetadata(row.schemaJson, {
- ...metadata,
+ metadataJson: mergeManagementMetadata({
+ currentMetadataJson: row.metadataJson,
+ currentSchemaJson: row.schemaJson,
+ patch: {
lifecycleStatus:
inferLifecycleStatus(row) === 'draft' ? 'validated' : inferLifecycleStatus(row),
validatedAt: validation.validatedAt,
+ validationSummary: validation,
+ auditSummary,
+ visualSummary,
+ },
}),
});
}
@@ -418,18 +554,25 @@ export async function publishDocumentTemplateVersion(args: {
}
const row = await getVersionRow(args.versionId, args.organizationId);
- const metadata = getManagementMetadata(row.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ });
await persistVersionManagementMetadata({
versionId: args.versionId,
organizationId: args.organizationId,
isActive: false,
- schemaJson: withManagementMetadata(row.schemaJson, {
- ...metadata,
- lifecycleStatus: 'published',
- runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
- publishedAt: new Date().toISOString(),
- publishedBy: args.userId,
- validatedAt: validation.validatedAt,
+ metadataJson: mergeManagementMetadata({
+ currentMetadataJson: row.metadataJson,
+ currentSchemaJson: row.schemaJson,
+ patch: {
+ lifecycleStatus: 'published',
+ runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
+ publishedAt: new Date().toISOString(),
+ publishedBy: args.userId,
+ validatedAt: validation.validatedAt,
+ validationSummary: validation,
+ },
}),
});
@@ -451,34 +594,48 @@ export async function activateDocumentTemplateVersion(args: {
const currentActive = siblings.find((item) => item.isActive && item.id !== row.id) ?? null;
if (currentActive) {
- const previousMetadata = getManagementMetadata(currentActive.schemaJson);
+ const previousMetadata = getManagementMetadata({
+ metadataJson: currentActive.metadataJson,
+ schemaJson: currentActive.schemaJson,
+ });
await persistVersionManagementMetadata({
versionId: currentActive.id,
organizationId: args.organizationId,
isActive: false,
- schemaJson: withManagementMetadata(currentActive.schemaJson, {
- ...previousMetadata,
- lifecycleStatus: 'published',
- nextVersionId: row.id,
+ metadataJson: mergeManagementMetadata({
+ currentMetadataJson: currentActive.metadataJson,
+ currentSchemaJson: currentActive.schemaJson,
+ patch: {
+ ...previousMetadata,
+ lifecycleStatus: 'published',
+ nextVersionId: row.id,
+ },
}),
});
}
- const metadata = getManagementMetadata(row.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ });
await persistVersionManagementMetadata({
versionId: row.id,
organizationId: args.organizationId,
isActive: true,
- schemaJson: withManagementMetadata(row.schemaJson, {
- ...metadata,
- lifecycleStatus: 'active',
- runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
- publishedAt: metadata.publishedAt ?? new Date().toISOString(),
- publishedBy: metadata.publishedBy ?? args.userId,
- activatedAt: new Date().toISOString(),
- activatedBy: args.userId,
- previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
- nextVersionId: null,
+ schemaJson: sanitizeTemplateSchemaJson(row.schemaJson),
+ metadataJson: mergeManagementMetadata({
+ currentMetadataJson: row.metadataJson,
+ currentSchemaJson: row.schemaJson,
+ patch: {
+ lifecycleStatus: 'active',
+ runtimeVersion: metadata.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
+ publishedAt: metadata.publishedAt ?? new Date().toISOString(),
+ publishedBy: metadata.publishedBy ?? args.userId,
+ activatedAt: new Date().toISOString(),
+ activatedBy: args.userId,
+ previousVersionId: currentActive?.id ?? metadata.previousVersionId ?? null,
+ nextVersionId: null,
+ },
}),
});
@@ -495,7 +652,10 @@ export async function rollbackDocumentTemplateVersion(args: {
throw new AuthError('Rollback is only allowed from the current active version.', 409);
}
- const metadata = getManagementMetadata(row.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ });
let targetVersionId = metadata.previousVersionId ?? null;
if (!targetVersionId) {
@@ -535,16 +695,22 @@ export async function archiveDocumentTemplateVersion(args: {
throw new AuthError('Active versions must be rolled back before archive.', 409);
}
- const metadata = getManagementMetadata(row.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ });
await persistVersionManagementMetadata({
versionId: row.id,
organizationId: args.organizationId,
isActive: false,
- schemaJson: withManagementMetadata(row.schemaJson, {
- ...metadata,
- lifecycleStatus: 'archived',
- archivedAt: new Date().toISOString(),
- archivedBy: args.userId,
+ metadataJson: mergeManagementMetadata({
+ currentMetadataJson: row.metadataJson,
+ currentSchemaJson: row.schemaJson,
+ patch: {
+ lifecycleStatus: 'archived',
+ archivedAt: new Date().toISOString(),
+ archivedBy: args.userId,
+ },
}),
});
@@ -585,6 +751,10 @@ export async function compareDocumentTemplateVersions(args: {
const leftPlaceholderSet = new Set(left.mappings.map((item) => item.placeholderKey));
const rightPlaceholderSet = new Set(right.mappings.map((item) => item.placeholderKey));
+ const leftFields = extractTemplateFields(left.schemaJson);
+ const rightFields = extractTemplateFields(right.schemaJson);
+ const leftFieldNames = new Set(leftFields.map((field) => field.name).filter(Boolean));
+ const rightFieldNames = new Set(rightFields.map((field) => field.name).filter(Boolean));
const mappingDifferences = [
...left.mappings.flatMap((mapping) => {
@@ -614,12 +784,52 @@ export async function compareDocumentTemplateVersions(args: {
});
return currentSignature === otherSignature
? []
- : [{ placeholderKey: mapping.placeholderKey, difference: 'Mapping contract changed' }];
+ : [{
+ placeholderKey: mapping.placeholderKey,
+ difference: 'Mapping contract changed',
+ leftValue: currentSignature,
+ rightValue: otherSignature,
+ }];
}),
];
const leftValidation = await validateDocumentTemplateVersion(left.id, args.organizationId);
const rightValidation = await validateDocumentTemplateVersion(right.id, args.organizationId);
+ const jsonDiff: DocumentTemplateVersionCompareResponse['comparison']['jsonDiff'] = [];
+
+ const fieldSchemaChanges = {
+ addedFields: [...rightFieldNames].filter((field) => !leftFieldNames.has(field)).sort(),
+ removedFields: [...leftFieldNames].filter((field) => !rightFieldNames.has(field)).sort(),
+ changedFields: leftFields
+ .flatMap((leftField) => {
+ const rightField = rightFields.find((item) => item.name === leftField.name);
+ if (!rightField) {
+ return [];
+ }
+ if (leftField.type === rightField.type) {
+ return [];
+ }
+ return [
+ {
+ field: leftField.name,
+ difference: `${leftField.type} -> ${rightField.type}`,
+ },
+ ];
+ })
+ .sort((a, b) => a.field.localeCompare(b.field)),
+ };
+
+ const leftSchema = sanitizeTemplateSchemaJson(left.schemaJson) as Record;
+ const rightSchema = sanitizeTemplateSchemaJson(right.schemaJson) as Record;
+ const leftPages = Array.isArray(leftSchema.schemas) ? leftSchema.schemas.length : 0;
+ const rightPages = Array.isArray(rightSchema.schemas) ? rightSchema.schemas.length : 0;
+ if (leftPages !== rightPages) {
+ jsonDiff.push({
+ path: 'schemas.length',
+ left: leftPages,
+ right: rightPages,
+ });
+ }
return {
leftVersionId: left.id,
@@ -628,10 +838,14 @@ export async function compareDocumentTemplateVersions(args: {
placeholderOnlyLeft: [...leftPlaceholderSet].filter((item) => !rightPlaceholderSet.has(item)),
placeholderOnlyRight: [...rightPlaceholderSet].filter((item) => !leftPlaceholderSet.has(item)),
mappingDifferences,
+ fieldSchemaChanges,
+ jsonDiff,
runtimeCompatibility: {
leftStatus: leftValidation.status,
rightStatus: rightValidation.status,
},
+ leftValidation,
+ rightValidation,
};
}
diff --git a/src/features/foundation/document-template/server/metadata.test.ts b/src/features/foundation/document-template/server/metadata.test.ts
new file mode 100644
index 0000000..aa30027
--- /dev/null
+++ b/src/features/foundation/document-template/server/metadata.test.ts
@@ -0,0 +1,72 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ getLegacyManagementMetadata,
+ getManagementMetadata,
+ mergeManagementMetadata,
+ sanitizeTemplateSchemaJson,
+} from './metadata';
+
+test('sanitizeTemplateSchemaJson removes embedded legacy management metadata', () => {
+ const schemaJson = {
+ basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
+ schemas: [],
+ __templateManagement: {
+ lifecycleStatus: 'published',
+ runtimeVersion: 'p5',
+ },
+ };
+
+ assert.deepEqual(sanitizeTemplateSchemaJson(schemaJson), {
+ basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
+ schemas: [],
+ });
+});
+
+test('getManagementMetadata falls back to legacy schema metadata when metadata_json is absent', () => {
+ const metadata = getManagementMetadata({
+ schemaJson: {
+ schemas: [],
+ __templateManagement: {
+ lifecycleStatus: 'validated',
+ runtimeVersion: 'legacy-runtime',
+ },
+ },
+ });
+
+ assert.equal(metadata.lifecycleStatus, 'validated');
+ assert.equal(metadata.runtimeVersion, 'legacy-runtime');
+});
+
+test('mergeManagementMetadata prefers dedicated metadata_json but preserves compatibility fallback', () => {
+ const metadata = mergeManagementMetadata({
+ currentMetadataJson: {
+ lifecycleStatus: 'draft',
+ runtimeVersion: 'p5.1',
+ },
+ currentSchemaJson: {
+ __templateManagement: {
+ lifecycleStatus: 'published',
+ runtimeVersion: 'legacy-runtime',
+ },
+ },
+ patch: {
+ lifecycleStatus: 'active',
+ publishedBy: 'user-1',
+ },
+ });
+
+ assert.equal(metadata.lifecycleStatus, 'active');
+ assert.equal(metadata.runtimeVersion, 'p5.1');
+ assert.equal(metadata.publishedBy, 'user-1');
+});
+
+test('getLegacyManagementMetadata returns empty object for clean PDFMe schema', () => {
+ assert.deepEqual(
+ getLegacyManagementMetadata({
+ basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] },
+ schemas: [],
+ }),
+ {},
+ );
+});
diff --git a/src/features/foundation/document-template/server/metadata.ts b/src/features/foundation/document-template/server/metadata.ts
new file mode 100644
index 0000000..a5f6624
--- /dev/null
+++ b/src/features/foundation/document-template/server/metadata.ts
@@ -0,0 +1,86 @@
+import type {
+ DocumentTemplateLifecycleStatus,
+ DocumentTemplateVersionAuditSummary,
+ DocumentTemplateVersionValidationSummary,
+} from '../types';
+
+export const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5.1-template-management';
+
+export interface DocumentTemplateVisualSummary {
+ status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
+ generatedAt: string | null;
+ changedPages: number;
+ layoutDifferences: number;
+}
+
+export interface DocumentTemplateManagementMetadata {
+ lifecycleStatus?: DocumentTemplateLifecycleStatus;
+ runtimeVersion?: string | null;
+ templateVariant?: string | null;
+ brand?: string | null;
+ publishedAt?: string | null;
+ publishedBy?: string | null;
+ activatedAt?: string | null;
+ activatedBy?: string | null;
+ archivedAt?: string | null;
+ archivedBy?: string | null;
+ previousVersionId?: string | null;
+ nextVersionId?: string | null;
+ validatedAt?: string | null;
+ validationSummary?: DocumentTemplateVersionValidationSummary | null;
+ auditSummary?: DocumentTemplateVersionAuditSummary | null;
+ visualSummary?: DocumentTemplateVisualSummary | null;
+}
+
+function getSchemaRecord(schemaJson: unknown): Record {
+ return schemaJson && typeof schemaJson === 'object'
+ ? { ...(schemaJson as Record) }
+ : {};
+}
+
+export function getLegacyManagementMetadata(
+ schemaJson: unknown,
+): DocumentTemplateManagementMetadata {
+ const schema = getSchemaRecord(schemaJson);
+ const metadata = schema.__templateManagement;
+
+ return metadata && typeof metadata === 'object'
+ ? { ...(metadata as DocumentTemplateManagementMetadata) }
+ : {};
+}
+
+export function getManagementMetadata(args: {
+ metadataJson?: unknown;
+ schemaJson?: unknown;
+}): DocumentTemplateManagementMetadata {
+ if (args.metadataJson && typeof args.metadataJson === 'object') {
+ return { ...(args.metadataJson as DocumentTemplateManagementMetadata) };
+ }
+
+ return getLegacyManagementMetadata(args.schemaJson);
+}
+
+export function sanitizeTemplateSchemaJson(schemaJson: unknown): unknown {
+ const schema = getSchemaRecord(schemaJson);
+
+ if (!('__templateManagement' in schema)) {
+ return schemaJson;
+ }
+
+ const { __templateManagement: _management, ...sanitized } = schema;
+ return sanitized;
+}
+
+export function mergeManagementMetadata(args: {
+ currentMetadataJson?: unknown;
+ currentSchemaJson?: unknown;
+ patch: DocumentTemplateManagementMetadata;
+}): DocumentTemplateManagementMetadata {
+ return {
+ ...getManagementMetadata({
+ metadataJson: args.currentMetadataJson,
+ schemaJson: args.currentSchemaJson,
+ }),
+ ...args.patch,
+ };
+}
diff --git a/src/features/foundation/document-template/server/service.test.ts b/src/features/foundation/document-template/server/service.test.ts
new file mode 100644
index 0000000..098fe83
--- /dev/null
+++ b/src/features/foundation/document-template/server/service.test.ts
@@ -0,0 +1,16 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createDuplicateVersionLabel } from './service';
+
+test('createDuplicateVersionLabel creates draft label first', () => {
+ const label = createDuplicateVersionLabel(['1.0', '2.0'], '2.0');
+ assert.equal(label, '2.0-draft');
+});
+
+test('createDuplicateVersionLabel increments copy suffix when draft already exists', () => {
+ const label = createDuplicateVersionLabel(
+ ['2.0', '2.0-draft', '2.0-copy-1'],
+ '2.0',
+ );
+ assert.equal(label, '2.0-copy-2');
+});
diff --git a/src/features/foundation/document-template/server/service.ts b/src/features/foundation/document-template/server/service.ts
index 16bff0e..05d8fd4 100644
--- a/src/features/foundation/document-template/server/service.ts
+++ b/src/features/foundation/document-template/server/service.ts
@@ -12,6 +12,12 @@ import {
formatPdfDate,
normalizePdfmeTable
} from '@/features/crm/quotations/document/server/pdfme-transforms';
+import {
+ CURRENT_TEMPLATE_RUNTIME_VERSION,
+ getManagementMetadata,
+ mergeManagementMetadata,
+ sanitizeTemplateSchemaJson,
+} from './metadata';
import type {
DocumentTemplateDetail,
DocumentTemplateFilters,
@@ -30,54 +36,9 @@ import type {
ResolvedDocumentTemplate
} from '../types';
-const CURRENT_TEMPLATE_RUNTIME_VERSION = 'p5-template-management';
-
-type DocumentTemplateManagementMetadata = {
- lifecycleStatus?: DocumentTemplateLifecycleStatus;
- runtimeVersion?: string | null;
- templateVariant?: string | null;
- brand?: string | null;
- publishedAt?: string | null;
- publishedBy?: string | null;
- activatedAt?: string | null;
- activatedBy?: string | null;
- archivedAt?: string | null;
- archivedBy?: string | null;
- previousVersionId?: string | null;
- nextVersionId?: string | null;
- validatedAt?: string | null;
-};
-
-function getTemplateSchemaRecord(schemaJson: unknown): Record {
- return schemaJson && typeof schemaJson === 'object'
- ? { ...(schemaJson as Record) }
- : {};
-}
-
-function getManagementMetadata(schemaJson: unknown): DocumentTemplateManagementMetadata {
- const schema = getTemplateSchemaRecord(schemaJson);
- const metadata = schema.__templateManagement;
- return metadata && typeof metadata === 'object'
- ? { ...(metadata as DocumentTemplateManagementMetadata) }
- : {};
-}
-
-function withManagementMetadata(
- schemaJson: unknown,
- metadata: DocumentTemplateManagementMetadata
-): unknown {
- return {
- ...getTemplateSchemaRecord(schemaJson),
- __templateManagement: {
- ...getManagementMetadata(schemaJson),
- ...metadata
- }
- };
-}
-
function inferLifecycleStatus(args: {
isActive: boolean;
- metadata: DocumentTemplateManagementMetadata;
+ metadata: ReturnType;
}): DocumentTemplateLifecycleStatus {
if (args.isActive) {
return 'active';
@@ -108,7 +69,10 @@ function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): Docum
function mapVersionRecord(
row: typeof crmDocumentTemplateVersions.$inferSelect
): DocumentTemplateVersionRecord {
- const metadata = getManagementMetadata(row.schemaJson);
+ const metadata = getManagementMetadata({
+ metadataJson: row.metadataJson,
+ schemaJson: row.schemaJson,
+ });
return {
id: row.id,
@@ -116,7 +80,8 @@ function mapVersionRecord(
templateId: row.templateId,
version: row.version,
filePath: row.filePath,
- schemaJson: row.schemaJson,
+ schemaJson: sanitizeTemplateSchemaJson(row.schemaJson),
+ metadataJson: row.metadataJson ?? null,
previewImageUrl: row.previewImageUrl,
isActive: row.isActive,
lifecycleStatus: inferLifecycleStatus({
@@ -502,6 +467,15 @@ export async function createDocumentTemplateVersion(
payload: DocumentTemplateVersionMutationPayload
) {
await assertTemplate(templateId, organizationId);
+ const sanitizedSchemaJson = sanitizeTemplateSchemaJson(payload.schemaJson);
+ const metadataJson = mergeManagementMetadata({
+ patch: {
+ lifecycleStatus: payload.isActive ? 'active' : 'draft',
+ runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
+ templateVariant: payload.templateVariant ?? null,
+ brand: payload.brand ?? null,
+ },
+ });
const [created] = await db
.insert(crmDocumentTemplateVersions)
.values({
@@ -510,9 +484,10 @@ export async function createDocumentTemplateVersion(
templateId,
version: payload.version.trim(),
filePath: payload.filePath?.trim() || null,
- schemaJson: payload.schemaJson,
+ schemaJson: sanitizedSchemaJson,
+ metadataJson,
previewImageUrl: payload.previewImageUrl?.trim() || null,
- isActive: payload.isActive ?? true,
+ isActive: payload.isActive ?? false,
createdBy: userId
})
.returning();
@@ -526,12 +501,28 @@ export async function updateDocumentTemplateVersion(
payload: Partial
) {
const current = await assertTemplateVersion(versionId, organizationId);
+ const currentMetadata = getManagementMetadata({
+ metadataJson: current.metadataJson,
+ schemaJson: current.schemaJson,
+ });
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
version: payload.version?.trim() ?? current.version,
filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null,
- schemaJson: payload.schemaJson ?? current.schemaJson,
+ schemaJson:
+ payload.schemaJson === undefined
+ ? sanitizeTemplateSchemaJson(current.schemaJson)
+ : sanitizeTemplateSchemaJson(payload.schemaJson),
+ metadataJson: mergeManagementMetadata({
+ currentMetadataJson: current.metadataJson,
+ currentSchemaJson: current.schemaJson,
+ patch: {
+ runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null,
+ templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null,
+ brand: payload.brand ?? currentMetadata.brand ?? null,
+ },
+ }),
previewImageUrl:
payload.previewImageUrl === undefined
? current.previewImageUrl
@@ -545,6 +536,114 @@ export async function updateDocumentTemplateVersion(
return updated;
}
+export function createDuplicateVersionLabel(existingVersions: string[], baseVersion: string) {
+ const normalizedBase = baseVersion.trim();
+ const draftCandidate = `${normalizedBase}-draft`;
+
+ if (!existingVersions.includes(draftCandidate)) {
+ return draftCandidate;
+ }
+
+ let suffix = 1;
+ while (existingVersions.includes(`${normalizedBase}-copy-${suffix}`)) {
+ suffix += 1;
+ }
+
+ return `${normalizedBase}-copy-${suffix}`;
+}
+
+export async function duplicateDocumentTemplateVersion(
+ versionId: string,
+ organizationId: string,
+ userId: string,
+) {
+ const sourceVersion = await assertTemplateVersion(versionId, organizationId);
+ const siblingVersions = await db
+ .select()
+ .from(crmDocumentTemplateVersions)
+ .where(
+ and(
+ eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId),
+ eq(crmDocumentTemplateVersions.organizationId, organizationId),
+ isNull(crmDocumentTemplateVersions.deletedAt),
+ ),
+ )
+ .orderBy(asc(crmDocumentTemplateVersions.createdAt));
+ const metadata = getManagementMetadata({
+ metadataJson: sourceVersion.metadataJson,
+ schemaJson: sourceVersion.schemaJson,
+ });
+ const duplicateId = crypto.randomUUID();
+ const duplicateVersionLabel = createDuplicateVersionLabel(
+ siblingVersions.map((item) => item.version),
+ sourceVersion.version,
+ );
+
+ const [createdVersion] = await db
+ .insert(crmDocumentTemplateVersions)
+ .values({
+ id: duplicateId,
+ organizationId,
+ templateId: sourceVersion.templateId,
+ version: duplicateVersionLabel,
+ filePath: sourceVersion.filePath,
+ schemaJson: sanitizeTemplateSchemaJson(sourceVersion.schemaJson),
+ metadataJson: {
+ ...metadata,
+ lifecycleStatus: 'draft',
+ publishedAt: null,
+ publishedBy: null,
+ activatedAt: null,
+ activatedBy: null,
+ archivedAt: null,
+ archivedBy: null,
+ previousVersionId: sourceVersion.id,
+ nextVersionId: null,
+ validationSummary: null,
+ auditSummary: null,
+ visualSummary: null,
+ },
+ previewImageUrl: sourceVersion.previewImageUrl,
+ isActive: false,
+ createdBy: userId,
+ })
+ .returning();
+
+ const mappings = await resolveTemplateMappings(sourceVersion.id, organizationId);
+ for (const mapping of mappings) {
+ const newMappingId = crypto.randomUUID();
+ await db.insert(crmDocumentTemplateMappings).values({
+ id: newMappingId,
+ organizationId,
+ templateVersionId: duplicateId,
+ placeholderKey: mapping.placeholderKey,
+ sourcePath: mapping.sourcePath,
+ dataType: mapping.dataType,
+ sheetName: mapping.sheetName,
+ defaultValue: mapping.defaultValue,
+ formatMask: mapping.formatMask,
+ sortOrder: mapping.sortOrder,
+ });
+
+ if (mapping.columns.length) {
+ await db.insert(crmDocumentTemplateTableColumns).values(
+ mapping.columns.map((column) => ({
+ id: crypto.randomUUID(),
+ organizationId,
+ mappingId: newMappingId,
+ columnName: column.columnName,
+ sourceField: column.sourceField,
+ columnLetter: column.columnLetter,
+ sortOrder: column.sortOrder,
+ formatMask: column.formatMask,
+ })),
+ );
+ }
+ }
+
+ return createdVersion;
+}
+
export async function setDocumentTemplateVersionActive(
versionId: string,
organizationId: string,
diff --git a/src/features/foundation/document-template/service.ts b/src/features/foundation/document-template/service.ts
index 3e6b7ef..5336414 100644
--- a/src/features/foundation/document-template/service.ts
+++ b/src/features/foundation/document-template/service.ts
@@ -145,6 +145,12 @@ export async function archiveDocumentTemplateVersion(id: string) {
});
}
+export async function duplicateDocumentTemplateVersion(id: string) {
+ return apiClient(`${BASE_PATH}/versions/${id}/duplicate`, {
+ method: 'POST',
+ });
+}
+
export async function previewDocumentTemplateVersion(id: string) {
return apiClient(`${BASE_PATH}/versions/${id}/preview`);
}
diff --git a/src/features/foundation/document-template/types.ts b/src/features/foundation/document-template/types.ts
index 1c32814..c195b68 100644
--- a/src/features/foundation/document-template/types.ts
+++ b/src/features/foundation/document-template/types.ts
@@ -5,6 +5,8 @@ export type DocumentTemplateLifecycleStatus =
| 'active'
| 'archived';
+export type TemplateHealthStatus = 'pass' | 'warning' | 'fail';
+
export interface DocumentTemplateRecord {
id: string;
organizationId: string;
@@ -28,6 +30,19 @@ export interface DocumentTemplateVersionValidationSummary {
requiredFieldsMissing: string[];
duplicateFields: string[];
unmappedPlaceholders: string[];
+ orphanMappings: string[];
+ unsupportedSchemaTypes: string[];
+ unknownPlugins: string[];
+ sectionMarkerIssues: string[];
+ suggestedNextStep?: string | null;
+ issues: Array<{
+ code: string;
+ severity: 'warning' | 'error';
+ location: string;
+ message: string;
+ suggestedFix: string | null;
+ }>;
+ healthStatus: TemplateHealthStatus;
runtimeIssues: Array<{
code: string;
severity: 'warning' | 'error';
@@ -43,6 +58,13 @@ export interface DocumentTemplateVersionAuditSummary {
visualRegressionAt: string | null;
}
+export interface DocumentTemplateVersionVisualSummary {
+ status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN';
+ generatedAt: string | null;
+ changedPages: number;
+ layoutDifferences: number;
+}
+
export interface DocumentTemplateVersionRecord {
id: string;
organizationId: string;
@@ -50,6 +72,7 @@ export interface DocumentTemplateVersionRecord {
version: string;
filePath: string | null;
schemaJson: unknown;
+ metadataJson?: unknown;
previewImageUrl: string | null;
isActive: boolean;
lifecycleStatus?: DocumentTemplateLifecycleStatus;
@@ -130,6 +153,7 @@ export interface DocumentTemplateVersionDetail
mappings: DocumentTemplateMappingWithColumns[];
validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null;
+ visualSummary?: DocumentTemplateVersionVisualSummary | null;
}
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
@@ -228,11 +252,28 @@ export interface DocumentTemplateVersionCompareResponse
mappingDifferences: Array<{
placeholderKey: string;
difference: string;
+ leftValue?: string | null;
+ rightValue?: string | null;
+ }>;
+ fieldSchemaChanges: {
+ addedFields: string[];
+ removedFields: string[];
+ changedFields: Array<{
+ field: string;
+ difference: string;
+ }>;
+ };
+ jsonDiff: Array<{
+ path: string;
+ left: string | number | boolean | null;
+ right: string | number | boolean | null;
}>;
runtimeCompatibility: {
leftStatus: 'PASS' | 'WARNING' | 'FAIL';
rightStatus: 'PASS' | 'WARNING' | 'FAIL';
};
+ leftValidation: DocumentTemplateVersionValidationSummary;
+ rightValidation: DocumentTemplateVersionValidationSummary;
};
}
diff --git a/src/features/foundation/pdf-audit/server/shared.ts b/src/features/foundation/pdf-audit/server/shared.ts
new file mode 100644
index 0000000..807d317
--- /dev/null
+++ b/src/features/foundation/pdf-audit/server/shared.ts
@@ -0,0 +1,1649 @@
+import fs from 'node:fs';
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import postgres from 'postgres';
+import { resolveQuotationStaticTemplateInputs } from '../../../crm/quotations/document/server/static-input-resolver.ts';
+
+export const AUDIT_QUOTATION_CODE = 'QT-H5-AUDIT';
+export const AUDIT_ARTIFACTS_DIR = path.resolve(
+ /* turbopackIgnore: true */ process.cwd(),
+ 'artifacts',
+ 'pdf-audit',
+);
+export const REPORT_JSON_PATH = path.resolve(
+ /* turbopackIgnore: true */ process.cwd(),
+ 'docs/implementation/pdf-audit-report.json'
+);
+export const REPORT_MARKDOWN_PATH = path.resolve(
+ /* turbopackIgnore: true */ process.cwd(),
+ 'docs/implementation/pdf-audit-report.md'
+);
+
+export type SqlClient = ReturnType;
+
+export interface TemplateFieldRecord {
+ pageIndex: number;
+ fieldIndex: number;
+ name: string;
+ type: string;
+ tokens: string[];
+}
+
+export interface ActiveTemplateVersionRecord {
+ templateId: string;
+ templateName: string;
+ organizationId: string;
+ documentType: string;
+ productType: string;
+ fileType: string;
+ versionId: string;
+ version: string;
+ filePath: string | null;
+ schemaJson: unknown;
+}
+
+export interface MappingRecord {
+ id: string;
+ organizationId: string;
+ templateVersionId: string;
+ placeholderKey: string;
+ sourcePath: string;
+ dataType: 'scalar' | 'multiline' | 'table' | 'image';
+ defaultValue: string | null;
+ formatMask: string | null;
+ sortOrder: number;
+}
+
+export interface MappingColumnRecord {
+ mappingId: string;
+ columnName: string;
+ sourceField: string;
+ sortOrder: number;
+ formatMask: string | null;
+}
+
+export type TemplateFieldClassification =
+ | 'static input field'
+ | 'dynamic topic template'
+ | 'readonly/static visual field'
+ | 'system field'
+ | 'unknown field';
+
+export interface TemplateMappingSeed {
+ placeholderKey: string;
+ sourcePath: string;
+ dataType: 'scalar' | 'multiline' | 'table' | 'image';
+ defaultValue?: string | null;
+ formatMask?: string | null;
+ sortOrder: number;
+ columns?: Array<{
+ columnName: string;
+ sourceField: string;
+ columnLetter?: string;
+ sortOrder: number;
+ formatMask?: string | null;
+ }>;
+}
+
+export interface AuditPayload {
+ organizationId: string;
+ quotationId: string;
+ quotationCode: string;
+ templateVersionId: string;
+ approvedTemplateVersionId: string | null;
+ templateName: string;
+ templateVersion: string;
+ templateSchema: unknown;
+ mappings: MappingRecord[];
+ mappingColumns: MappingColumnRecord[];
+ documentData: Record;
+ templateInput: Record;
+ topicInputs: Record;
+ quotationMetrics: {
+ topicCount: number;
+ itemCount: number;
+ partyCount: number;
+ };
+ approvedSnapshot: Record | null;
+ approvedArtifactReference: string | null;
+}
+
+const SYSTEM_FIELD_NAMES = new Set([
+ '',
+ 'company1',
+ 'company',
+ 'company2',
+ 'company_addr1',
+ 'company_addr2',
+ 'company_tel',
+ 'company_email',
+ 'company_tax',
+ 'Please_do_not',
+ 'yours_faithfuly'
+]);
+
+const DYNAMIC_TOPIC_FIELD_NAMES = new Set(['topic', 'data_topic']);
+const LEGACY_DYNAMIC_SEED_TOKENS = new Set(['item_topic']);
+const DYNAMIC_TOPIC_TOKEN_PREFIXES = ['topic_', 'item_topic_'];
+const DESIGNER_LABEL_NAME_PATTERNS = [/_label$/i, /_lable$/i];
+const STATIC_VISUAL_FIELD_NAMES = new Set([
+ 'colon',
+ 'colon copy',
+ 'colon_att',
+ 'colon_email',
+ 'colon_fax',
+ 'colon_project',
+ 'colon_site',
+ 'colon_tel',
+ 'dear_sirs',
+ 'line',
+ 'yours_faithfuly',
+ 'quotation_code_lable',
+ 'quotation_date_labe'
+]);
+export const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
+export const PDFME_STATIC_LABELS = {
+ tel: 'Tel',
+ email: 'Email',
+ att: 'Att',
+ project: 'Project',
+ location: 'Location'
+} as const;
+export const PDFME_STATIC_LABEL_FIELD_KEYS = [
+ 'tel_label',
+ 'email_label',
+ 'att_label',
+ 'project_label',
+ 'site_label'
+] as const;
+export type TemplateSourceVariant = 'legacy' | 'product-v1';
+export const SUPPORTED_TEMPLATE_MAPPING_SEEDS: TemplateMappingSeed[] = [
+ {
+ placeholderKey: 'customer_name',
+ sourcePath: 'customer.name',
+ dataType: 'scalar',
+ sortOrder: 1
+ },
+ {
+ placeholderKey: 'customer_addr',
+ sourcePath: 'customer.address',
+ dataType: 'multiline',
+ sortOrder: 2
+ },
+ {
+ placeholderKey: 'customer_tel',
+ sourcePath: 'customer.phone',
+ dataType: 'scalar',
+ sortOrder: 3
+ },
+ {
+ placeholderKey: 'customer_email',
+ sourcePath: 'customer.email',
+ dataType: 'scalar',
+ sortOrder: 4
+ },
+ {
+ placeholderKey: 'customer_att',
+ sourcePath: 'quotation.attention',
+ dataType: 'scalar',
+ sortOrder: 5
+ },
+ {
+ placeholderKey: 'project_name',
+ sourcePath: 'quotation.projectName',
+ dataType: 'scalar',
+ sortOrder: 6
+ },
+ {
+ placeholderKey: 'site_location',
+ sourcePath: 'quotation.projectLocation',
+ dataType: 'multiline',
+ sortOrder: 7
+ },
+ {
+ placeholderKey: 'quotation_date_data',
+ sourcePath: 'pdfme.quotation_date',
+ dataType: 'scalar',
+ sortOrder: 8
+ },
+ {
+ placeholderKey: 'quotation_code_data',
+ sourcePath: 'quotation.code',
+ dataType: 'scalar',
+ sortOrder: 9
+ },
+ {
+ placeholderKey: 'quotation_price_data',
+ sourcePath: 'pdfme.quotation_price_data',
+ dataType: 'table',
+ defaultValue: JSON.stringify([[PRICE_TABLE_LABEL, '-', ' ', 'THB']]),
+ sortOrder: 10
+ },
+ {
+ placeholderKey: 'quotation_price',
+ sourcePath: 'pdfme.quotation_price',
+ dataType: 'scalar',
+ sortOrder: 11
+ },
+ {
+ placeholderKey: 'currency',
+ sourcePath: 'quotation.currency',
+ dataType: 'scalar',
+ sortOrder: 12
+ },
+ {
+ placeholderKey: 'exclusion_data',
+ sourcePath: 'pdfme.exclusion_data',
+ dataType: 'table',
+ sortOrder: 13
+ },
+ {
+ placeholderKey: 'tel_label',
+ sourcePath: 'pdfme.labels.tel',
+ dataType: 'scalar',
+ defaultValue: PDFME_STATIC_LABELS.tel,
+ sortOrder: 14
+ },
+ {
+ placeholderKey: 'email_label',
+ sourcePath: 'pdfme.labels.email',
+ dataType: 'scalar',
+ defaultValue: PDFME_STATIC_LABELS.email,
+ sortOrder: 15
+ },
+ {
+ placeholderKey: 'att_label',
+ sourcePath: 'pdfme.labels.att',
+ dataType: 'scalar',
+ defaultValue: PDFME_STATIC_LABELS.att,
+ sortOrder: 16
+ },
+ {
+ placeholderKey: 'project_label',
+ sourcePath: 'pdfme.labels.project',
+ dataType: 'scalar',
+ defaultValue: PDFME_STATIC_LABELS.project,
+ sortOrder: 17
+ },
+ {
+ placeholderKey: 'site_label',
+ sourcePath: 'pdfme.labels.location',
+ dataType: 'scalar',
+ defaultValue: PDFME_STATIC_LABELS.location,
+ sortOrder: 18
+ },
+ {
+ placeholderKey: 'app1',
+ sourcePath: 'signatures.preparedBy.name',
+ dataType: 'scalar',
+ defaultValue: '-',
+ sortOrder: 19
+ },
+ {
+ placeholderKey: 'app1_position',
+ sourcePath: 'signatures.preparedBy.position',
+ dataType: 'scalar',
+ defaultValue: '-',
+ sortOrder: 20
+ },
+ {
+ placeholderKey: 'app2',
+ sourcePath: 'signatures.approvedBy.name',
+ dataType: 'scalar',
+ defaultValue: '-',
+ sortOrder: 21
+ },
+ {
+ placeholderKey: 'app2_position',
+ sourcePath: 'signatures.approvedBy.position',
+ dataType: 'scalar',
+ defaultValue: '-',
+ sortOrder: 22
+ },
+ {
+ placeholderKey: 'app3',
+ sourcePath: 'signatures.authorizedBy.name',
+ dataType: 'scalar',
+ defaultValue: '-',
+ sortOrder: 23
+ },
+ {
+ placeholderKey: 'app3_position',
+ sourcePath: 'signatures.authorizedBy.position',
+ dataType: 'scalar',
+ defaultValue: '-',
+ sortOrder: 24
+ },
+ {
+ placeholderKey: 'items_table',
+ sourcePath: 'items',
+ dataType: 'table',
+ sortOrder: 25,
+ columns: [
+ { columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
+ { columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
+ { columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 },
+ { columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 },
+ {
+ columnName: 'Unit Price',
+ sourceField: 'unitPrice',
+ columnLetter: 'E',
+ sortOrder: 5,
+ formatMask: 'currency'
+ },
+ {
+ columnName: 'Discount',
+ sourceField: 'discount',
+ columnLetter: 'F',
+ sortOrder: 6,
+ formatMask: 'currency'
+ },
+ {
+ columnName: 'Total',
+ sourceField: 'totalPrice',
+ columnLetter: 'G',
+ sortOrder: 7,
+ formatMask: 'currency'
+ }
+ ]
+ }
+] as const;
+const SIGNATURE_ROLE_FALLBACK_LABELS: Record = {
+ sales: 'Sales Engineer',
+ sales_support: 'Sales Support',
+ sales_manager: 'Sales Manager',
+ department_manager: 'Department Manager',
+ top_manager: 'General Manager'
+};
+
+export function loadEnvFile(filePath: string) {
+ 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;
+ }
+ }
+}
+
+export function loadLocalEnv() {
+ loadEnvFile(path.resolve(/* turbopackIgnore: true */ process.cwd(), '.env.local'));
+ loadEnvFile(path.resolve(/* turbopackIgnore: true */ process.cwd(), '.env'));
+}
+
+export function requireEnv(name: string) {
+ const value = process.env[name]?.trim();
+
+ if (!value) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+
+ return value;
+}
+
+export function createSqlClient() {
+ loadLocalEnv();
+ return postgres(requireEnv('DATABASE_URL'), {
+ prepare: false
+ });
+}
+
+export async function ensureAuditArtifactsDir() {
+ await mkdir(AUDIT_ARTIFACTS_DIR, { recursive: true });
+}
+
+export async function writeAuditArtifact(fileName: string, payload: unknown) {
+ await ensureAuditArtifactsDir();
+ const targetPath = path.resolve(AUDIT_ARTIFACTS_DIR, fileName);
+ await writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
+ return targetPath;
+}
+
+export function computePayloadHash(value: unknown) {
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
+}
+
+export function extractTokensFromValue(value: unknown) {
+ const tokens = new Set();
+
+ if (typeof value !== 'string') {
+ return tokens;
+ }
+
+ for (const match of value.matchAll(/\{([^}]+)\}/g)) {
+ tokens.add(match[1]);
+ }
+
+ try {
+ const parsed = JSON.parse(value) as unknown;
+
+ if (Array.isArray(parsed)) {
+ for (const row of parsed) {
+ if (!Array.isArray(row)) {
+ continue;
+ }
+
+ for (const cell of row) {
+ if (typeof cell !== 'string') {
+ continue;
+ }
+
+ for (const match of cell.matchAll(/\{([^}]+)\}/g)) {
+ tokens.add(match[1]);
+ }
+ }
+ }
+ }
+ } catch {
+ return tokens;
+ }
+
+ return tokens;
+}
+
+export function extractSinglePlaceholder(value: unknown) {
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ const matches = [...value.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]);
+ const uniqueMatches = [...new Set(matches)];
+
+ return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
+}
+
+function normalizeSchemaJson(schemaJson: unknown) {
+ if (typeof schemaJson === 'string') {
+ try {
+ return JSON.parse(schemaJson) as unknown;
+ } catch {
+ return null;
+ }
+ }
+
+ return schemaJson;
+}
+
+export function extractTemplateFields(schemaJson: unknown) {
+ const fields: TemplateFieldRecord[] = [];
+ const normalizedSchemaJson = normalizeSchemaJson(schemaJson);
+
+ if (
+ !normalizedSchemaJson ||
+ typeof normalizedSchemaJson !== 'object' ||
+ !('schemas' in normalizedSchemaJson)
+ ) {
+ return fields;
+ }
+
+ const pages = (normalizedSchemaJson as { schemas?: unknown }).schemas;
+
+ if (!Array.isArray(pages)) {
+ return fields;
+ }
+
+ for (const [pageIndex, page] of pages.entries()) {
+ if (!Array.isArray(page)) {
+ continue;
+ }
+
+ for (const [fieldIndex, field] of page.entries()) {
+ if (!field || typeof field !== 'object') {
+ continue;
+ }
+
+ const typedField = field as {
+ name?: unknown;
+ type?: unknown;
+ content?: unknown;
+ head?: unknown;
+ };
+ const tokens = new Set();
+
+ for (const token of extractTokensFromValue(typedField.content)) {
+ tokens.add(token);
+ }
+
+ if (Array.isArray(typedField.head)) {
+ for (const value of typedField.head) {
+ for (const token of extractTokensFromValue(value)) {
+ tokens.add(token);
+ }
+ }
+ }
+
+ fields.push({
+ pageIndex,
+ fieldIndex,
+ name: typeof typedField.name === 'string' ? typedField.name : '',
+ type: typeof typedField.type === 'string' ? typedField.type : 'unknown',
+ tokens: [...tokens]
+ });
+ }
+ }
+
+ return fields;
+}
+
+export function getDuplicateFieldNames(fields: TemplateFieldRecord[]) {
+ const fieldNameCounts = new Map();
+
+ for (const field of fields) {
+ if (!field.name) {
+ continue;
+ }
+
+ fieldNameCounts.set(field.name, (fieldNameCounts.get(field.name) ?? 0) + 1);
+ }
+
+ return [...fieldNameCounts.entries()]
+ .filter(([, count]) => count > 1)
+ .map(([name]) => name)
+ .sort();
+}
+
+export function getUnknownFieldNames(
+ fields: TemplateFieldRecord[],
+ mappedKeys: Set = new Set()
+) {
+ return fields
+ .filter(
+ (field) =>
+ field.name && classifyTemplateField(field, mappedKeys) === 'unknown field'
+ )
+ .map((field) => field.name)
+ .sort();
+}
+
+export function isDesignerLabelField(field: TemplateFieldRecord) {
+ return (
+ field.type === 'text' &&
+ field.tokens.length === 0 &&
+ DESIGNER_LABEL_NAME_PATTERNS.some((pattern) => pattern.test(field.name))
+ );
+}
+
+export function classifyTemplateField(
+ field: TemplateFieldRecord,
+ mappedKeys: Set = new Set()
+): TemplateFieldClassification {
+ if (!field.name) {
+ return 'system field';
+ }
+
+ if (SYSTEM_FIELD_NAMES.has(field.name)) {
+ return 'system field';
+ }
+
+ if (DYNAMIC_TOPIC_FIELD_NAMES.has(field.name)) {
+ return 'dynamic topic template';
+ }
+
+ if (mappedKeys.has(field.name)) {
+ return 'static input field';
+ }
+
+ if (isDesignerLabelField(field) || STATIC_VISUAL_FIELD_NAMES.has(field.name)) {
+ return 'readonly/static visual field';
+ }
+
+ if (
+ field.tokens.length === 0 &&
+ ['text', 'line', 'rectangle', 'ellipse', 'image'].includes(field.type)
+ ) {
+ return 'readonly/static visual field';
+ }
+
+ return 'unknown field';
+}
+
+export function getEffectiveMappedKeys(
+ fields: TemplateFieldRecord[],
+ mappings: Array<{ placeholderKey: string }>
+) {
+ const mappedKeys = new Set(mappings.map((mapping) => mapping.placeholderKey));
+
+ for (const field of fields) {
+ if (!field.name || field.tokens.length !== 1) {
+ continue;
+ }
+
+ const [token] = field.tokens;
+
+ if (mappedKeys.has(field.name) && !mappedKeys.has(token)) {
+ mappedKeys.add(token);
+ }
+
+ if (mappedKeys.has(token) && !mappedKeys.has(field.name)) {
+ mappedKeys.add(field.name);
+ }
+ }
+
+ return mappedKeys;
+}
+
+export function normalizeTemplateInputAliases(
+ fields: TemplateFieldRecord[],
+ templateInput: Record
+) {
+ const normalized = { ...templateInput };
+
+ for (const field of fields) {
+ if (!field.name || field.tokens.length !== 1) {
+ continue;
+ }
+
+ const [token] = field.tokens;
+
+ if (!token || field.name === token) {
+ continue;
+ }
+
+ if (normalized[field.name] !== undefined && normalized[token] === undefined) {
+ normalized[token] = normalized[field.name];
+ }
+
+ if (normalized[token] !== undefined && normalized[field.name] === undefined) {
+ normalized[field.name] = normalized[token];
+ }
+ }
+
+ return normalized;
+}
+
+export function loadTemplateSourceByOrganizationName(
+ organizationName: string,
+ variant: TemplateSourceVariant = 'legacy',
+) {
+ const normalized = organizationName.toUpperCase();
+ const fileName = normalized.includes('ONVALLA')
+ ? (
+ variant === 'product-v1'
+ ? 'ONVALLA_template_pdfme_product_v1.json'
+ : 'ONVALLA_template_pdfme_fainal3.json'
+ )
+ : normalized.includes('ALLA')
+ ? (
+ variant === 'product-v1'
+ ? 'ALLA_template_pdfme_product_v1.json'
+ : 'ALLA_template_pdfme_fainal3.json'
+ )
+ : null;
+
+ if (!fileName) {
+ return null;
+ }
+
+ const filePath = path.resolve(
+ /* turbopackIgnore: true */ process.cwd(),
+ 'src',
+ 'pdfme_template',
+ fileName,
+ );
+ return {
+ fileName,
+ relativePath: `src/pdfme_template/${fileName}`,
+ absolutePath: filePath,
+ schemaJson: JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown
+ };
+}
+
+export function loadTemplateSourceByRelativePath(relativePath: string | null | undefined) {
+ if (!relativePath) {
+ return null;
+ }
+
+ const normalizedRelativePath = relativePath.replaceAll('\\', '/');
+ const absolutePath = path.resolve(
+ /* turbopackIgnore: true */ process.cwd(),
+ normalizedRelativePath,
+ );
+
+ if (!fs.existsSync(absolutePath)) {
+ return null;
+ }
+
+ return {
+ fileName: path.basename(normalizedRelativePath),
+ relativePath: normalizedRelativePath,
+ absolutePath,
+ schemaJson: JSON.parse(fs.readFileSync(absolutePath, 'utf8')) as unknown
+ };
+}
+
+export function computeFieldDiff(
+ currentFields: TemplateFieldRecord[],
+ previousFields: TemplateFieldRecord[]
+) {
+ const currentFieldNames = new Set(currentFields.map((field) => field.name).filter(Boolean));
+ const previousFieldNames = new Set(previousFields.map((field) => field.name).filter(Boolean));
+
+ return {
+ deletedFields: [...previousFieldNames].filter((name) => !currentFieldNames.has(name)).sort(),
+ newFields: [...currentFieldNames].filter((name) => !previousFieldNames.has(name)).sort()
+ };
+}
+
+export function buildSupportedMappingsForFieldNames(
+ fieldNames: Set,
+ tokenNames: Set = new Set()
+) {
+ return SUPPORTED_TEMPLATE_MAPPING_SEEDS.filter(
+ (mapping) => fieldNames.has(mapping.placeholderKey) || tokenNames.has(mapping.placeholderKey)
+ );
+}
+
+export function bumpMinorTemplateVersion(version: string) {
+ const [majorPart, minorPart = '0'] = version.split('.');
+ const major = Number(majorPart);
+ const minor = Number(minorPart);
+
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) {
+ return `${version}.1`;
+ }
+
+ return `${major}.${minor + 1}`;
+}
+
+function normalizeSnapshotPayload(snapshot: unknown) {
+ if (!snapshot) {
+ return null;
+ }
+
+ if (typeof snapshot === 'string') {
+ try {
+ const parsed = JSON.parse(snapshot) as unknown;
+ return parsed && typeof parsed === 'object' ? (parsed as Record) : null;
+ } catch {
+ return null;
+ }
+ }
+
+ return snapshot && typeof snapshot === 'object' ? (snapshot as Record) : null;
+}
+
+export async function getActiveTemplateVersions(sql: SqlClient): Promise {
+ const rows = await sql`
+select
+ t.id as "templateId",
+ t.template_name as "templateName",
+ t.organization_id as "organizationId",
+ t.document_type as "documentType",
+ t.product_type as "productType",
+ t.file_type as "fileType",
+ v.id as "versionId",
+ v.version,
+ v.file_path as "filePath",
+ v.schema_json as "schemaJson"
+ from crm_document_templates t
+ inner join crm_document_template_versions v
+ on v.template_id = t.id
+ where t.is_active = true
+ and v.is_active = true
+ and t.deleted_at is null
+ and v.deleted_at is null
+ order by t.organization_id asc, t.template_name asc, v.created_at asc
+ `;
+
+ return castRows(rows);
+}
+
+export async function getMappingsForVersion(sql: SqlClient, templateVersionId: string) {
+ const [mappings, columns] = await Promise.all([
+ sql`
+ select
+ id,
+ organization_id as "organizationId",
+ template_version_id as "templateVersionId",
+ placeholder_key as "placeholderKey",
+ source_path as "sourcePath",
+ data_type as "dataType",
+ default_value as "defaultValue",
+ format_mask as "formatMask",
+ sort_order as "sortOrder"
+ from crm_document_template_mappings
+ where template_version_id = ${templateVersionId}
+ and deleted_at is null
+ order by sort_order asc, created_at asc
+ `,
+ sql`
+ select
+ mapping_id as "mappingId",
+ column_name as "columnName",
+ source_field as "sourceField",
+ sort_order as "sortOrder",
+ format_mask as "formatMask"
+ from crm_document_template_table_columns
+ where deleted_at is null
+ order by sort_order asc, created_at asc
+ `
+ ]);
+
+ return {
+ mappings: castRows(mappings),
+ columns: castRows(columns)
+ };
+}
+
+export async function findAuditQuotation(sql: SqlClient) {
+ const [fixtureQuotation] = await sql`
+ select
+ q.id,
+ q.code,
+ q.organization_id as "organizationId",
+ q.customer_id as "customerId",
+ q.contact_id as "contactId",
+ q.quotation_date as "quotationDate",
+ q.valid_until as "validUntil",
+ q.project_name as "projectName",
+ q.project_location as "projectLocation",
+ q.attention,
+ q.reference,
+ q.currency,
+ q.subtotal,
+ q.total_amount as "totalAmount",
+ currency_option.code as "currencyCode",
+ q.approved_template_version_id as "approvedTemplateVersionId",
+ q.salesman_id as "salesmanId",
+ q.created_by as "createdBy",
+ q.created_at as "createdAt",
+ q.approved_snapshot as "approvedSnapshot",
+ q.approved_pdf_url as "approvedPdfUrl"
+ from crm_quotations q
+ left join ms_options currency_option
+ on currency_option.id = q.currency
+ where q.code = ${AUDIT_QUOTATION_CODE}
+ and q.deleted_at is null
+ limit 1
+ `;
+
+ if (fixtureQuotation) {
+ return fixtureQuotation;
+ }
+
+ const [fallbackQuotation] = await sql`
+ select
+ q.id,
+ q.code,
+ q.organization_id as "organizationId",
+ q.customer_id as "customerId",
+ q.contact_id as "contactId",
+ q.quotation_date as "quotationDate",
+ q.valid_until as "validUntil",
+ q.project_name as "projectName",
+ q.project_location as "projectLocation",
+ q.attention,
+ q.reference,
+ q.currency,
+ q.subtotal,
+ q.total_amount as "totalAmount",
+ currency_option.code as "currencyCode",
+ q.salesman_id as "salesmanId",
+ q.created_by as "createdBy",
+ q.created_at as "createdAt",
+ q.approved_snapshot as "approvedSnapshot",
+ q.approved_pdf_url as "approvedPdfUrl"
+ from crm_quotations q
+ left join ms_options currency_option
+ on currency_option.id = q.currency
+ where q.deleted_at is null
+ order by q.updated_at desc
+ limit 1
+ `;
+
+ if (!fallbackQuotation) {
+ throw new Error('No quotation found for PDF audit');
+ }
+
+ return fallbackQuotation;
+}
+
+function getValueByPath(source: unknown, valuePath: string): unknown {
+ return valuePath.split('.').reduce((current, segment) => {
+ if (current === null || current === undefined) {
+ return undefined;
+ }
+
+ if (Array.isArray(current) && /^\d+$/.test(segment)) {
+ return current[Number(segment)];
+ }
+
+ if (typeof current === 'object' && current !== null && segment in current) {
+ return (current as Record)[segment];
+ }
+
+ return undefined;
+ }, source);
+}
+
+function formatPdfDate(dateString: string | Date | null | undefined) {
+ if (!dateString) {
+ return '-';
+ }
+
+ const date = dateString instanceof Date ? dateString : new Date(dateString);
+
+ if (Number.isNaN(date.getTime())) {
+ return '-';
+ }
+
+ return date.toLocaleDateString('en-US', {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric'
+ });
+}
+
+function formatPdfCurrency(amount: number | string | null | undefined, currencyCode = 'THB') {
+ if (amount === null || amount === undefined) {
+ return '-';
+ }
+
+ const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
+
+ if (!Number.isFinite(normalized)) {
+ return '-';
+ }
+
+ const symbols: Record = {
+ THB: 'THB ',
+ USD: '$',
+ EUR: 'EUR '
+ };
+ const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `;
+ return `${prefix}${new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ }).format(normalized)}`.trim();
+}
+
+function formatPdfCurrencyAmount(amount: number | string | null | undefined, currencyCode = 'THB') {
+ if (amount === null || amount === undefined) {
+ return '-';
+ }
+
+ const normalized = typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
+
+ if (!Number.isFinite(normalized)) {
+ return '-';
+ }
+
+ const symbols: Record = {
+ THB: '฿',
+ USD: '$',
+ EUR: 'EUR '
+ };
+ const prefix = symbols[currencyCode.toUpperCase()] ?? `${currencyCode.toUpperCase()} `;
+ return `${prefix}${new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2
+ }).format(normalized)}`.trim();
+}
+
+function buildPdfPriceTableData(amount: number | string | null | undefined, currencyCode?: string | null) {
+ const normalizedCurrency = currencyCode?.trim() || 'THB';
+
+ return [[
+ PRICE_TABLE_LABEL,
+ formatPdfCurrencyAmount(amount, normalizedCurrency),
+ ' ',
+ normalizedCurrency
+ ]];
+}
+
+function formatTopicItems(items: Array<{ content?: string | null; sortOrder?: number | null }> = []) {
+ if (!items.length) {
+ return [['-']];
+ }
+
+ return [...items]
+ .sort((left, right) => (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER))
+ .map((item) => [item.content?.trim() || '-']);
+}
+
+function formatPdfDecimal(amount: number | string | null | undefined) {
+ if (amount === null || amount === undefined) {
+ return '-';
+ }
+
+ const normalized =
+ typeof amount === 'number' ? amount : Number(String(amount).replaceAll(',', ''));
+
+ if (!Number.isFinite(normalized)) {
+ return '-';
+ }
+
+ return new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 3
+ }).format(normalized);
+}
+
+function buildAuditItemsTable(
+ items: Array<{
+ itemNumber?: number | null;
+ description?: string | null;
+ quantity?: number | null;
+ unitLabel?: string | null;
+ unitPrice?: number | null;
+ discount?: number | null;
+ totalPrice?: number | null;
+ notes?: string | null;
+ }>,
+ currencyCode?: string | null
+) {
+ return items.map((item, index) => {
+ const description = [item.description?.trim(), item.notes?.trim()]
+ .filter((value): value is string => Boolean(value))
+ .join('\n')
+ .trim();
+
+ return [
+ String(item.itemNumber ?? index + 1),
+ description || '-',
+ formatPdfDecimal(item.quantity ?? null),
+ item.unitLabel?.trim() || '-',
+ formatPdfCurrency(item.unitPrice ?? null, currencyCode?.trim() || 'THB'),
+ formatPdfCurrency(item.discount ?? 0, currencyCode?.trim() || 'THB'),
+ formatPdfCurrency(item.totalPrice ?? null, currencyCode?.trim() || 'THB')
+ ];
+ });
+}
+
+function applyFormatMask(value: unknown, formatMask?: string | null) {
+ if (value === null || value === undefined) {
+ return value;
+ }
+
+ if (formatMask === 'currency' || formatMask?.startsWith('currency_')) {
+ const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', '');
+ return formatPdfCurrency(value as number | string, currencyCode);
+ }
+
+ if (formatMask === 'date' || formatMask === 'date_en') {
+ return formatPdfDate(value as string);
+ }
+
+ return value;
+}
+
+function isBlankString(value: unknown) {
+ return typeof value === 'string' && value.trim().length === 0;
+}
+
+function resolveScalarMappingValue(
+ rawValue: unknown,
+ formattedValue: unknown,
+ defaultValue?: string | null
+) {
+ if (formattedValue !== undefined && formattedValue !== null && !isBlankString(formattedValue)) {
+ return formattedValue;
+ }
+
+ if (rawValue !== undefined && rawValue !== null && !isBlankString(rawValue)) {
+ return rawValue;
+ }
+
+ if (defaultValue !== undefined && defaultValue !== null && defaultValue.trim().length > 0) {
+ return defaultValue;
+ }
+
+ return '-';
+}
+
+function normalizePdfmeTable(
+ value: unknown,
+ columns?: Array<{ sourceField: string; formatMask?: string | null }>
+) {
+ if (!Array.isArray(value) || value.length === 0) {
+ return [['-']];
+ }
+
+ if (value.every((row) => Array.isArray(row))) {
+ return (value as unknown[][]).map((row) =>
+ row.map((cell) => {
+ if (typeof cell === 'string') {
+ const trimmedValue = cell.trim();
+
+ if (trimmedValue) {
+ return trimmedValue;
+ }
+
+ return cell.length > 0 ? cell : '-';
+ }
+
+ return String(cell ?? '-').trim() || '-';
+ })
+ );
+ }
+
+ if (!columns?.length) {
+ return value.map((row) => [String(row ?? '-').trim() || '-']);
+ }
+
+ return value.map((row) => {
+ const rowRecord = row as Record;
+ return columns.map((column) => {
+ const formatted = applyFormatMask(rowRecord[column.sourceField], column.formatMask);
+ return String(formatted ?? '-').trim() || '-';
+ });
+ });
+}
+
+function mapDocumentDataToTemplateInput(
+ documentData: Record,
+ mappings: MappingRecord[],
+ columns: MappingColumnRecord[]
+) {
+ const result: Record = {};
+ const columnMap = new Map();
+
+ for (const column of columns) {
+ const items = columnMap.get(column.mappingId) ?? [];
+ items.push(column);
+ columnMap.set(column.mappingId, items);
+ }
+
+ for (const mapping of mappings) {
+ const rawValue = getValueByPath(documentData, mapping.sourcePath);
+
+ if (mapping.dataType === 'table') {
+ const mappingColumns = columnMap.get(mapping.id) ?? [];
+ let tableValue = rawValue;
+
+ if (!Array.isArray(tableValue) && mapping.defaultValue) {
+ try {
+ const parsedDefault = JSON.parse(mapping.defaultValue) as unknown;
+ tableValue = Array.isArray(parsedDefault) ? parsedDefault : tableValue;
+ } catch {
+ // ignore invalid legacy table defaults
+ }
+ }
+
+ result[mapping.placeholderKey] = normalizePdfmeTable(tableValue, mappingColumns);
+ continue;
+ }
+
+ if (mapping.dataType === 'multiline') {
+ if (Array.isArray(rawValue)) {
+ result[mapping.placeholderKey] = rawValue
+ .map((item) => String(item ?? '').trim())
+ .filter(Boolean)
+ .join('\n');
+ continue;
+ }
+
+ result[mapping.placeholderKey] =
+ typeof rawValue === 'string' && rawValue.trim().length > 0
+ ? rawValue
+ : mapping.defaultValue ?? '-';
+ continue;
+ }
+
+ const formattedValue = applyFormatMask(rawValue, mapping.formatMask);
+ result[mapping.placeholderKey] = resolveScalarMappingValue(
+ rawValue,
+ formattedValue,
+ mapping.defaultValue
+ );
+ }
+
+ return result;
+}
+
+function normalizeBusinessRoleLabel(businessRole: string | null | undefined) {
+ if (!businessRole) {
+ return '-';
+ }
+
+ return (
+ SIGNATURE_ROLE_FALLBACK_LABELS[businessRole] ??
+ businessRole
+ .split('_')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+ );
+}
+
+function buildTopicInputs(topics: Array<{ topicType: string; items: Array<{ content: string; sortOrder: number }> }>) {
+ const sortedTopics = [...topics].sort((left, right) => left.topicType.localeCompare(right.topicType));
+ const topicInputs: Record = {};
+
+ for (const [index, topic] of sortedTopics.entries()) {
+ topicInputs[`topic_1_${index}`] = [[topic.topicType || '-']];
+ topicInputs[`item_topic_1_${index}`] = formatTopicItems(topic.items);
+ }
+
+ return topicInputs;
+}
+
+export async function buildAuditPayload(sql: SqlClient): Promise {
+ const quotation = await findAuditQuotation(sql);
+ const [organization, customer, contact, itemRows, topicRows, topicItemRows, partyRows, jobTitles] =
+ await Promise.all([
+ sql`
+ select id, name
+ from organizations
+ where id = ${quotation.organizationId}
+ limit 1
+ `.then((rows) => rows[0] ?? null),
+ sql`
+ select id, name, address, phone, email
+ from crm_customers
+ where id = ${quotation.customerId}
+ limit 1
+ `.then((rows) => rows[0] ?? null),
+ quotation.contactId
+ ? sql`
+ select id, name, email, mobile, position
+ from crm_customer_contacts
+ where id = ${quotation.contactId}
+ limit 1
+ `.then((rows) => rows[0] ?? null)
+ : Promise.resolve(null),
+ sql`
+ select
+ qi.id,
+ qi.item_number as "itemNumber",
+ qi.description,
+ qi.quantity,
+ qi.unit,
+ qi.unit_price as "unitPrice",
+ qi.discount,
+ qi.total_price as "totalPrice",
+ qi.sort_order as "sortOrder",
+ u.label as "unitLabel"
+ from crm_quotation_items qi
+ left join ms_options u
+ on u.id = qi.unit
+ where qi.quotation_id = ${quotation.id}
+ and qi.deleted_at is null
+ order by qi.sort_order asc, qi.item_number asc
+ `,
+ sql`
+ select
+ qt.id,
+ qt.title,
+ qt.sort_order as "sortOrder",
+ qt.topic_type as "topicTypeId",
+ o.code as "topicCode",
+ coalesce(o.label, qt.title) as "topicLabel"
+ from crm_quotation_topics qt
+ left join ms_options o
+ on o.id = qt.topic_type
+ where qt.quotation_id = ${quotation.id}
+ and qt.deleted_at is null
+ order by qt.sort_order asc, qt.created_at asc
+ `,
+ sql`
+ select
+ qti.id,
+ qti.topic_id as "topicId",
+ qti.content,
+ qti.sort_order as "sortOrder"
+ from crm_quotation_topic_items qti
+ where qti.deleted_at is null
+ and qti.topic_id in (
+ select id
+ from crm_quotation_topics
+ where quotation_id = ${quotation.id}
+ and deleted_at is null
+ )
+ order by qti.sort_order asc, qti.created_at asc
+ `,
+ sql`
+ select
+ qc.id,
+ qc.customer_id as "customerId",
+ qc.role,
+ qc.is_primary as "isPrimary"
+ from crm_quotation_customers qc
+ where qc.quotation_id = ${quotation.id}
+ and qc.deleted_at is null
+ `,
+ sql`
+ select code, label
+ from ms_options
+ where organization_id = ${quotation.organizationId}
+ and category = 'crm_job_title'
+ and is_active = true
+ and deleted_at is null
+ `
+ ]);
+
+ if (!organization || !customer) {
+ throw new Error('Audit quotation references missing organization or customer');
+ }
+
+ const activeTemplateVersions = await getActiveTemplateVersions(sql);
+ const templateVersion = activeTemplateVersions.find(
+ (item) => item.organizationId === quotation.organizationId && item.documentType === 'quotation'
+ );
+
+ if (!templateVersion) {
+ throw new Error(`No active quotation template version found for organization ${quotation.organizationId}`);
+ }
+
+ const { mappings, columns } = await getMappingsForVersion(sql, templateVersion.versionId);
+ const topicItemsByTopicId = new Map>();
+
+ for (const topicItemRow of castRows<{ topicId: string; content: string; sortOrder: number }>(
+ topicItemRows
+ )) {
+ const items = topicItemsByTopicId.get(topicItemRow.topicId) ?? [];
+ items.push({
+ content: topicItemRow.content,
+ sortOrder: topicItemRow.sortOrder
+ });
+ topicItemsByTopicId.set(topicItemRow.topicId, items);
+ }
+
+ const topics = (topicRows as Array>).map((topicRow) => ({
+ id: String(topicRow.id),
+ title: String(topicRow.title ?? topicRow.topicLabel ?? 'Topic'),
+ topicCode: String(topicRow.topicCode ?? ''),
+ topicType: String(topicRow.topicLabel ?? topicRow.title ?? 'Topic'),
+ items: topicItemsByTopicId.get(String(topicRow.id)) ?? []
+ }));
+ const exclusionTopic = topics.find((topic) => topic.topicCode === 'exclusion') ?? topics[0] ?? null;
+ const topicInputs = buildTopicInputs(
+ topics.map((topic) => ({
+ topicType: topic.topicType,
+ items: topic.items
+ }))
+ );
+
+ const [approvalActions, userRows, membershipRows] = await Promise.all([
+ sql`
+ select
+ aa.acted_by as "actedBy",
+ aa.acted_at as "actedAt",
+ aa.action,
+ aa.step_number as "stepNumber",
+ aps.role_code as "roleCode",
+ u.name as "actorName"
+ from crm_approval_actions aa
+ inner join crm_approval_requests ar
+ on ar.id = aa.approval_request_id
+ left join crm_approval_steps aps
+ on aps.workflow_id = ar.workflow_id
+ and aps.step_number = aa.step_number
+ and aps.deleted_at is null
+ left join users u
+ on u.id = aa.acted_by
+ where ar.entity_type = 'quotation'
+ and ar.entity_id = ${quotation.id}
+ and ar.organization_id = ${quotation.organizationId}
+ and aa.deleted_at is null
+ order by aa.acted_at asc, aa.created_at asc
+ `,
+ sql`
+ select id, name
+ from users
+ where id in (${quotation.salesmanId ?? quotation.createdBy}, ${quotation.createdBy})
+ `,
+ sql`
+ select user_id as "userId", business_role as "businessRole"
+ from memberships
+ where organization_id = ${quotation.organizationId}
+ `
+ ]);
+ const userMap = new Map(
+ castRows<{ id: string; name: string }>(userRows).map((row) => [row.id, row.name])
+ );
+ const membershipMap = new Map(
+ castRows<{ userId: string; businessRole: string }>(membershipRows).map((row) => [
+ row.userId,
+ row.businessRole
+ ])
+ );
+ const jobTitleMap = new Map(
+ castRows<{ code: string; label: string }>(jobTitles).map((row) => [row.code, row.label])
+ );
+ const approvedActions = (approvalActions as Array>).filter(
+ (row) => row.action === 'approve'
+ );
+ const finalApprovedAction = approvedActions.at(-1) ?? null;
+ const approvedByAction =
+ approvedActions.filter((row) => row.roleCode !== finalApprovedAction?.roleCode).at(-1) ?? null;
+ const preparedByUserId = (quotation.salesmanId as string | null) ?? (quotation.createdBy as string);
+
+ const resolvePosition = (userId: string | null | undefined, roleCode?: string | null) => {
+ const businessRole = (userId ? membershipMap.get(userId) : null) ?? roleCode ?? null;
+ return businessRole ? jobTitleMap.get(businessRole) ?? normalizeBusinessRoleLabel(businessRole) : '-';
+ };
+ const currencyCode =
+ typeof quotation.currencyCode === 'string' && quotation.currencyCode.trim()
+ ? quotation.currencyCode.trim()
+ : 'THB';
+
+ const documentData: Record = {
+ company: {
+ id: organization.id,
+ name: organization.name
+ },
+ customer: {
+ id: customer.id,
+ name: customer.name,
+ address: customer.address,
+ phone: customer.phone,
+ email: customer.email
+ },
+ contact: contact
+ ? {
+ id: contact.id,
+ name: contact.name,
+ email: contact.email,
+ mobile: contact.mobile,
+ position: contact.position
+ }
+ : null,
+ quotation: {
+ id: quotation.id,
+ code: quotation.code,
+ quotationDate: quotation.quotationDate,
+ validUntil: quotation.validUntil,
+ projectName: quotation.projectName,
+ projectLocation: quotation.projectLocation,
+ attention: quotation.attention,
+ reference: quotation.reference,
+ currency: currencyCode,
+ currencyCode,
+ subtotal: quotation.subtotal,
+ totalAmount: quotation.totalAmount
+ },
+ items: (itemRows as Array>).map((row) => ({
+ id: row.id,
+ itemNumber: row.itemNumber,
+ description: row.description,
+ quantity: row.quantity,
+ unitLabel: row.unitLabel ?? row.unit ?? '-',
+ unitPrice: row.unitPrice,
+ discount: row.discount,
+ totalPrice: row.totalPrice
+ })),
+ quotationCustomers: partyRows,
+ topics: {
+ all: topics,
+ exclusion: exclusionTopic ? [exclusionTopic] : []
+ },
+ pdfme: {
+ labels: PDFME_STATIC_LABELS,
+ quotation_date: formatPdfDate(quotation.quotationDate as string),
+ quotation_price: formatPdfCurrency(quotation.subtotal as number, currencyCode),
+ quotation_price_data: buildPdfPriceTableData(quotation.subtotal as number, currencyCode),
+ exclusion_data: formatTopicItems(exclusionTopic?.items ?? []),
+ topic_inputs: topicInputs
+ },
+ signatures: {
+ preparedBy: {
+ name: userMap.get(preparedByUserId) ?? '-',
+ position: resolvePosition(preparedByUserId),
+ actedAt: new Date(String(quotation.createdAt)).toISOString()
+ },
+ approvedBy: {
+ name: String(approvedByAction?.actorName ?? '-'),
+ position: resolvePosition(
+ (approvedByAction?.actedBy as string | null | undefined) ?? null,
+ (approvedByAction?.roleCode as string | null | undefined) ?? null
+ ),
+ actedAt: approvedByAction?.actedAt ? new Date(String(approvedByAction.actedAt)).toISOString() : null
+ },
+ authorizedBy: {
+ name: String(finalApprovedAction?.actorName ?? '-'),
+ position: resolvePosition(
+ (finalApprovedAction?.actedBy as string | null | undefined) ?? null,
+ (finalApprovedAction?.roleCode as string | null | undefined) ?? null
+ ),
+ actedAt: finalApprovedAction?.actedAt
+ ? new Date(String(finalApprovedAction.actedAt)).toISOString()
+ : null
+ }
+ }
+ };
+
+ const templateInput = {
+ ...mapDocumentDataToTemplateInput(documentData, mappings, columns),
+ ...resolveQuotationStaticTemplateInputs(
+ documentData as unknown as Parameters<
+ typeof resolveQuotationStaticTemplateInputs
+ >[0],
+ ),
+ ...topicInputs
+ };
+const templateFields = extractTemplateFields(templateVersion.schemaJson);
+const hasItemsTableField = templateFields.some((field) => field.name === 'items_table');
+const normalizedTemplateInput = normalizeTemplateInputAliases(
+ templateFields,
+ {
+ ...templateInput,
+ ...(hasItemsTableField
+ ? {
+ items_table: buildAuditItemsTable(
+ (documentData as { items: Parameters[0] }).items,
+ (documentData as { quotation: { currencyCode?: string | null } }).quotation.currencyCode
+ )
+ }
+ : {}),
+ }
+);
+
+ return {
+ organizationId: quotation.organizationId,
+ quotationId: quotation.id,
+ quotationCode: quotation.code,
+ templateVersionId: templateVersion.versionId,
+ approvedTemplateVersionId:
+ typeof quotation.approvedTemplateVersionId === 'string'
+ ? quotation.approvedTemplateVersionId
+ : null,
+ templateName: templateVersion.templateName,
+ templateVersion: templateVersion.version,
+ templateSchema: templateVersion.schemaJson,
+ mappings,
+ mappingColumns: columns,
+ documentData,
+ templateInput: normalizedTemplateInput,
+ topicInputs,
+ quotationMetrics: {
+ topicCount: topics.length,
+ itemCount: (itemRows as Array).length,
+ partyCount: (partyRows as Array).length
+ },
+ approvedSnapshot: normalizeSnapshotPayload(quotation.approvedSnapshot),
+ approvedArtifactReference:
+ typeof quotation.approvedPdfUrl === 'string' ? quotation.approvedPdfUrl : null
+ };
+}
+
+export function summarizeIssues(statuses: Array<'PASS' | 'WARNING' | 'FAIL'>) {
+ if (statuses.includes('FAIL')) {
+ return 'FAIL';
+ }
+
+ if (statuses.includes('WARNING')) {
+ return 'WARNING';
+ }
+
+ return 'PASS';
+}
+
+function castRows(rows: unknown): T[] {
+ return rows as T[];
+}
+
+export interface ComputedSnapshotPayload {
+ quotationId: string;
+ approvedAt: string | null;
+ documentData: Record;
+ templateVersionId: string;
+ templateInput: Record;
+ generatedAt: string;
+ generatedBy: string;
+}
+
+export function buildComputedSnapshotPayload(
+ payload: AuditPayload,
+ options?: { generatedAt?: string; generatedBy?: string }
+): ComputedSnapshotPayload {
+ return {
+ quotationId: payload.quotationId,
+ approvedAt:
+ typeof (payload.documentData.approval as Record | undefined)?.approvedAt ===
+ 'string'
+ ? ((payload.documentData.approval as Record).approvedAt as string)
+ : null,
+ documentData: payload.documentData,
+ templateVersionId: payload.templateVersionId,
+ templateInput: payload.templateInput,
+ generatedAt: options?.generatedAt ?? 'audit-generated',
+ generatedBy: options?.generatedBy ?? 'audit-suite'
+ };
+}
+
+export function getSnapshotTemplateInput(snapshot: Record | null) {
+ if (!snapshot) {
+ return null;
+ }
+
+ return snapshot.templateInput && typeof snapshot.templateInput === 'object'
+ ? (snapshot.templateInput as Record)
+ : null;
+}
+
+export function getSnapshotDocumentData(snapshot: Record | null) {
+ if (!snapshot) {
+ return null;
+ }
+
+ return snapshot.documentData && typeof snapshot.documentData === 'object'
+ ? (snapshot.documentData as Record)
+ : null;
+}