This commit is contained in:
phaichayon
2026-06-19 12:30:26 +07:00
parent 721653f692
commit a85d24235c
14 changed files with 1250 additions and 102 deletions

View File

@@ -1,14 +1,11 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import {
crmCustomerContacts,
crmCustomers,
crmQuotations,
organizations,
users
} from '@/db/schema';
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';
import {
listApprovalRequests,
getApprovalRequest
} from '@/features/foundation/approval/server/service';
import {
mapDocumentDataToTemplateInput,
resolveTemplateForDocument,
@@ -27,12 +24,9 @@ import type {
QuotationDocumentData
} from '../types';
import type { PdfTopic } from './pdf-topic.type';
import {
formatPdfCurrency,
formatPdfDate,
formatTopicItems
} from './pdfme-transforms';
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';
@@ -76,14 +70,14 @@ function findOptionLabel(
options: Array<{ id: string; code: string; label: string }>,
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
) {
return id ? options.find((option) => option.id === id)?.code ?? null : null;
return id ? (options.find((option) => option.id === id)?.code ?? null) : null;
}
function extractSinglePlaceholder(value: string) {
@@ -94,7 +88,12 @@ function extractSinglePlaceholder(value: string) {
}
function normalizeTopicKey(value: string | null | undefined) {
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
return (
value
?.trim()
.toLowerCase()
.replaceAll(/[\s_-]+/g, '') ?? ''
);
}
function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
@@ -127,14 +126,13 @@ function matchesTopicAlias(actualCode: string | null | undefined, expectedCode:
['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);
const delta =
(left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (delta !== 0) {
return delta;
@@ -144,10 +142,7 @@ 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 as { schemas?: unknown }).schemas
@@ -243,14 +238,18 @@ export async function buildQuotationDocumentData(
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { organizationId }),
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
listApprovalRequests(organizationId, {
entityType: 'quotation',
entityId: quotationId,
limit: 20
})
]);
if (!company) {
throw new AuthError('Organization not found', 404);
}
const [customer, contact, relatedCustomers, preparer] = await Promise.all([
const [customer, contact, relatedCustomers, signatures] = await Promise.all([
db
.select()
.from(crmCustomers)
@@ -290,18 +289,17 @@ export async function buildQuotationDocumentData(
)
)
: Promise.resolve([]),
db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, quotation.createdBy))
.then((rows) => rows[0] ?? null)
resolveQuotationSignatures(quotationId, organizationId)
]);
if (!customer) {
throw new AuthError('Customer not found', 404);
}
const approvalRequest = approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items[0] ?? null;
const approvalRequest =
approvalRequests.items.find((item) => item.status === 'approved') ??
approvalRequests.items[0] ??
null;
const approvalDetail = approvalRequest
? await getApprovalRequest(approvalRequest.id, organizationId)
: null;
@@ -364,9 +362,13 @@ export async function buildQuotationDocumentData(
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
);
const otherTopics = resolvedTopics.filter((item) => {
return ![topicTypeMapping.scope, topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, topicTypeMapping.delivery].some(
(expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)
);
return ![
topicTypeMapping.scope,
topicTypeMapping.exclusion,
topicTypeMapping.payment,
topicTypeMapping.warranty,
topicTypeMapping.delivery
].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode));
});
const exclusionTopic = exclusionTopics[0];
const paymentTopic = paymentTopics[0];
@@ -376,7 +378,9 @@ export async function buildQuotationDocumentData(
approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
.map((item) => {
const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber);
const step = approvalDetail.steps.find(
(candidate) => candidate.stepNumber === item.stepNumber
);
return {
stepNumber: item.stepNumber,
roleCode: step?.roleCode ?? '',
@@ -527,27 +531,12 @@ export async function buildQuotationDocumentData(
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
approvers: approvalApprovers
},
signatures: {
preparedBy: preparer?.name ?? null,
preparedByPosition: null,
requestedBy: contact?.name ?? customer.name,
salesManager:
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
salesManagerPosition: null,
departmentManager:
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
departmentManagerPosition: null,
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null,
topManagerPosition: null
},
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
signatures,
watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null
};
}
export async function getQuotationDocumentPreviewData(
quotationId: string,
organizationId: string
) {
export async function getQuotationDocumentPreviewData(quotationId: string, organizationId: string) {
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
const template = await resolveTemplateForDocument(organizationId, {
documentType: 'quotation',
@@ -557,7 +546,10 @@ export async function getQuotationDocumentPreviewData(
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);
throw new AuthError(
'Document template mappings are not configured for this template version',
409
);
}
const templateInput = normalizePdfmeTemplateInput(

View File

@@ -0,0 +1,202 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { crmQuotations, memberships, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
getApprovalRequest,
listApprovalRequests
} from '@/features/foundation/approval/server/service';
import type {
ApprovalDetailRecord,
ApprovalTimelineItem
} from '@/features/foundation/approval/types';
import {
getSignaturePositionLookup,
resolveSignaturePositionLabel
} from '@/features/crm/quotations/document/signature-position';
import type {
SignatureBlock,
SignatureParty
} from '@/features/crm/quotations/document/signature.types';
const EMPTY_SIGNATURE_PARTY: SignatureParty = {
name: '-',
position: '-',
actedAt: null,
userId: null,
roleCode: null
};
const APPROVED_BY_ROLE_CODES = new Set(['sales_manager', 'department_manager']);
type ApprovalActionWithRole = ApprovalTimelineItem & {
roleCode: string | null;
};
function toSignatureParty(input?: Partial<SignatureParty> | null): SignatureParty {
return {
name: input?.name?.trim() || '-',
position: input?.position?.trim() || '-',
actedAt: input?.actedAt ?? null,
userId: input?.userId ?? null,
roleCode: input?.roleCode ?? null
};
}
function mapApprovalActions(detail: ApprovalDetailRecord | null): ApprovalActionWithRole[] {
if (!detail) {
return [];
}
return detail.timeline
.filter((item) => item.action === 'approve')
.map((item) => ({
...item,
roleCode: detail.steps.find((step) => step.stepNumber === item.stepNumber)?.roleCode ?? null
}));
}
function resolveApprovedByAction(actions: ApprovalActionWithRole[], finalRoleCode: string | null) {
const candidates = actions.filter(
(item) =>
item.roleCode !== finalRoleCode &&
item.roleCode !== null &&
APPROVED_BY_ROLE_CODES.has(item.roleCode)
);
return candidates.at(-1) ?? null;
}
function resolveAuthorizedByAction(
actions: ApprovalActionWithRole[],
finalRoleCode: string | null
) {
if (!finalRoleCode) {
return null;
}
return actions.filter((item) => item.roleCode === finalRoleCode).at(-1) ?? null;
}
export async function resolveQuotationSignatures(
quotationId: string,
organizationId: string
): Promise<SignatureBlock> {
const [quotation] = await db
.select({
id: crmQuotations.id,
organizationId: crmQuotations.organizationId,
salesmanId: crmQuotations.salesmanId,
createdBy: crmQuotations.createdBy,
createdAt: crmQuotations.createdAt
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.id, quotationId),
eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt)
)
)
.limit(1);
if (!quotation) {
throw new AuthError('Quotation not found', 404);
}
const approvalRequests = await listApprovalRequests(organizationId, {
entityType: 'quotation',
entityId: quotationId,
limit: 20
});
const approvalRequest =
approvalRequests.items.find((item) => item.status === 'approved') ??
approvalRequests.items[0] ??
null;
const approvalDetail = approvalRequest
? await getApprovalRequest(approvalRequest.id, organizationId)
: null;
const finalStep = approvalDetail?.steps.at(-1) ?? null;
const approvalActions = mapApprovalActions(approvalDetail);
const approvedByAction = resolveApprovedByAction(approvalActions, finalStep?.roleCode ?? null);
const authorizedByAction = resolveAuthorizedByAction(
approvalActions,
finalStep?.roleCode ?? null
);
const preparedByUserId = quotation.salesmanId ?? quotation.createdBy;
const userIds = [
preparedByUserId,
approvedByAction?.actedBy ?? null,
authorizedByAction?.actedBy ?? null
].filter((value): value is string => Boolean(value));
const uniqueUserIds = [...new Set(userIds)];
const [userRows, membershipRows, positionLookup] = await Promise.all([
uniqueUserIds.length
? db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, uniqueUserIds))
: Promise.resolve([]),
uniqueUserIds.length
? db
.select({
userId: memberships.userId,
businessRole: memberships.businessRole
})
.from(memberships)
.where(
and(
eq(memberships.organizationId, organizationId),
inArray(memberships.userId, uniqueUserIds)
)
)
: Promise.resolve([]),
getSignaturePositionLookup(organizationId)
]);
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
const membershipMap = new Map(membershipRows.map((row) => [row.userId, row.businessRole]));
const createPartyFromUser = (args: {
userId: string | null | undefined;
actedAt?: string | null;
roleCode?: string | null;
fallbackName?: string | null;
}) => {
if (!args.userId && !args.fallbackName) {
return toSignatureParty(EMPTY_SIGNATURE_PARTY);
}
const businessRole =
(args.userId ? membershipMap.get(args.userId) : null) ?? args.roleCode ?? null;
return toSignatureParty({
name:
(args.userId ? userMap.get(args.userId) : null) ??
args.fallbackName?.trim() ??
EMPTY_SIGNATURE_PARTY.name,
position: resolveSignaturePositionLabel(businessRole, positionLookup),
actedAt: args.actedAt ?? null,
userId: args.userId ?? null,
roleCode: businessRole
});
};
return {
preparedBy: createPartyFromUser({
userId: preparedByUserId,
actedAt: quotation.createdAt.toISOString()
}),
approvedBy: createPartyFromUser({
userId: approvedByAction?.actedBy,
actedAt: approvedByAction?.actedAt ?? null,
roleCode: approvedByAction?.roleCode ?? null,
fallbackName: approvedByAction?.actorName ?? null
}),
authorizedBy: createPartyFromUser({
userId: authorizedByAction?.actedBy,
actedAt: authorizedByAction?.actedAt ?? null,
roleCode: authorizedByAction?.roleCode ?? finalStep?.roleCode ?? null,
fallbackName: authorizedByAction?.actorName ?? null
})
};
}