task-h.5.3
This commit is contained in:
@@ -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 || '-';
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user