This commit is contained in:
phaichayon
2026-06-16 10:57:41 +07:00
parent dff22d75b5
commit 357414c247
33 changed files with 9281 additions and 26 deletions

View File

@@ -0,0 +1,359 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import {
crmCustomerContacts,
crmCustomers,
crmQuotations,
organizations,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { listApprovalRequests, 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';
import {
getQuotationDetail,
listQuotationCustomers,
listQuotationItems,
listQuotationTopics
} from '@/features/crm/quotations/server/service';
import type {
ApprovedQuotationSnapshot,
QuotationDocumentApprovalStep,
QuotationDocumentData
} from '../types';
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_quotation_customer_role',
productType: 'crm_product_type'
} as const;
function toRevisionLabel(revision: number) {
return `R${String(revision).padStart(2, '0')}`;
}
async function assertQuotationDocumentAccess(quotationId: string, organizationId: string) {
const [quotation] = await db
.select()
.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);
}
return quotation;
}
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;
}
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;
}
export async function buildQuotationDocumentData(
quotationId: string,
organizationId: string
): Promise<QuotationDocumentData> {
const quotation = await assertQuotationDocumentAccess(quotationId, organizationId);
const [
company,
quotationDetail,
items,
quotationCustomers,
topics,
branchOptions,
statusOptions,
quotationTypeOptions,
currencyOptions,
discountTypeOptions,
unitOptions,
customerRoleOptions,
productTypeOptions,
approvalRequests
] = await Promise.all([
db
.select()
.from(organizations)
.where(eq(organizations.id, organizationId))
.then((rows) => rows[0] ?? null),
getQuotationDetail(quotationId, organizationId),
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 }),
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([
db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.id, quotation.customerId),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
)
)
.then((rows) => rows[0] ?? null),
quotation.contactId
? db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.id, quotation.contactId),
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt)
)
)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
quotationCustomers.length
? db
.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)
),
isNull(crmCustomers.deletedAt)
)
)
: Promise.resolve([]),
db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, quotation.createdBy))
.then((rows) => rows[0] ?? null)
]);
if (!customer) {
throw new AuthError('Customer not found', 404);
}
const approvalRequest = 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 statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(statusOptions, quotation.status);
const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
.map((item) => {
const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber);
return {
stepNumber: item.stepNumber,
roleCode: step?.roleCode ?? '',
roleName: step?.roleName ?? `Step ${item.stepNumber}`,
action: item.action,
actorName: item.actorName,
actedAt: item.actedAt,
remark: item.remark
};
}) ?? [];
return {
company: {
id: company.id,
name: company.name,
slug: company.slug,
imageUrl: company.imageUrl,
plan: company.plan
},
branch: branch
? {
id: branch.id,
code: branch.code,
label: branch.label,
value: branch.value
}
: null,
customer: {
id: customer.id,
code: customer.code,
name: customer.name,
address: customer.address,
phone: customer.phone,
email: customer.email,
taxId: customer.taxId
},
contact: contact
? {
id: contact.id,
name: contact.name,
email: contact.email,
mobile: contact.mobile,
position: contact.position
}
: null,
quotation: {
id: quotationDetail.id,
code: quotationDetail.code,
quotationDate: quotationDetail.quotationDate,
validUntil: quotationDetail.validUntil,
projectName: quotationDetail.projectName,
projectLocation: quotationDetail.projectLocation,
attention: quotationDetail.attention,
reference: quotationDetail.reference,
revision: quotationDetail.revision,
revisionLabel: toRevisionLabel(quotationDetail.revision),
statusCode,
statusLabel,
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
exchangeRate: quotationDetail.exchangeRate,
subtotal: quotationDetail.subtotal,
discount: quotationDetail.discount,
discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType),
discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType),
taxRate: quotationDetail.taxRate,
taxAmount: quotationDetail.taxAmount,
totalAmount: quotationDetail.totalAmount,
notes: quotationDetail.notes
},
items: items.map((item) => ({
id: item.id,
itemNumber: item.itemNumber,
productTypeCode: findOptionCode(productTypeOptions, item.productType),
productTypeLabel: findOptionLabel(productTypeOptions, item.productType),
description: item.description,
quantity: item.quantity,
unitCode: findOptionCode(unitOptions, item.unit),
unitLabel: findOptionLabel(unitOptions, item.unit),
unitPrice: item.unitPrice,
discount: item.discount,
discountTypeCode: findOptionCode(discountTypeOptions, item.discountType),
taxRate: item.taxRate,
totalPrice: item.totalPrice,
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,
roleCode: item.role,
roleLabel: findOptionLabel(customerRoleOptions, item.role),
isPrimary: item.isPrimary
})),
topics: {
scope: topics
.filter((item) => item.topicType === 'scope')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
exclusion: topics
.filter((item) => item.topicType === 'exclusion')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
payment: topics
.filter((item) => item.topicType === 'payment')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
other: topics
.filter((item) => !['scope', 'exclusion', 'payment'].includes(item.topicType))
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
},
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,
currentStep: approvalDetail?.request.currentStep ?? null,
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
approvers: approvalApprovers
},
signatures: {
preparedBy: preparer?.name ?? null,
requestedBy: contact?.name ?? customer.name,
salesManager:
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
departmentManager:
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null
},
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
};
}
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'
});
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
mappings
);
return {
documentData,
template,
mappings,
templateInput
};
}
export async function prepareApprovedQuotationSnapshot(
quotationId: string,
organizationId: string
): Promise<ApprovedQuotationSnapshot> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
return {
quotationId,
approvedAt: preview.documentData.approval.approvedAt,
documentData: preview.documentData,
templateVersionId: preview.template.version.id,
templateInput: preview.templateInput
};
}