commit
This commit is contained in:
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user