|
|
|
|
@@ -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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|