This commit is contained in:
phaichayon
2026-07-15 05:46:59 +07:00
parent b51bf3e02a
commit df1821982d
10 changed files with 932 additions and 1346 deletions

View File

@@ -49,6 +49,7 @@
"audit:pdf:visual": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts", "audit:pdf:visual": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts",
"audit:pdf:visual:baseline": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts --refresh-baseline", "audit:pdf:visual:baseline": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts --refresh-baseline",
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
"pdf:seed:product": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1",
"pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate" "pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate"
}, },
"dependencies": { "dependencies": {

View File

@@ -311,6 +311,7 @@ async function main() {
matchingVersion, matchingVersion,
}); });
const targetVersionId = matchingVersion?.id ?? randomUUID(); const targetVersionId = matchingVersion?.id ?? randomUUID();
const isProductVariant = options.variant === 'product-v1';
await sql.begin(async (tx) => { await sql.begin(async (tx) => {
if (!matchingVersion) { if (!matchingVersion) {
@@ -322,6 +323,7 @@ async function main() {
version, version,
file_path, file_path,
schema_json, schema_json,
metadata_json,
preview_image_url, preview_image_url,
is_active, is_active,
deleted_at, deleted_at,
@@ -333,6 +335,10 @@ async function main() {
${targetVersionLabel}, ${targetVersionLabel},
${templateSource.relativePath}, ${templateSource.relativePath},
${JSON.stringify(targetSchema)}, ${JSON.stringify(targetSchema)},
case
when ${isProductVariant} then jsonb_build_object('templateVariant', 'product-v1')
else null
end,
${null}, ${null},
${options.activate}, ${options.activate},
${null}, ${null},
@@ -344,6 +350,11 @@ async function main() {
update crm_document_template_versions update crm_document_template_versions
set set
file_path = ${templateSource.relativePath}, file_path = ${templateSource.relativePath},
metadata_json = case
when ${isProductVariant}
then coalesce(metadata_json, '{}'::jsonb) || jsonb_build_object('templateVariant', 'product-v1')
else metadata_json
end,
is_active = ${options.activate ? true : matchingVersion.isActive}, is_active = ${options.activate ? true : matchingVersion.isActive},
updated_at = now() updated_at = now()
where id = ${targetVersionId} where id = ${targetVersionId}

View File

@@ -12,9 +12,15 @@ type Params = {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}; };
export async function GET(_request: NextRequest, { params }: Params) { export async function GET(request: NextRequest, { params }: Params) {
try { try {
const { id } = await params; const { id } = await params;
const templateVariant = request.nextUrl.searchParams.get('templateVariant');
if (templateVariant && templateVariant !== 'product-v1') {
return NextResponse.json({ message: 'Unsupported template variant' }, { status: 400 });
}
const { organization, session, membership } = await requireOrganizationAccess({ const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfPreview permission: PERMISSIONS.crmQuotationPdfPreview
}); });
@@ -36,7 +42,9 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
} }
const pdf = await generateQuotationPreviewPdf(id, organization.id); const pdf = await generateQuotationPreviewPdf(id, organization.id, {
templateVariant
});
return new NextResponse(pdf.buffer, { return new NextResponse(pdf.buffer, {
headers: { headers: {

View File

@@ -33,6 +33,8 @@ import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenc
import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema'; import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema';
import { isHotProjectSuggested } from '@/features/crm/shared/hot-project'; import { isHotProjectSuggested } from '@/features/crm/shared/hot-project';
const CHANCE_PERCENT_OPTIONS = [10, 25, 50, 75, 85] as const;
function toDefaultValues( function toDefaultValues(
opportunity: OpportunityRecord | undefined, opportunity: OpportunityRecord | undefined,
referenceData: OpportunityReferenceData referenceData: OpportunityReferenceData
@@ -93,8 +95,7 @@ export function OpportunityFormSheet({
FormSelectField, FormSelectField,
FormSwitchField, FormSwitchField,
FormDatePickerField, FormDatePickerField,
FormCurrencyField, FormCurrencyField
FormPercentageField
} = useFormFields<OpportunityFormValues>(); } = useFormFields<OpportunityFormValues>();
const projectPartiesQuery = useQuery({ const projectPartiesQuery = useQuery({
...opportunityProjectPartiesOptions(opportunity?.id ?? ''), ...opportunityProjectPartiesOptions(opportunity?.id ?? ''),
@@ -395,10 +396,38 @@ form.setFieldValue('isHotProject', suggested);
currencyLabel='THB' currencyLabel='THB'
placeholder='2500000' placeholder='2500000'
/> />
<FormPercentageField <form.AppField
name='chancePercent' name='chancePercent'
label='โอกาสปิดการขาย %' children={(field) => (
placeholder='60' <field.FieldSet>
<field.Field>
<field.FieldLabel> %</field.FieldLabel>
<Select
value={
typeof field.state.value === 'number'
? String(field.state.value)
: ''
}
onValueChange={(value) => {
field.handleChange(value ? Number(value) : undefined);
field.handleBlur();
}}
>
<SelectTrigger aria-label='Chance percentage'>
<SelectValue placeholder='เลือก Chance %' />
</SelectTrigger>
<SelectContent>
{CHANCE_PERCENT_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}%
</SelectItem>
))}
</SelectContent>
</Select>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
/> />
<FormDatePickerField <FormDatePickerField
name='expectedCloseDate' name='expectedCloseDate'

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +1,64 @@
import { and, eq, inArray, isNull } from "drizzle-orm"; import { and, eq, inArray, isNull } from 'drizzle-orm';
import { import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema';
crmCustomerContacts, import { db } from '@/lib/db';
crmCustomers, import { AuthError } from '@/lib/auth/session';
crmQuotations,
organizations,
} from "@/db/schema";
import { db } from "@/lib/db";
import { AuthError } from "@/lib/auth/session";
import { import {
listApprovalRequests, listApprovalRequests,
getApprovalRequest, getApprovalRequest
} from "@/features/foundation/approval/server/service"; } from '@/features/foundation/approval/server/service';
import { import {
mapDocumentDataToTemplateInput, mapDocumentDataToTemplateInput,
resolveTemplateForDocument, resolveTemplateForDocument,
resolveTemplateMappings, resolveTemplateMappings
} from "@/features/foundation/document-template/server/service"; } from '@/features/foundation/document-template/server/service';
import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service"; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { import {
getQuotationDetail, getQuotationDetail,
listQuotationCustomers, listQuotationCustomers,
listQuotationItems, listQuotationItems,
listQuotationTopics, listQuotationTopics
} from "@/features/crm/quotations/server/service"; } from '@/features/crm/quotations/server/service';
import type { import type {
ApprovedQuotationSnapshot, ApprovedQuotationSnapshot,
QuotationDocumentApprovalStep, QuotationDocumentApprovalStep,
QuotationDocumentData, QuotationDocumentData
} from "../types"; } from '../types';
import type { PdfTopic } from "./pdf-topic.type"; import type { PdfTopic } from './pdf-topic.type';
import { import {
buildPdfPriceTableData, buildPdfPriceTableData,
formatPdfCurrency, formatPdfCurrency,
formatPdfDate, formatPdfDate,
formatTopicItems, formatTopicItems
} from "./pdfme-transforms"; } from './pdfme-transforms';
import { buildQuotationPdfRuntime } from "./quotation-pdf-runtime"; import { buildQuotationPdfRuntime } from './quotation-pdf-runtime';
import { resolveQuotationSignatures } from "./signature-resolver"; import { resolveQuotationSignatures } from './signature-resolver';
import { resolveQuotationTopicTypeMapping } from "./topic-mapping"; import { resolveQuotationTopicTypeMapping } from './topic-mapping';
const DOCUMENT_OPTION_CATEGORIES = { const DOCUMENT_OPTION_CATEGORIES = {
branch: "crm_branch", branch: 'crm_branch',
status: "crm_quotation_status", status: 'crm_quotation_status',
quotationType: "crm_quotation_type", quotationType: 'crm_quotation_type',
currency: "crm_currency", currency: 'crm_currency',
discountType: "crm_discount_type", discountType: 'crm_discount_type',
unit: "crm_unit", unit: 'crm_unit',
customerRole: "crm_project_party_role", customerRole: 'crm_project_party_role',
productType: "crm_product_type", productType: 'crm_product_type',
topicType: "crm_quotation_topic_type", topicType: 'crm_quotation_topic_type'
} as const; } as const;
const PDFME_DEFAULT_LABELS = { const PDFME_DEFAULT_LABELS = {
tel: "Tel", tel: 'Tel',
email: "Email", email: 'Email',
att: "Att", att: 'Att',
project: "Project", project: 'Project',
location: "Location", location: 'Location'
} as const; } as const;
function toRevisionLabel(revision: number) { function toRevisionLabel(revision: number) {
return `R${String(revision).padStart(2, "0")}`; return `R${String(revision).padStart(2, '0')}`;
} }
async function assertQuotationDocumentAccess( async function assertQuotationDocumentAccess(quotationId: string, organizationId: string) {
quotationId: string,
organizationId: string,
) {
const [quotation] = await db const [quotation] = await db
.select() .select()
.from(crmQuotations) .from(crmQuotations)
@@ -74,13 +66,13 @@ async function assertQuotationDocumentAccess(
and( and(
eq(crmQuotations.id, quotationId), eq(crmQuotations.id, quotationId),
eq(crmQuotations.organizationId, organizationId), eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt), isNull(crmQuotations.deletedAt)
), )
) )
.limit(1); .limit(1);
if (!quotation) { if (!quotation) {
throw new AuthError("Quotation not found", 404); throw new AuthError('Quotation not found', 404);
} }
return quotation; return quotation;
@@ -88,16 +80,14 @@ async function assertQuotationDocumentAccess(
function findOptionLabel( function findOptionLabel(
options: Array<{ id: string; code: string; label: string }>, options: Array<{ id: string; code: string; label: string }>,
id: string | null | undefined, id: string | null | undefined
) { ) {
return id return id ? (options.find((option) => option.id === id)?.label ?? null) : null;
? (options.find((option) => option.id === id)?.label ?? null)
: null;
} }
function findOptionCode( function findOptionCode(
options: Array<{ id: string; code: string; label: string }>, options: Array<{ id: string; code: string; label: string }>,
id: string | null | undefined, id: string | null | undefined
) { ) {
return id ? (options.find((option) => option.id === id)?.code ?? null) : null; return id ? (options.find((option) => option.id === id)?.code ?? null) : null;
} }
@@ -111,10 +101,10 @@ function extractSinglePlaceholder(value: string) {
function getFieldName(field: unknown) { function getFieldName(field: unknown) {
return field && return field &&
typeof field === "object" && typeof field === 'object' &&
typeof (field as { name?: unknown }).name === "string" typeof (field as { name?: unknown }).name === 'string'
? ((field as { name: string }).name ?? "") ? ((field as { name: string }).name ?? '')
: ""; : '';
} }
function normalizeTopicKey(value: string | null | undefined) { function normalizeTopicKey(value: string | null | undefined) {
@@ -122,14 +112,11 @@ function normalizeTopicKey(value: string | null | undefined) {
value value
?.trim() ?.trim()
.toLowerCase() .toLowerCase()
.replaceAll(/[\s_-]+/g, "") ?? "" .replaceAll(/[\s_-]+/g, '') ?? ''
); );
} }
function matchesTopicAlias( function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
actualCode: string | null | undefined,
expectedCode: string,
) {
const actual = normalizeTopicKey(actualCode); const actual = normalizeTopicKey(actualCode);
const expected = normalizeTopicKey(expectedCode); const expected = normalizeTopicKey(expectedCode);
@@ -142,33 +129,30 @@ function matchesTopicAlias(
} }
const aliasGroups = [ const aliasGroups = [
["scope", "scopeofwork", "dockscopeofwork", "solarcellscopeofwork"], ['scope', 'scopeofwork', 'dockscopeofwork', 'solarcellscopeofwork'],
[ [
"exclusion", 'exclusion',
"exclusionfromscopeofsupply", 'exclusionfromscopeofsupply',
"exclusionofsupplyinstallation", 'exclusionofsupplyinstallation',
"solarcellexclusionofsupplyinstallation", 'solarcellexclusionofsupplyinstallation'
], ],
[ [
"payment", 'payment',
"paymentconditions", 'paymentconditions',
"termsofpaymentuponprogressofeachitem", 'termsofpaymentuponprogressofeachitem',
"solarcellpaymentconditions", 'solarcellpaymentconditions'
], ],
["delivery", "deliverydate", "solarcelldelivery"], ['delivery', 'deliverydate', 'solarcelldelivery'],
["warranty", "solarcellwarranty"], ['warranty', 'solarcellwarranty']
]; ];
return aliasGroups.some( return aliasGroups.some((group) => group.includes(actual) && group.includes(expected));
(group) => group.includes(actual) && group.includes(expected),
);
} }
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) { function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
return [...items].sort((left, right) => { return [...items].sort((left, right) => {
const delta = const delta =
(left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
(right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (delta !== 0) { if (delta !== 0) {
return delta; return delta;
@@ -178,12 +162,9 @@ function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
}); });
} }
function normalizePdfmeTemplateInput( function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<string, unknown>) {
schemaJson: unknown,
templateInput: Record<string, unknown>,
) {
const pages = const pages =
schemaJson && typeof schemaJson === "object" && "schemas" in schemaJson schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson
? (schemaJson as { schemas?: unknown }).schemas ? (schemaJson as { schemas?: unknown }).schemas
: null; : null;
@@ -199,37 +180,28 @@ function normalizePdfmeTemplateInput(
} }
for (const field of page) { for (const field of page) {
if (!field || typeof field !== "object") { if (!field || typeof field !== 'object') {
continue; continue;
} }
const tableField = field as { type?: unknown; content?: unknown }; const tableField = field as { type?: unknown; content?: unknown };
const fieldName = getFieldName(field); const fieldName = getFieldName(field);
const placeholderKey = const placeholderKey =
typeof tableField.content === "string" typeof tableField.content === 'string'
? extractSinglePlaceholder(tableField.content) ? extractSinglePlaceholder(tableField.content)
: null; : null;
if (fieldName && placeholderKey && fieldName !== placeholderKey) { if (fieldName && placeholderKey && fieldName !== placeholderKey) {
if ( if (normalized[fieldName] !== undefined && normalized[placeholderKey] === undefined) {
normalized[fieldName] !== undefined &&
normalized[placeholderKey] === undefined
) {
normalized[placeholderKey] = normalized[fieldName]; normalized[placeholderKey] = normalized[fieldName];
} }
if ( if (normalized[placeholderKey] !== undefined && normalized[fieldName] === undefined) {
normalized[placeholderKey] !== undefined &&
normalized[fieldName] === undefined
) {
normalized[fieldName] = normalized[placeholderKey]; normalized[fieldName] = normalized[placeholderKey];
} }
} }
if ( if (tableField.type !== 'table' || typeof tableField.content !== 'string') {
tableField.type !== "table" ||
typeof tableField.content !== "string"
) {
continue; continue;
} }
@@ -239,12 +211,12 @@ function normalizePdfmeTemplateInput(
const value = normalized[placeholderKey]; const value = normalized[placeholderKey];
if (typeof value !== "string") { if (typeof value !== 'string') {
continue; continue;
} }
const lines = value const lines = value
.split("\n") .split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .filter(Boolean);
@@ -264,20 +236,14 @@ function logQuotationPdfRuntimeWarnings(issues: Array<{ message: string }>) {
return; return;
} }
console.warn( console.warn('[quotation-pdf-runtime]', issues.map((issue) => issue.message).join(' | '));
"[quotation-pdf-runtime]",
issues.map((issue) => issue.message).join(" | "),
);
} }
export async function buildQuotationDocumentData( export async function buildQuotationDocumentData(
quotationId: string, quotationId: string,
organizationId: string, organizationId: string
): Promise<QuotationDocumentData> { ): Promise<QuotationDocumentData> {
const quotation = await assertQuotationDocumentAccess( const quotation = await assertQuotationDocumentAccess(quotationId, organizationId);
quotationId,
organizationId,
);
const [ const [
company, company,
quotationDetail, quotationDetail,
@@ -293,7 +259,7 @@ export async function buildQuotationDocumentData(
customerRoleOptions, customerRoleOptions,
productTypeOptions, productTypeOptions,
topicTypeOptions, topicTypeOptions,
approvalRequests, approvalRequests
] = await Promise.all([ ] = await Promise.all([
db db
.select() .select()
@@ -305,41 +271,41 @@ export async function buildQuotationDocumentData(
listQuotationCustomers(quotationId, organizationId), listQuotationCustomers(quotationId, organizationId),
listQuotationTopics(quotationId, organizationId), listQuotationTopics(quotationId, organizationId),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, {
organizationId, organizationId
}), }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, {
organizationId, organizationId
}), }),
listApprovalRequests(organizationId, { listApprovalRequests(organizationId, {
entityType: "quotation", entityType: 'quotation',
entityId: quotationId, entityId: quotationId,
limit: 20, limit: 20
}), })
]); ]);
if (!company) { if (!company) {
throw new AuthError("Organization not found", 404); throw new AuthError('Organization not found', 404);
} }
const [customer, contact, relatedCustomers, signatures] = await Promise.all([ const [customer, contact, relatedCustomers, signatures] = await Promise.all([
@@ -350,8 +316,8 @@ export async function buildQuotationDocumentData(
and( and(
eq(crmCustomers.id, quotation.customerId), eq(crmCustomers.id, quotation.customerId),
eq(crmCustomers.organizationId, organizationId), eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt), isNull(crmCustomers.deletedAt)
), )
) )
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
quotation.contactId quotation.contactId
@@ -362,8 +328,8 @@ export async function buildQuotationDocumentData(
and( and(
eq(crmCustomerContacts.id, quotation.contactId), eq(crmCustomerContacts.id, quotation.contactId),
eq(crmCustomerContacts.organizationId, organizationId), eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt), isNull(crmCustomerContacts.deletedAt)
), )
) )
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
: Promise.resolve(null), : Promise.resolve(null),
@@ -372,7 +338,7 @@ export async function buildQuotationDocumentData(
.select({ .select({
id: crmCustomers.id, id: crmCustomers.id,
code: crmCustomers.code, code: crmCustomers.code,
name: crmCustomers.name, name: crmCustomers.name
}) })
.from(crmCustomers) .from(crmCustomers)
.where( .where(
@@ -380,39 +346,33 @@ export async function buildQuotationDocumentData(
eq(crmCustomers.organizationId, organizationId), eq(crmCustomers.organizationId, organizationId),
inArray( inArray(
crmCustomers.id, crmCustomers.id,
quotationCustomers.map((item) => item.customerId), quotationCustomers.map((item) => item.customerId)
),
isNull(crmCustomers.deletedAt),
), ),
isNull(crmCustomers.deletedAt)
)
) )
: Promise.resolve([]), : Promise.resolve([]),
resolveQuotationSignatures(quotationId, organizationId), resolveQuotationSignatures(quotationId, organizationId)
]); ]);
if (!customer) { if (!customer) {
throw new AuthError("Customer not found", 404); throw new AuthError('Customer not found', 404);
} }
const approvalRequest = const approvalRequest =
approvalRequests.items.find((item) => item.status === "approved") ?? approvalRequests.items.find((item) => item.status === 'approved') ??
approvalRequests.items[0] ?? approvalRequests.items[0] ??
null; null;
const approvalDetail = approvalRequest const approvalDetail = approvalRequest
? await getApprovalRequest(approvalRequest.id, organizationId) ? await getApprovalRequest(approvalRequest.id, organizationId)
: null; : null;
const branch = const branch = branchOptions.find((option) => option.id === quotation.branchId) ?? null;
branchOptions.find((option) => option.id === quotation.branchId) ?? null; const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item]));
const relatedCustomerMap = new Map(
relatedCustomers.map((item) => [item.id, item]),
);
const statusCode = findOptionCode(statusOptions, quotation.status); const statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(statusOptions, quotation.status); const statusLabel = findOptionLabel(statusOptions, quotation.status);
const topicCodeById = new Map( const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
topicTypeOptions.map((option) => [option.id, option.code]),
);
const primaryProductType = const primaryProductType =
items.find((item) => findOptionCode(productTypeOptions, item.productType)) items.find((item) => findOptionCode(productTypeOptions, item.productType))?.productType ?? null;
?.productType ?? null;
const productTypeCode = const productTypeCode =
findOptionCode(productTypeOptions, primaryProductType) ?? findOptionCode(productTypeOptions, primaryProductType) ??
findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ?? findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ??
@@ -424,7 +384,7 @@ export async function buildQuotationDocumentData(
findOptionLabel(topicTypeOptions, item.topicType) ?? findOptionLabel(topicTypeOptions, item.topicType) ??
item.title?.trim() ?? item.title?.trim() ??
topicCodeById.get(item.topicType) ?? topicCodeById.get(item.topicType) ??
"Topic"; 'Topic';
return { return {
id: item.id, id: item.id,
@@ -435,8 +395,8 @@ export async function buildQuotationDocumentData(
items: sortBySortOrder(item.items).map((child) => ({ items: sortBySortOrder(item.items).map((child) => ({
id: child.id, id: child.id,
content: child.content, content: child.content,
sortOrder: child.sortOrder, sortOrder: child.sortOrder
})), }))
}; };
}); });
const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({ const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({
@@ -445,24 +405,24 @@ export async function buildQuotationDocumentData(
sortOrder: item.sortOrder, sortOrder: item.sortOrder,
items: item.items.map((child) => ({ items: item.items.map((child) => ({
id: child.id, id: child.id,
content: child.content?.trim() || "-", content: child.content?.trim() || '-',
sortOrder: child.sortOrder, sortOrder: child.sortOrder
})), }))
})); }));
const scopeTopics = resolvedTopics.filter((item) => const scopeTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.scope), matchesTopicAlias(item.topicCode, topicTypeMapping.scope)
); );
const exclusionTopics = resolvedTopics.filter((item) => const exclusionTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion), matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion)
); );
const paymentTopics = resolvedTopics.filter((item) => const paymentTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.payment), matchesTopicAlias(item.topicCode, topicTypeMapping.payment)
); );
const warrantyTopics = resolvedTopics.filter((item) => const warrantyTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.warranty), matchesTopicAlias(item.topicCode, topicTypeMapping.warranty)
); );
const deliveryTopics = resolvedTopics.filter((item) => const deliveryTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery), matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
); );
const otherTopics = resolvedTopics.filter((item) => { const otherTopics = resolvedTopics.filter((item) => {
return ![ return ![
@@ -470,7 +430,7 @@ export async function buildQuotationDocumentData(
topicTypeMapping.exclusion, topicTypeMapping.exclusion,
topicTypeMapping.payment, topicTypeMapping.payment,
topicTypeMapping.warranty, topicTypeMapping.warranty,
topicTypeMapping.delivery, topicTypeMapping.delivery
].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)); ].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode));
}); });
const exclusionTopic = exclusionTopics[0]; const exclusionTopic = exclusionTopics[0];
@@ -479,23 +439,22 @@ export async function buildQuotationDocumentData(
const deliveryTopic = deliveryTopics[0]; const deliveryTopic = deliveryTopics[0];
const approvalApprovers: QuotationDocumentApprovalStep[] = const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline approvalDetail?.timeline
.filter((item) => ["approve", "reject", "return"].includes(item.action)) .filter((item) => ['approve', 'reject', 'return'].includes(item.action))
.map((item) => { .map((item) => {
const step = approvalDetail.steps.find( const step = approvalDetail.steps.find(
(candidate) => candidate.stepNumber === item.stepNumber, (candidate) => candidate.stepNumber === item.stepNumber
); );
return { return {
stepNumber: item.stepNumber, stepNumber: item.stepNumber,
roleCode: step?.roleCode ?? "", roleCode: step?.roleCode ?? '',
roleName: step?.roleName ?? `Step ${item.stepNumber}`, roleName: step?.roleName ?? `Step ${item.stepNumber}`,
action: item.action, action: item.action,
actorName: item.actorName, actorName: item.actorName,
actedAt: item.actedAt, actedAt: item.actedAt,
remark: item.remark, remark: item.remark
}; };
}) ?? []; }) ?? [];
const currencyCode = const currencyCode = findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB';
findOptionCode(currencyOptions, quotationDetail.currency) ?? "THB";
return { return {
company: { company: {
@@ -503,14 +462,14 @@ export async function buildQuotationDocumentData(
name: company.name, name: company.name,
slug: company.slug, slug: company.slug,
imageUrl: company.imageUrl, imageUrl: company.imageUrl,
plan: company.plan, plan: company.plan
}, },
branch: branch branch: branch
? { ? {
id: branch.id, id: branch.id,
code: branch.code, code: branch.code,
label: branch.label, label: branch.label,
value: branch.value, value: branch.value
} }
: null, : null,
customer: { customer: {
@@ -520,7 +479,7 @@ export async function buildQuotationDocumentData(
address: customer.address, address: customer.address,
phone: customer.phone, phone: customer.phone,
email: customer.email, email: customer.email,
taxId: customer.taxId, taxId: customer.taxId
}, },
contact: contact contact: contact
? { ? {
@@ -528,7 +487,7 @@ export async function buildQuotationDocumentData(
name: contact.name, name: contact.name,
email: contact.email, email: contact.email,
mobile: contact.mobile, mobile: contact.mobile,
position: contact.position, position: contact.position
} }
: null, : null,
quotation: { quotation: {
@@ -544,32 +503,20 @@ export async function buildQuotationDocumentData(
revisionLabel: toRevisionLabel(quotationDetail.revision), revisionLabel: toRevisionLabel(quotationDetail.revision),
statusCode, statusCode,
statusLabel, statusLabel,
quotationTypeCode: findOptionCode( quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
quotationTypeOptions, quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
quotationDetail.quotationType,
),
quotationTypeLabel: findOptionLabel(
quotationTypeOptions,
quotationDetail.quotationType,
),
currency: currencyCode, currency: currencyCode,
currencyCode, currencyCode,
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency), currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
exchangeRate: quotationDetail.exchangeRate, exchangeRate: quotationDetail.exchangeRate,
subtotal: quotationDetail.subtotal, subtotal: quotationDetail.subtotal,
discount: quotationDetail.discount, discount: quotationDetail.discount,
discountTypeCode: findOptionCode( discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType),
discountTypeOptions, discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType),
quotationDetail.discountType,
),
discountTypeLabel: findOptionLabel(
discountTypeOptions,
quotationDetail.discountType,
),
taxRate: quotationDetail.taxRate, taxRate: quotationDetail.taxRate,
taxAmount: quotationDetail.taxAmount, taxAmount: quotationDetail.taxAmount,
totalAmount: quotationDetail.totalAmount, totalAmount: quotationDetail.totalAmount,
notes: quotationDetail.notes, notes: quotationDetail.notes
}, },
items: items.map((item) => ({ items: items.map((item) => ({
id: item.id, id: item.id,
@@ -585,130 +532,107 @@ export async function buildQuotationDocumentData(
discountTypeCode: findOptionCode(discountTypeOptions, item.discountType), discountTypeCode: findOptionCode(discountTypeOptions, item.discountType),
taxRate: item.taxRate, taxRate: item.taxRate,
totalPrice: item.totalPrice, totalPrice: item.totalPrice,
notes: item.notes, notes: item.notes
})), })),
quotationCustomers: quotationCustomers.map((item) => ({ quotationCustomers: quotationCustomers.map((item) => ({
id: item.id, id: item.id,
customerId: item.customerId, customerId: item.customerId,
customerName: customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
relatedCustomerMap.get(item.customerId)?.name ?? item.customerName, customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
customerCode:
relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
roleCode: item.roleCode, roleCode: item.roleCode,
roleLabel: roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role),
item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role), isPrimary: item.isPrimary
isPrimary: item.isPrimary,
})), })),
topics: { topics: {
scope: scopeTopics.map((item) => ({ scope: scopeTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? "-"), items: item.items.map((child) => child.content ?? '-')
})), })),
exclusion: exclusionTopics.map((item) => ({ exclusion: exclusionTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? "-"), items: item.items.map((child) => child.content ?? '-')
})), })),
payment: paymentTopics.map((item) => ({ payment: paymentTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? "-"), items: item.items.map((child) => child.content ?? '-')
})), })),
warranty: warrantyTopics.map((item) => ({ warranty: warrantyTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? "-"), items: item.items.map((child) => child.content ?? '-')
})), })),
delivery: deliveryTopics.map((item) => ({ delivery: deliveryTopics.map((item) => ({
title: item.title, title: item.title,
items: item.items.map((child) => child.content ?? "-"), items: item.items.map((child) => child.content ?? '-')
})), })),
other: otherTopics.map((item) => ({ other: otherTopics.map((item) => ({
id: item.id, id: item.id,
title: item.title, title: item.title,
sortOrder: item.sortOrder, sortOrder: item.sortOrder,
items: item.items.map((child) => child.content ?? "-"), items: item.items.map((child) => child.content ?? '-')
})), })),
all: pdfTopics, all: pdfTopics
}, },
pdfme: { pdfme: {
labels: PDFME_DEFAULT_LABELS, labels: PDFME_DEFAULT_LABELS,
quotation_date: formatPdfDate(quotationDetail.quotationDate, "en"), quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'),
quotation_price: formatPdfCurrency( quotation_price: formatPdfCurrency(quotationDetail.subtotal, currencyCode),
quotationDetail.subtotal, quotation_price_data: buildPdfPriceTableData(quotationDetail.subtotal, currencyCode),
currencyCode,
),
quotation_price_data: buildPdfPriceTableData(
quotationDetail.subtotal,
currencyCode,
),
exclusion_data: formatTopicItems(exclusionTopic), exclusion_data: formatTopicItems(exclusionTopic),
payment_data: formatTopicItems(paymentTopic), payment_data: formatTopicItems(paymentTopic),
warranty_data: formatTopicItems(warrantyTopic), warranty_data: formatTopicItems(warrantyTopic),
delivery_data: formatTopicItems(deliveryTopic), delivery_data: formatTopicItems(deliveryTopic),
topics_data: formatTopicItems( topics_data: formatTopicItems(scopeTopics[0] ?? exclusionTopic ?? paymentTopic),
scopeTopics[0] ?? exclusionTopic ?? paymentTopic, topic_inputs: {}
),
topic_inputs: {},
}, },
approval: { approval: {
requestId: approvalDetail?.request.id ?? null, requestId: approvalDetail?.request.id ?? null,
workflowName: approvalDetail?.workflow.name ?? null, workflowName: approvalDetail?.workflow.name ?? null,
status: approvalDetail?.request.status ?? null, status: approvalDetail?.request.status ?? null,
approvedAt: approvedAt:
approvalDetail?.request.status === "approved" approvalDetail?.request.status === 'approved' ? approvalDetail.request.completedAt : null,
? approvalDetail.request.completedAt
: null,
currentStep: approvalDetail?.request.currentStep ?? null, currentStep: approvalDetail?.request.currentStep ?? null,
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null, currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
approvers: approvalApprovers, approvers: approvalApprovers
}, },
signatures, signatures,
watermarkStatus: watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null
statusCode && statusCode !== "approved"
? (statusLabel ?? statusCode)
: null,
}; };
} }
export async function getQuotationDocumentPreviewData( export async function getQuotationDocumentPreviewData(
quotationId: string, quotationId: string,
organizationId: string, organizationId: string,
options?: {
templateVariant?: string | null;
}
) { ) {
const documentData = await buildQuotationDocumentData( const documentData = await buildQuotationDocumentData(quotationId, organizationId);
quotationId,
organizationId,
);
const template = await resolveTemplateForDocument(organizationId, { const template = await resolveTemplateForDocument(organizationId, {
documentType: "quotation", documentType: 'quotation',
productType: "default", productType: 'default',
fileType: "pdfme", fileType: 'pdfme',
templateVariant: options?.templateVariant ?? null
}); });
const mappings = await resolveTemplateMappings( const mappings = await resolveTemplateMappings(template.version.id, organizationId);
template.version.id,
organizationId,
);
if (!mappings.length) { if (!mappings.length) {
throw new AuthError( throw new AuthError(
"Document template mappings are not configured for this template version", 'Document template mappings are not configured for this template version',
409, 409
); );
} }
const templateInput = normalizePdfmeTemplateInput( const templateInput = normalizePdfmeTemplateInput(
template.version.schemaJson, template.version.schemaJson,
mapDocumentDataToTemplateInput( mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings)
documentData as unknown as Record<string, unknown>,
mappings,
),
); );
const runtime = await buildQuotationPdfRuntime({ const runtime = await buildQuotationPdfRuntime({
documentData, documentData,
template, template,
mappings, mappings,
templateInput, templateInput
}); });
const runtimeWarnings = runtime.issues.filter( const runtimeWarnings = runtime.issues.filter((issue) => issue.severity === 'warning');
(issue) => issue.severity === "warning",
);
logQuotationPdfRuntimeWarnings(runtimeWarnings); logQuotationPdfRuntimeWarnings(runtimeWarnings);
@@ -720,23 +644,20 @@ export async function getQuotationDocumentPreviewData(
...template, ...template,
version: { version: {
...template.version, ...template.version,
schemaJson: runtime.assembled.template, schemaJson: runtime.assembled.template
}, }
}, },
mappings, mappings,
templateInput: runtime.assembled.templateInput, templateInput: runtime.assembled.templateInput
}; };
} }
export async function prepareApprovedQuotationSnapshot( export async function prepareApprovedQuotationSnapshot(
quotationId: string, quotationId: string,
organizationId: string, organizationId: string,
userId: string, userId: string
): Promise<ApprovedQuotationSnapshot> { ): Promise<ApprovedQuotationSnapshot> {
const preview = await getQuotationDocumentPreviewData( const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
quotationId,
organizationId,
);
const generatedAt = new Date().toISOString(); const generatedAt = new Date().toISOString();
return { return {
@@ -746,6 +667,6 @@ export async function prepareApprovedQuotationSnapshot(
templateVersionId: preview.template.version.id, templateVersionId: preview.template.version.id,
templateInput: preview.templateInput, templateInput: preview.templateInput,
generatedAt, generatedAt,
generatedBy: userId, generatedBy: userId
}; };
} }

View File

@@ -16,7 +16,7 @@ import {
CURRENT_TEMPLATE_RUNTIME_VERSION, CURRENT_TEMPLATE_RUNTIME_VERSION,
getManagementMetadata, getManagementMetadata,
mergeManagementMetadata, mergeManagementMetadata,
sanitizeTemplateSchemaJson, sanitizeTemplateSchemaJson
} from './metadata'; } from './metadata';
import type { import type {
DocumentTemplateDetail, DocumentTemplateDetail,
@@ -71,7 +71,7 @@ function mapVersionRecord(
): DocumentTemplateVersionRecord { ): DocumentTemplateVersionRecord {
const metadata = getManagementMetadata({ const metadata = getManagementMetadata({
metadataJson: row.metadataJson, metadataJson: row.metadataJson,
schemaJson: row.schemaJson, schemaJson: row.schemaJson
}); });
return { return {
@@ -218,7 +218,10 @@ export async function resolveTemplateMappings(
isNull(crmDocumentTemplateMappings.deletedAt) isNull(crmDocumentTemplateMappings.deletedAt)
) )
) )
.orderBy(asc(crmDocumentTemplateMappings.sortOrder), asc(crmDocumentTemplateMappings.createdAt)); .orderBy(
asc(crmDocumentTemplateMappings.sortOrder),
asc(crmDocumentTemplateMappings.createdAt)
);
const columns = await db const columns = await db
.select() .select()
.from(crmDocumentTemplateTableColumns) .from(crmDocumentTemplateTableColumns)
@@ -257,7 +260,9 @@ export async function listDocumentTemplates(
and( and(
eq(crmDocumentTemplates.organizationId, organizationId), eq(crmDocumentTemplates.organizationId, organizationId),
isNull(crmDocumentTemplates.deletedAt), isNull(crmDocumentTemplates.deletedAt),
...(filters.documentType ? [eq(crmDocumentTemplates.documentType, filters.documentType)] : []), ...(filters.documentType
? [eq(crmDocumentTemplates.documentType, filters.documentType)]
: []),
...(filters.fileType ? [eq(crmDocumentTemplates.fileType, filters.fileType)] : []), ...(filters.fileType ? [eq(crmDocumentTemplates.fileType, filters.fileType)] : []),
...(filters.isActive === 'true' ? [eq(crmDocumentTemplates.isActive, true)] : []), ...(filters.isActive === 'true' ? [eq(crmDocumentTemplates.isActive, true)] : []),
...(filters.isActive === 'false' ? [eq(crmDocumentTemplates.isActive, false)] : []) ...(filters.isActive === 'false' ? [eq(crmDocumentTemplates.isActive, false)] : [])
@@ -288,7 +293,9 @@ export async function listDocumentTemplates(
const templateVersions = versions.filter((item) => item.templateId === template.id); const templateVersions = versions.filter((item) => item.templateId === template.id);
const latestVersion = templateVersions.at(-1) ?? null; const latestVersion = templateVersions.at(-1) ?? null;
const templateVersionIds = new Set(templateVersions.map((item) => item.id)); const templateVersionIds = new Set(templateVersions.map((item) => item.id));
const mappingCount = mappings.filter((item) => templateVersionIds.has(item.templateVersionId)).length; const mappingCount = mappings.filter((item) =>
templateVersionIds.has(item.templateVersionId)
).length;
return { return {
...mapTemplateRecord(template), ...mapTemplateRecord(template),
@@ -379,7 +386,12 @@ export async function createDocumentTemplate(
.returning(); .returning();
if (created.isDefault) { if (created.isDefault) {
await normalizeDefaultTemplate(created.id, organizationId, created.documentType, created.fileType); await normalizeDefaultTemplate(
created.id,
organizationId,
created.documentType,
created.fileType
);
} }
return created; return created;
@@ -399,7 +411,10 @@ export async function updateDocumentTemplate(
productType: payload.productType ?? template.productType, productType: payload.productType ?? template.productType,
fileType: payload.fileType ?? template.fileType, fileType: payload.fileType ?? template.fileType,
templateName: payload.templateName?.trim() ?? template.templateName, templateName: payload.templateName?.trim() ?? template.templateName,
description: payload.description === undefined ? template.description : payload.description?.trim() || null, description:
payload.description === undefined
? template.description
: payload.description?.trim() || null,
isDefault: payload.isDefault ?? template.isDefault, isDefault: payload.isDefault ?? template.isDefault,
isActive: payload.isActive ?? template.isActive, isActive: payload.isActive ?? template.isActive,
updatedAt: new Date(), updatedAt: new Date(),
@@ -409,7 +424,12 @@ export async function updateDocumentTemplate(
.returning(); .returning();
if (updated.isDefault) { if (updated.isDefault) {
await normalizeDefaultTemplate(updated.id, organizationId, updated.documentType, updated.fileType); await normalizeDefaultTemplate(
updated.id,
organizationId,
updated.documentType,
updated.fileType
);
} }
return updated; return updated;
@@ -445,7 +465,11 @@ async function normalizeDefaultTemplate(
.where(eq(crmDocumentTemplates.id, templateId)); .where(eq(crmDocumentTemplates.id, templateId));
} }
export async function softDeleteDocumentTemplate(id: string, organizationId: string, userId: string) { export async function softDeleteDocumentTemplate(
id: string,
organizationId: string,
userId: string
) {
await assertTemplate(id, organizationId); await assertTemplate(id, organizationId);
const [updated] = await db const [updated] = await db
.update(crmDocumentTemplates) .update(crmDocumentTemplates)
@@ -473,8 +497,8 @@ export async function createDocumentTemplateVersion(
lifecycleStatus: payload.isActive ? 'active' : 'draft', lifecycleStatus: payload.isActive ? 'active' : 'draft',
runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION,
templateVariant: payload.templateVariant ?? null, templateVariant: payload.templateVariant ?? null,
brand: payload.brand ?? null, brand: payload.brand ?? null
}, }
}); });
const [created] = await db const [created] = await db
.insert(crmDocumentTemplateVersions) .insert(crmDocumentTemplateVersions)
@@ -503,13 +527,14 @@ export async function updateDocumentTemplateVersion(
const current = await assertTemplateVersion(versionId, organizationId); const current = await assertTemplateVersion(versionId, organizationId);
const currentMetadata = getManagementMetadata({ const currentMetadata = getManagementMetadata({
metadataJson: current.metadataJson, metadataJson: current.metadataJson,
schemaJson: current.schemaJson, schemaJson: current.schemaJson
}); });
const [updated] = await db const [updated] = await db
.update(crmDocumentTemplateVersions) .update(crmDocumentTemplateVersions)
.set({ .set({
version: payload.version?.trim() ?? current.version, version: payload.version?.trim() ?? current.version,
filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null, filePath:
payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null,
schemaJson: schemaJson:
payload.schemaJson === undefined payload.schemaJson === undefined
? sanitizeTemplateSchemaJson(current.schemaJson) ? sanitizeTemplateSchemaJson(current.schemaJson)
@@ -520,8 +545,8 @@ export async function updateDocumentTemplateVersion(
patch: { patch: {
runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null, runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null,
templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null, templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null,
brand: payload.brand ?? currentMetadata.brand ?? null, brand: payload.brand ?? currentMetadata.brand ?? null
}, }
}), }),
previewImageUrl: previewImageUrl:
payload.previewImageUrl === undefined payload.previewImageUrl === undefined
@@ -555,7 +580,7 @@ export function createDuplicateVersionLabel(existingVersions: string[], baseVers
export async function duplicateDocumentTemplateVersion( export async function duplicateDocumentTemplateVersion(
versionId: string, versionId: string,
organizationId: string, organizationId: string,
userId: string, userId: string
) { ) {
const sourceVersion = await assertTemplateVersion(versionId, organizationId); const sourceVersion = await assertTemplateVersion(versionId, organizationId);
const siblingVersions = await db const siblingVersions = await db
@@ -565,18 +590,18 @@ export async function duplicateDocumentTemplateVersion(
and( and(
eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId), eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId),
eq(crmDocumentTemplateVersions.organizationId, organizationId), eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt), isNull(crmDocumentTemplateVersions.deletedAt)
), )
) )
.orderBy(asc(crmDocumentTemplateVersions.createdAt)); .orderBy(asc(crmDocumentTemplateVersions.createdAt));
const metadata = getManagementMetadata({ const metadata = getManagementMetadata({
metadataJson: sourceVersion.metadataJson, metadataJson: sourceVersion.metadataJson,
schemaJson: sourceVersion.schemaJson, schemaJson: sourceVersion.schemaJson
}); });
const duplicateId = crypto.randomUUID(); const duplicateId = crypto.randomUUID();
const duplicateVersionLabel = createDuplicateVersionLabel( const duplicateVersionLabel = createDuplicateVersionLabel(
siblingVersions.map((item) => item.version), siblingVersions.map((item) => item.version),
sourceVersion.version, sourceVersion.version
); );
const [createdVersion] = await db const [createdVersion] = await db
@@ -601,11 +626,11 @@ export async function duplicateDocumentTemplateVersion(
nextVersionId: null, nextVersionId: null,
validationSummary: null, validationSummary: null,
auditSummary: null, auditSummary: null,
visualSummary: null, visualSummary: null
}, },
previewImageUrl: sourceVersion.previewImageUrl, previewImageUrl: sourceVersion.previewImageUrl,
isActive: false, isActive: false,
createdBy: userId, createdBy: userId
}) })
.returning(); .returning();
@@ -622,7 +647,7 @@ export async function duplicateDocumentTemplateVersion(
sheetName: mapping.sheetName, sheetName: mapping.sheetName,
defaultValue: mapping.defaultValue, defaultValue: mapping.defaultValue,
formatMask: mapping.formatMask, formatMask: mapping.formatMask,
sortOrder: mapping.sortOrder, sortOrder: mapping.sortOrder
}); });
if (mapping.columns.length) { if (mapping.columns.length) {
@@ -635,8 +660,8 @@ export async function duplicateDocumentTemplateVersion(
sourceField: column.sourceField, sourceField: column.sourceField,
columnLetter: column.columnLetter, columnLetter: column.columnLetter,
sortOrder: column.sortOrder, sortOrder: column.sortOrder,
formatMask: column.formatMask, formatMask: column.formatMask
})), }))
); );
} }
} }
@@ -721,9 +746,12 @@ export async function updateDocumentTemplateMapping(
placeholderKey: payload.placeholderKey?.trim() ?? current.placeholderKey, placeholderKey: payload.placeholderKey?.trim() ?? current.placeholderKey,
sourcePath: payload.sourcePath?.trim() ?? current.sourcePath, sourcePath: payload.sourcePath?.trim() ?? current.sourcePath,
dataType: payload.dataType ?? current.dataType, dataType: payload.dataType ?? current.dataType,
sheetName: payload.sheetName === undefined ? current.sheetName : payload.sheetName?.trim() || null, sheetName:
payload.sheetName === undefined ? current.sheetName : payload.sheetName?.trim() || null,
defaultValue: defaultValue:
payload.defaultValue === undefined ? current.defaultValue : payload.defaultValue?.trim() || null, payload.defaultValue === undefined
? current.defaultValue
: payload.defaultValue?.trim() || null,
formatMask: formatMask:
payload.formatMask === undefined ? current.formatMask : payload.formatMask?.trim() || null, payload.formatMask === undefined ? current.formatMask : payload.formatMask?.trim() || null,
sortOrder: payload.sortOrder ?? current.sortOrder, sortOrder: payload.sortOrder ?? current.sortOrder,
@@ -753,8 +781,8 @@ export async function updateDocumentTemplateMapping(
} }
} }
const [mapping] = await resolveTemplateMappings(updated.templateVersionId, organizationId).then((items) => const [mapping] = await resolveTemplateMappings(updated.templateVersionId, organizationId).then(
items.filter((item) => item.id === mappingId) (items) => items.filter((item) => item.id === mappingId)
); );
return mapping ?? { ...mapMappingRecord(updated), columns: [] }; return mapping ?? { ...mapMappingRecord(updated), columns: [] };
@@ -820,15 +848,30 @@ export async function resolveTemplateForDocument(
and( and(
eq(crmDocumentTemplateVersions.organizationId, organizationId), eq(crmDocumentTemplateVersions.organizationId, organizationId),
eq(crmDocumentTemplateVersions.templateId, exactTemplate.id), eq(crmDocumentTemplateVersions.templateId, exactTemplate.id),
eq(crmDocumentTemplateVersions.isActive, true),
isNull(crmDocumentTemplateVersions.deletedAt) isNull(crmDocumentTemplateVersions.deletedAt)
) )
) )
.orderBy(asc(crmDocumentTemplateVersions.createdAt)); .orderBy(asc(crmDocumentTemplateVersions.createdAt));
const version = versions.at(-1); const templateVariant = params.templateVariant?.trim() || null;
const eligibleVersions = templateVariant
? versions.filter((item) => {
const metadata = getManagementMetadata({
metadataJson: item.metadataJson,
schemaJson: item.schemaJson
});
return metadata.templateVariant === templateVariant;
})
: versions.filter((item) => item.isActive);
const version = eligibleVersions.at(-1);
if (!version) { if (!version) {
throw new AuthError('No active template version found', 404); throw new AuthError(
templateVariant === 'product-v1'
? 'product-v1 template version is not seeded'
: 'No active template version found',
404
);
} }
return { return {
@@ -861,8 +904,7 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
} }
if (formatMask === 'currency' || formatMask?.startsWith('currency_')) { if (formatMask === 'currency' || formatMask?.startsWith('currency_')) {
const currencyCode = const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', '');
formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', '');
return formatPdfCurrency(value as number | string, currencyCode); return formatPdfCurrency(value as number | string, currencyCode);
} }
@@ -943,7 +985,10 @@ export function mapDocumentDataToTemplateInput(
const rowRecord = row as Record<string, unknown>; const rowRecord = row as Record<string, unknown>;
return mapping.columns.map((column) => { return mapping.columns.map((column) => {
const formattedValue = applyFormatMask(rowRecord[column.sourceField], column.formatMask); const formattedValue = applyFormatMask(
rowRecord[column.sourceField],
column.formatMask
);
return String(formattedValue ?? '-').trim() || '-'; return String(formattedValue ?? '-').trim() || '-';
}); });
}); });

View File

@@ -143,13 +143,11 @@ export interface DocumentTemplateMappingMutationPayload {
columns?: DocumentTemplateMappingColumnMutationPayload[]; columns?: DocumentTemplateMappingColumnMutationPayload[];
} }
export interface DocumentTemplateMappingWithColumns export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
extends DocumentTemplateMappingRecord {
columns: DocumentTemplateTableColumnRecord[]; columns: DocumentTemplateTableColumnRecord[];
} }
export interface DocumentTemplateVersionDetail export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord {
extends DocumentTemplateVersionRecord {
mappings: DocumentTemplateMappingWithColumns[]; mappings: DocumentTemplateMappingWithColumns[];
validationSummary?: DocumentTemplateVersionValidationSummary | null; validationSummary?: DocumentTemplateVersionValidationSummary | null;
auditSummary?: DocumentTemplateVersionAuditSummary | null; auditSummary?: DocumentTemplateVersionAuditSummary | null;
@@ -200,6 +198,7 @@ export interface ResolveTemplateParams {
documentType: string; documentType: string;
productType: string; productType: string;
fileType: string; fileType: string;
templateVariant?: string | null;
} }
export interface ResolvedDocumentTemplate { export interface ResolvedDocumentTemplate {
@@ -227,18 +226,15 @@ export interface DocumentTemplateVersionsResponse extends MutationSuccessRespons
items: DocumentTemplateVersionDetail[]; items: DocumentTemplateVersionDetail[];
} }
export interface DocumentTemplateVersionActionResponse export interface DocumentTemplateVersionActionResponse extends MutationSuccessResponse {
extends MutationSuccessResponse {
version: DocumentTemplateVersionDetail; version: DocumentTemplateVersionDetail;
} }
export interface DocumentTemplateVersionValidateResponse export interface DocumentTemplateVersionValidateResponse extends MutationSuccessResponse {
extends MutationSuccessResponse {
validation: DocumentTemplateVersionValidationSummary; validation: DocumentTemplateVersionValidationSummary;
} }
export interface DocumentTemplateVersionCompareResponse export interface DocumentTemplateVersionCompareResponse extends MutationSuccessResponse {
extends MutationSuccessResponse {
comparison: { comparison: {
leftVersionId: string; leftVersionId: string;
rightVersionId: string; rightVersionId: string;
@@ -277,8 +273,7 @@ export interface DocumentTemplateVersionCompareResponse
}; };
} }
export interface DocumentTemplateVersionPreviewResponse export interface DocumentTemplateVersionPreviewResponse extends MutationSuccessResponse {
extends MutationSuccessResponse {
preview: { preview: {
fileName: string; fileName: string;
contentType: 'application/pdf'; contentType: 'application/pdf';

View File

@@ -299,9 +299,14 @@ async function loadLegacyApprovedQuotationPdf(
export async function generateQuotationPdf( export async function generateQuotationPdf(
quotationId: string, quotationId: string,
organizationId: string organizationId: string,
options?: {
templateVariant?: string | null;
}
): Promise<GeneratedQuotationPdf> { ): Promise<GeneratedQuotationPdf> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId); const preview = await getQuotationDocumentPreviewData(quotationId, organizationId, {
templateVariant: options?.templateVariant ?? null
});
const buffer = await generatePdfFromTemplate({ const buffer = await generatePdfFromTemplate({
template: preview.template.version.schemaJson as Template, template: preview.template.version.schemaJson as Template,
inputs: [preview.templateInput] inputs: [preview.templateInput]
@@ -314,8 +319,14 @@ export async function generateQuotationPdf(
}; };
} }
export async function generateQuotationPreviewPdf(quotationId: string, organizationId: string) { export async function generateQuotationPreviewPdf(
return generateQuotationPdf(quotationId, organizationId); quotationId: string,
organizationId: string,
options?: {
templateVariant?: string | null;
}
) {
return generateQuotationPdf(quotationId, organizationId, options);
} }
export async function generateApprovedQuotationPdf( export async function generateApprovedQuotationPdf(

View File

@@ -380,17 +380,9 @@
"content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]", "content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]",
"showHead": false, "showHead": false,
"repeatHead": false, "repeatHead": false,
"head": [ "head": ["Name", "City", "Head 3", "Head 4"],
"Name",
"City",
"Head 3",
"Head 4"
],
"headWidthPercentages": [ "headWidthPercentages": [
28.125, 28.125, 58.34767550533545, 2.4513428524798244, 11.075981642184729
58.34767550533545,
2.4513428524798244,
11.075981642184729
], ],
"tableStyles": { "tableStyles": {
"borderWidth": 0, "borderWidth": 0,
@@ -1023,7 +1015,7 @@
{ {
"name": "product_items_title", "name": "product_items_title",
"type": "text", "type": "text",
"content": "Product Item Details", "content": "",
"position": { "position": {
"x": 12, "x": 12,
"y": 22 "y": 22
@@ -1050,7 +1042,7 @@
{ {
"name": "product_items_subtitle", "name": "product_items_subtitle",
"type": "text", "type": "text",
"content": "Detailed quotation item list", "content": "",
"position": { "position": {
"x": 12, "x": 12,
"y": 29 "y": 29
@@ -1111,15 +1103,7 @@
"Discount", "Discount",
"Total" "Total"
], ],
"headWidthPercentages": [ "headWidthPercentages": [8, 34, 8, 10, 14, 12, 14],
8,
34,
8,
10,
14,
12,
14
],
"tableStyles": { "tableStyles": {
"borderWidth": 0.2, "borderWidth": 0.2,
"borderColor": "#000000" "borderColor": "#000000"
@@ -1376,12 +1360,8 @@
"content": "[[\"Topic:\"]]", "content": "[[\"Topic:\"]]",
"showHead": false, "showHead": false,
"repeatHead": false, "repeatHead": false,
"head": [ "head": ["Terms of payment upon progress of each item:"],
"Terms of payment upon progress of each item:" "headWidthPercentages": [100],
],
"headWidthPercentages": [
100
],
"tableStyles": { "tableStyles": {
"borderWidth": 0, "borderWidth": 0,
"borderColor": "#000000" "borderColor": "#000000"
@@ -1449,12 +1429,8 @@
"content": "[[\"{item_topic}\"]]", "content": "[[\"{item_topic}\"]]",
"showHead": false, "showHead": false,
"repeatHead": false, "repeatHead": false,
"head": [ "head": ["Head 2"],
"Head 2" "headWidthPercentages": [100],
],
"headWidthPercentages": [
100
],
"tableStyles": { "tableStyles": {
"borderWidth": 0, "borderWidth": 0,
"borderColor": "#000000" "borderColor": "#000000"
@@ -1731,12 +1707,7 @@
"basePdf": { "basePdf": {
"width": 210, "width": 210,
"height": 297, "height": 297,
"padding": [ "padding": [31, 10, 25, 10],
31,
10,
25,
10
],
"staticSchema": [ "staticSchema": [
{ {
"name": "field1", "name": "field1",