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: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",
"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"
},
"dependencies": {

View File

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

View File

@@ -12,9 +12,15 @@ type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
export async function GET(request: NextRequest, { params }: Params) {
try {
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({
permission: PERMISSIONS.crmQuotationPdfPreview
});
@@ -36,7 +42,9 @@ export async function GET(_request: NextRequest, { params }: Params) {
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, {
headers: {

View File

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

View File

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

View File

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

View File

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

View File

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