task-h.5.3

This commit is contained in:
phaichayon
2026-06-19 17:15:28 +07:00
parent fd01ebf7c7
commit 51d67ef7c2
35 changed files with 4301 additions and 633 deletions

View File

@@ -1,4 +1,5 @@
const EMPTY_TABLE_FALLBACK = [['-']];
const PRICE_TABLE_LABEL = 'Price (Exclude VAT)';
function normalizeNumber(
amount: number | string | null | undefined
@@ -70,6 +71,45 @@ export function formatPdfCurrency(
return `${symbol}${formatted}`.trim();
}
export function formatPdfCurrencyAmount(
amount: number | string | null | undefined,
currencyCode = 'THB'
): string {
const numericAmount = normalizeNumber(amount);
if (numericAmount === null) {
return '-';
}
const normalizedCode = currencyCode.toUpperCase();
const symbols: Record<string, string> = {
THB: '฿',
USD: '$',
EUR: 'EUR '
};
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(numericAmount);
return `${symbol}${formatted}`.trim();
}
export function buildPdfPriceTableData(
amount: number | string | null | undefined,
currencyCode?: string | null
): string[][] {
const normalizedCurrency = currencyCode?.trim() || 'THB';
return [[
PRICE_TABLE_LABEL,
formatPdfCurrencyAmount(amount, normalizedCurrency),
' ',
normalizedCurrency
]];
}
export function formatTopicItems(topic?: {
items?: Array<{ content?: string | null; sortOrder?: number | null }> | string[];
}): string[][] {
@@ -109,6 +149,16 @@ export function normalizePdfmeTable(
if (value.every((row) => Array.isArray(row))) {
const normalizedRows = value.map((row) =>
(row as unknown[]).map((cell) => {
if (typeof cell === 'string') {
const trimmedValue = cell.trim();
if (trimmedValue) {
return trimmedValue;
}
return cell.length > 0 ? cell : '-';
}
const stringValue = String(cell ?? '').trim();
return stringValue || '-';
})

View File

@@ -1,52 +1,73 @@
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';
import { formatPdfCurrency, formatPdfDate, formatTopicItems } from './pdfme-transforms';
import { buildPdfTopicTemplate } from './pdf-topic-engine';
import { resolveQuotationSignatures } from './signature-resolver';
import { resolveQuotationTopicTypeMapping } from './topic-mapping';
import type { Template } from '@pdfme/common';
QuotationDocumentData,
} from "../types";
import type { PdfTopic } from "./pdf-topic.type";
import {
buildPdfPriceTableData,
formatPdfCurrency,
formatPdfDate,
formatTopicItems,
} from "./pdfme-transforms";
import { buildPdfTopicTemplate } from "./pdf-topic-engine";
import { resolveQuotationSignatures } from "./signature-resolver";
import { resolveQuotationTopicTypeMapping } from "./topic-mapping";
import type { Template } from "@pdfme/common";
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",
} 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)
@@ -54,13 +75,13 @@ async function assertQuotationDocumentAccess(quotationId: string, organizationId
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;
@@ -68,14 +89,16 @@ async function assertQuotationDocumentAccess(quotationId: string, organizationId
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;
}
@@ -88,9 +111,11 @@ 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 ?? '')
: '';
return field &&
typeof field === "object" &&
typeof (field as { name?: unknown }).name === "string"
? ((field as { name: string }).name ?? "")
: "";
}
function normalizeTopicKey(value: string | null | undefined) {
@@ -98,11 +123,14 @@ 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);
@@ -115,30 +143,33 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode:
}
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;
@@ -148,9 +179,12 @@ 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;
@@ -166,28 +200,37 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
}
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;
}
@@ -197,12 +240,12 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
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);
@@ -219,9 +262,12 @@ function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record<
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,
@@ -237,7 +283,7 @@ export async function buildQuotationDocumentData(
customerRoleOptions,
productTypeOptions,
topicTypeOptions,
approvalRequests
approvalRequests,
] = await Promise.all([
db
.select()
@@ -248,24 +294,42 @@ export async function buildQuotationDocumentData(
listQuotationItems(quotationId, organizationId),
listQuotationCustomers(quotationId, organizationId),
listQuotationTopics(quotationId, organizationId),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, {
organizationId,
}),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, {
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([
@@ -276,8 +340,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
@@ -288,47 +352,57 @@ 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),
quotationCustomers.length
? db
.select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name })
.select({
id: crmCustomers.id,
code: crmCustomers.code,
name: crmCustomers.name,
})
.from(crmCustomers)
.where(
and(
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) ??
@@ -340,7 +414,7 @@ export async function buildQuotationDocumentData(
findOptionLabel(topicTypeOptions, item.topicType) ??
item.title?.trim() ??
topicCodeById.get(item.topicType) ??
'Topic';
"Topic";
return {
id: item.id,
@@ -351,8 +425,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) => ({
@@ -361,24 +435,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 ![
@@ -386,7 +460,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];
@@ -395,21 +469,23 @@ 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";
return {
company: {
@@ -417,14 +493,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: {
@@ -434,7 +510,7 @@ export async function buildQuotationDocumentData(
address: customer.address,
phone: customer.phone,
email: customer.email,
taxId: customer.taxId
taxId: customer.taxId,
},
contact: contact
? {
@@ -442,7 +518,7 @@ export async function buildQuotationDocumentData(
name: contact.name,
email: contact.email,
mobile: contact.mobile,
position: contact.position
position: contact.position,
}
: null,
quotation: {
@@ -458,20 +534,32 @@ export async function buildQuotationDocumentData(
revisionLabel: toRevisionLabel(quotationDetail.revision),
statusCode,
statusLabel,
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
currency: findOptionCode(currencyOptions, quotationDetail.currency),
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
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,
@@ -487,101 +575,128 @@ 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: {
quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'),
labels: PDFME_DEFAULT_LABELS,
quotation_date: formatPdfDate(quotationDetail.quotationDate, "en"),
quotation_price: formatPdfCurrency(
quotationDetail.totalAmount,
findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB'
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) {
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
export async function getQuotationDocumentPreviewData(
quotationId: string,
organizationId: string,
) {
const documentData = await buildQuotationDocumentData(
quotationId,
organizationId,
);
const template = await resolveTemplateForDocument(organizationId, {
documentType: 'quotation',
productType: 'default',
fileType: 'pdfme'
documentType: "quotation",
productType: "default",
fileType: "pdfme",
});
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 { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
template.version.schemaJson as Template,
documentData.topics.all
documentData.topics.all,
);
const mergedTemplateInput = {
...templateInput,
...topicInputs
...topicInputs,
};
documentData.pdfme.topic_inputs = topicInputs;
@@ -592,20 +707,23 @@ export async function getQuotationDocumentPreviewData(quotationId: string, organ
...template,
version: {
...template.version,
schemaJson: renderTemplate
}
schemaJson: renderTemplate,
},
},
mappings,
templateInput: mergedTemplateInput
templateInput: mergedTemplateInput,
};
}
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 {
@@ -615,6 +733,6 @@ export async function prepareApprovedQuotationSnapshot(
templateVersionId: preview.template.version.id,
templateInput: preview.templateInput,
generatedAt,
generatedBy: userId
generatedBy: userId,
};
}

View File

@@ -115,8 +115,16 @@ export interface QuotationDocumentData {
all: PdfTopic[];
};
pdfme: {
labels: {
tel: string;
email: string;
att: string;
project: string;
location: string;
};
quotation_date: string;
quotation_price: string;
quotation_price_data: string[][];
exclusion_data: string[][];
payment_data?: string[][];
warranty_data?: string[][];