import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm'; import { crmDocumentArtifacts, crmCustomerContacts, crmCustomers, crmOpportunities, crmQuotationAttachments, crmQuotationCustomers, crmQuotationFollowups, crmQuotationItems, crmQuotationTopicItems, crmQuotationTopics, crmQuotations, memberships, users } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; import { type CrmSecurityContext, canAccessScopedCrmRecord } from '@/features/crm/security/server/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { resolveBranchDisplays, resolveUserDisplays } from '@/features/foundation/display/server/display-resolver'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service'; import { syncOpportunityFromQuotationApproval, syncOpportunityFromQuotationCreated } from '@/features/crm/opportunities/server/quotation-sync.service'; import { assertOpportunityLinkIntegrity, assertRevisionOpportunityLinkIntegrity, auditQuotationOpportunityLink } from './opportunity-link.service'; import type { QuotationActivityRecord, QuotationAttachmentMutationPayload, QuotationAttachmentRecord, QuotationApprovedArtifactSummary, QuotationBranchOption, QuotationContactLookup, QuotationCustomerListItem, QuotationCustomerLookup, QuotationCustomerMutationPayload, QuotationCustomerRecord, QuotationOpportunityLookup, QuotationFilters, QuotationFollowupMutationPayload, QuotationFollowupRecord, QuotationItemMutationPayload, QuotationItemRecord, QuotationListItem, QuotationMutationPayload, QuotationOption, QuotationRecord, QuotationReferenceData, QuotationRelationItem, QuotationSalesmanLookup, QuotationTopicItemRecord, QuotationTopicMutationPayload, QuotationTopicRecord } from '../api/types'; import { buildProjectPartyKey, PROJECT_PARTY_OPTION_CATEGORY, resolveProjectPartyRole } from '@/features/crm/shared/project-party'; const QUOTATION_OPTION_CATEGORIES = { status: 'crm_quotation_status', quotationType: 'crm_quotation_type', currency: 'crm_currency', discountType: 'crm_discount_type', unit: 'crm_unit', sentVia: 'crm_sent_via', projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY, topicType: 'crm_quotation_topic_type', followupType: 'crm_followup_type', productType: 'crm_product_type' } as const; const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']); export type QuotationAccessContext = CrmSecurityContext; function mapOption( option: Awaited>[number] ): QuotationOption { return { id: option.id, code: option.code, label: option.label, value: option.value }; } function mapBranch( branch: Awaited>[number] ): QuotationBranchOption { return { id: branch.id, code: branch.code, name: branch.name, value: branch.value }; } function mapApprovedArtifactSummary( row: typeof crmDocumentArtifacts.$inferSelect, options?: { generatedByName?: string | null; lockedByName?: string | null } ): QuotationApprovedArtifactSummary { return { id: row.id, fileName: row.fileName, contentType: row.contentType, fileSize: row.fileSize, checksum: row.checksum, status: row.status, generatedAt: row.generatedAt.toISOString(), generatedBy: row.generatedBy, generatedByName: options?.generatedByName ?? null, lockedAt: row.lockedAt?.toISOString() ?? null, lockedBy: row.lockedBy, lockedByName: options?.lockedByName ?? null }; } function mapQuotationRecord( row: typeof crmQuotations.$inferSelect, options?: { approvedArtifact?: QuotationApprovedArtifactSummary | null; branchName?: string | null; branchCode?: string | null; salesmanName?: string | null; createdByName?: string | null; updatedByName?: string | null; } ): QuotationRecord { return { id: row.id, organizationId: row.organizationId, branchId: row.branchId, branchName: options?.branchName ?? null, branchCode: options?.branchCode ?? null, code: row.code, opportunityId: row.opportunityId, customerId: row.customerId, contactId: row.contactId, quotationDate: row.quotationDate.toISOString(), validUntil: row.validUntil?.toISOString() ?? null, quotationType: row.quotationType, projectName: row.projectName, projectLocation: row.projectLocation, attention: row.attention, reference: row.reference, notes: row.notes, status: row.status, revision: row.revision, parentQuotationId: row.parentQuotationId, revisionRemark: row.revisionRemark, currency: row.currency, exchangeRate: row.exchangeRate, subtotal: row.subtotal, discount: row.discount, discountType: row.discountType, taxRate: row.taxRate, taxAmount: row.taxAmount, totalAmount: row.totalAmount, chancePercent: row.chancePercent, isHotProject: row.isHotProject, competitor: row.competitor, salesmanId: row.salesmanId, salesmanName: options?.salesmanName ?? null, isSent: row.isSent, sentAt: row.sentAt?.toISOString() ?? null, sentVia: row.sentVia, approvedAt: row.approvedAt?.toISOString() ?? null, approvedArtifactId: row.approvedArtifactId, approvedPdfUrl: row.approvedPdfUrl, approvedSnapshot: row.approvedSnapshot ?? null, approvedTemplateVersionId: row.approvedTemplateVersionId, approvedArtifact: options?.approvedArtifact ?? null, hasLegacyApprovedPdf: !row.approvedArtifactId && typeof row.approvedPdfUrl === 'string' && row.approvedPdfUrl.startsWith('/generated/'), acceptedAt: row.acceptedAt?.toISOString() ?? null, rejectedAt: row.rejectedAt?.toISOString() ?? null, rejectionReason: row.rejectionReason, isActive: row.isActive, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null, createdBy: row.createdBy, createdByName: options?.createdByName ?? null, updatedBy: row.updatedBy, updatedByName: options?.updatedByName ?? null }; } function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): QuotationItemRecord { return { id: row.id, organizationId: row.organizationId, quotationId: row.quotationId, itemNumber: row.itemNumber, productType: row.productType, description: row.description, quantity: row.quantity, unit: row.unit, unitPrice: row.unitPrice, discount: row.discount, discountType: row.discountType, taxRate: row.taxRate, totalPrice: row.totalPrice, notes: row.notes, sortOrder: row.sortOrder, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null }; } function mapQuotationCustomerRecord( row: typeof crmQuotationCustomers.$inferSelect, options: { role: { id: string; code: string; label: string } } ): QuotationCustomerRecord { return { id: row.id, organizationId: row.organizationId, quotationId: row.quotationId, customerId: row.customerId, role: options.role.id, roleCode: options.role.code, roleLabel: options.role.label, isPrimary: row.isPrimary, remark: row.remark, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null }; } function mapQuotationTopicItemRecord( row: typeof crmQuotationTopicItems.$inferSelect ): QuotationTopicItemRecord { return { id: row.id, organizationId: row.organizationId, topicId: row.topicId, content: row.content, sortOrder: row.sortOrder, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null }; } function mapQuotationFollowupRecord( row: typeof crmQuotationFollowups.$inferSelect ): QuotationFollowupRecord { return { id: row.id, organizationId: row.organizationId, quotationId: row.quotationId, followupDate: row.followupDate.toISOString(), followupType: row.followupType, contactId: row.contactId, outcome: row.outcome, notes: row.notes, nextFollowupDate: row.nextFollowupDate?.toISOString() ?? null, nextAction: row.nextAction, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null, createdBy: row.createdBy, updatedBy: row.updatedBy }; } function mapQuotationAttachmentRecord( row: typeof crmQuotationAttachments.$inferSelect ): QuotationAttachmentRecord { return { id: row.id, organizationId: row.organizationId, quotationId: row.quotationId, fileName: row.fileName, originalFileName: row.originalFileName, filePath: row.filePath, fileSize: row.fileSize, fileType: row.fileType, description: row.description, uploadedAt: row.uploadedAt.toISOString(), uploadedBy: row.uploadedBy, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null }; } function parseSort(sort?: string) { if (!sort) { return desc(crmQuotations.updatedAt); } try { const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>; const [rule] = parsed; if (!rule) { return desc(crmQuotations.updatedAt); } const sortMap = { code: crmQuotations.code, quotationDate: crmQuotations.quotationDate, status: crmQuotations.status, quotationType: crmQuotations.quotationType, branch: crmQuotations.branchId, customer: crmQuotations.customerId, totalAmount: crmQuotations.totalAmount, updatedAt: crmQuotations.updatedAt } as const; const column = sortMap[rule.id as keyof typeof sortMap]; if (!column) { return desc(crmQuotations.updatedAt); } return rule.desc ? desc(column) : asc(column); } catch { return desc(crmQuotations.updatedAt); } } function splitFilterValue(value?: string) { return value ? value .split(/[.,]/) .map((item) => item.trim()) .filter(Boolean) : []; } function toRevisionLabel(revision: number) { return `R${String(revision).padStart(2, '0')}`; } function calculateDiscountAmount(baseAmount: number, discount = 0, discountType?: string | null) { if (!discount) { return 0; } if (discountType === 'percent' || discountType === 'percentage') { return (baseAmount * discount) / 100; } return discount; } function calculateLineTotal(payload: QuotationItemMutationPayload) { const baseAmount = payload.quantity * payload.unitPrice; const discountAmount = calculateDiscountAmount( baseAmount, payload.discount ?? 0, payload.discountType ?? null ); return Math.max(baseAmount - discountAmount, 0); } async function resolveValidOptionIds(category: string, organizationId: string) { const options = await getActiveOptionsByCategory(category, { organizationId }); return new Set(options.map((option) => option.id)); } async function resolveOptionCodeById( organizationId: string, category: string, optionId?: string | null ) { if (!optionId) { return null; } const options = await getActiveOptionsByCategory(category, { organizationId }); return options.find((option) => option.id === optionId)?.code ?? null; } async function resolveOptionIdByCode(organizationId: string, category: string, code: string) { const options = await getActiveOptionsByCategory(category, { organizationId }); return options.find((option) => option.code === code)?.id ?? null; } async function assertMasterOptionValue( organizationId: string, category: string, optionId?: string | null ) { if (!optionId) { return; } const validIds = await resolveValidOptionIds(category, organizationId); if (!validIds.has(optionId)) { throw new AuthError(`Invalid option for ${category}`, 400); } } export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { const [customer] = await db .select() .from(crmCustomers) .where( and( eq(crmCustomers.id, id), eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt) ) ) .limit(1); if (!customer) { throw new AuthError('Customer not found', 404); } return customer; } export async function assertContactBelongsToOrganization( contactId: string, organizationId: string, customerId?: string | null ) { const [contact] = await db .select() .from(crmCustomerContacts) .where( and( eq(crmCustomerContacts.id, contactId), eq(crmCustomerContacts.organizationId, organizationId), isNull(crmCustomerContacts.deletedAt), ...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : []) ) ) .limit(1); if (!contact) { throw new AuthError('Contact not found', 404); } return contact; } async function assertOpportunityBelongsToOrganization(id: string, organizationId: string) { const [opportunity] = await db .select() .from(crmOpportunities) .where( and( eq(crmOpportunities.id, id), eq(crmOpportunities.organizationId, organizationId), isNull(crmOpportunities.deletedAt) ) ) .limit(1); if (!opportunity) { throw new AuthError('Opportunity not found', 404); } return opportunity; } function assertOpportunityOpenForQuotation( opportunity: Awaited> ) { if ((opportunity.outcomeStatus ?? 'open') !== 'open') { throw new AuthError('Closed opportunities cannot create quotations', 400); } } async function assertQuotationBelongsToOrganization( id: string, organizationId: string, accessContext?: QuotationAccessContext ) { const [quotation] = await db .select() .from(crmQuotations) .where( and( eq(crmQuotations.id, id), eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt) ) ) .limit(1); if (!quotation) { throw new AuthError('Quotation not found', 404); } if (accessContext && !canAccessQuotationRow(quotation, accessContext)) { throw new AuthError('Forbidden', 403); } return quotation; } function canAccessQuotationRow( row: typeof crmQuotations.$inferSelect, accessContext: QuotationAccessContext ) { return canAccessScopedCrmRecord(accessContext, { branchId: row.branchId, productType: row.quotationType, createdBy: row.createdBy, ownerUserId: row.salesmanId }); } function buildQuotationAccessScopedFilters(accessContext: QuotationAccessContext): SQL[] { const filters: SQL[] = []; if (accessContext.branchScopeMode === 'assigned' && accessContext.branchScopeIds.length > 0) { filters.push(inArray(crmQuotations.branchId, accessContext.branchScopeIds)); } if (accessContext.productScopeMode === 'assigned' && accessContext.productTypeScopeIds.length > 0) { filters.push(inArray(crmQuotations.quotationType, accessContext.productTypeScopeIds)); } if (accessContext.membershipRole !== 'admin' && accessContext.ownershipScope === 'own') { filters.push( or(eq(crmQuotations.salesmanId, accessContext.userId), eq(crmQuotations.createdBy, accessContext.userId))! ); } return filters; } async function assertQuotationItemBelongsToQuotation( quotationId: string, itemId: string, organizationId: string ) { const [item] = await db .select() .from(crmQuotationItems) .where( and( eq(crmQuotationItems.id, itemId), eq(crmQuotationItems.quotationId, quotationId), eq(crmQuotationItems.organizationId, organizationId), isNull(crmQuotationItems.deletedAt) ) ) .limit(1); if (!item) { throw new AuthError('Quotation item not found', 404); } return item; } async function assertQuotationCustomerBelongsToQuotation( quotationId: string, relationId: string, organizationId: string ) { const [relation] = await db .select() .from(crmQuotationCustomers) .where( and( eq(crmQuotationCustomers.id, relationId), eq(crmQuotationCustomers.quotationId, quotationId), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ) .limit(1); if (!relation) { throw new AuthError('Quotation customer relation not found', 404); } return relation; } async function assertQuotationTopicBelongsToQuotation( quotationId: string, topicId: string, organizationId: string ) { const [topic] = await db .select() .from(crmQuotationTopics) .where( and( eq(crmQuotationTopics.id, topicId), eq(crmQuotationTopics.quotationId, quotationId), eq(crmQuotationTopics.organizationId, organizationId), isNull(crmQuotationTopics.deletedAt) ) ) .limit(1); if (!topic) { throw new AuthError('Quotation topic not found', 404); } return topic; } async function assertQuotationFollowupBelongsToQuotation( quotationId: string, followupId: string, organizationId: string ) { const [followup] = await db .select() .from(crmQuotationFollowups) .where( and( eq(crmQuotationFollowups.id, followupId), eq(crmQuotationFollowups.quotationId, quotationId), eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt) ) ) .limit(1); if (!followup) { throw new AuthError('Quotation follow-up not found', 404); } return followup; } async function assertQuotationAttachmentBelongsToQuotation( quotationId: string, attachmentId: string, organizationId: string ) { const [attachment] = await db .select() .from(crmQuotationAttachments) .where( and( eq(crmQuotationAttachments.id, attachmentId), eq(crmQuotationAttachments.quotationId, quotationId), eq(crmQuotationAttachments.organizationId, organizationId), isNull(crmQuotationAttachments.deletedAt) ) ) .limit(1); if (!attachment) { throw new AuthError('Quotation attachment not found', 404); } return attachment; } async function assertSalesmanBelongsToOrganization(userId: string, organizationId: string) { const [membership] = await db .select({ userId: memberships.userId }) .from(memberships) .where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId))) .limit(1); if (!membership) { throw new AuthError('Salesman not found in organization', 404); } } async function validateQuotationPayload( organizationId: string, payload: QuotationMutationPayload, accessContext?: QuotationAccessContext ) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); if (payload.contactId) { await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId); } if (payload.opportunityId) { const opportunity = await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId); if (opportunity.customerId !== payload.customerId) { throw new AuthError('Selected opportunity belongs to a different customer', 400); } } if (payload.branchId) { await validateBranchAccess(payload.branchId); } if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) { throw new AuthError('Forbidden branch scope', 403); } if (payload.salesmanId) { await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId); } await assertMasterOptionValue(organizationId, QUOTATION_OPTION_CATEGORIES.status, payload.status); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.quotationType, payload.quotationType ); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.currency, payload.currency ); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.discountType, payload.discountType ?? null ); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.sentVia, payload.sentVia ?? null ); if (accessContext && !hasProductScopeAccess(accessContext, payload.quotationType)) { throw new AuthError('Forbidden product scope', 403); } for (const projectParty of payload.projectParties ?? []) { await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.projectPartyRole, projectParty.role ); } } async function validateQuotationItemPayload( organizationId: string, payload: QuotationItemMutationPayload ) { await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.productType, payload.productType ); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.discountType, payload.discountType ?? null ); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.unit, payload.unit ?? null ); } async function validateQuotationCustomerPayload( organizationId: string, payload: QuotationCustomerMutationPayload ) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.projectPartyRole, payload.role ); } async function buildPreparedQuotationProjectParties( organizationId: string, billingCustomerId: string, projectParties: QuotationCustomerMutationPayload[] = [] ) { const roleOptions = await getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId }); const billingRole = roleOptions.find((option) => option.code === 'billing_customer'); if (!billingRole) { throw new AuthError('Billing customer project-party role is not configured', 409); } const prepared = new Map< string, { customerId: string; roleId: string; roleCode: string; isPrimary: boolean; remark: string | null; } >(); for (const item of projectParties) { const resolvedRole = resolveProjectPartyRole(roleOptions, item.role); if (!resolvedRole) { throw new AuthError('Invalid project party role', 400); } const key = buildProjectPartyKey(item.customerId, resolvedRole.code); if (!prepared.has(key)) { prepared.set(key, { customerId: item.customerId, roleId: resolvedRole.id, roleCode: resolvedRole.code, isPrimary: resolvedRole.code === 'billing_customer' || !!item.isPrimary, remark: item.remark?.trim() || null }); } } const billingKey = buildProjectPartyKey(billingCustomerId, billingRole.code); if (!prepared.has(billingKey)) { prepared.set(billingKey, { customerId: billingCustomerId, roleId: billingRole.id, roleCode: billingRole.code, isPrimary: true, remark: null }); } return [...prepared.values()].map((item) => ({ ...item, isPrimary: item.roleCode === 'billing_customer' })); } async function syncQuotationProjectParties( tx: Pick, organizationId: string, quotationId: string, billingCustomerId: string, payload: QuotationCustomerMutationPayload[] ) { const now = new Date(); const preparedParties = await buildPreparedQuotationProjectParties( organizationId, billingCustomerId, payload ); await tx .update(crmQuotationCustomers) .set({ deletedAt: now, updatedAt: now }) .where( and( eq(crmQuotationCustomers.quotationId, quotationId), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ); if (!preparedParties.length) { return; } await tx.insert(crmQuotationCustomers).values( preparedParties.map((item) => ({ id: crypto.randomUUID(), organizationId, quotationId, customerId: item.customerId, role: item.roleId, isPrimary: item.isPrimary, remark: item.remark })) ); } async function assertQuotationProjectPartyNotDuplicated( quotationId: string, organizationId: string, payload: QuotationCustomerMutationPayload, excludeRelationId?: string ) { const [relations, roleOptions] = await Promise.all([ db .select() .from(crmQuotationCustomers) .where( and( eq(crmQuotationCustomers.quotationId, quotationId), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId }) ]); const incomingRole = resolveProjectPartyRole(roleOptions, payload.role); if (!incomingRole) { throw new AuthError('Invalid project party role', 400); } const duplicate = relations.find((relation) => { if (excludeRelationId && relation.id === excludeRelationId) { return false; } const currentRole = resolveProjectPartyRole(roleOptions, relation.role); return relation.customerId === payload.customerId && currentRole?.code === incomingRole.code; }); if (duplicate) { throw new AuthError('This project party already exists for the quotation', 400); } } async function validateQuotationTopicPayload( organizationId: string, payload: QuotationTopicMutationPayload ) { await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.topicType, payload.topicType ); } async function validateQuotationFollowupPayload( quotationId: string, organizationId: string, payload: QuotationFollowupMutationPayload ) { const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId); await assertMasterOptionValue( organizationId, QUOTATION_OPTION_CATEGORIES.followupType, payload.followupType ); if (payload.contactId) { await assertContactBelongsToOrganization( payload.contactId, organizationId, quotation.customerId ); } } function buildQuotationFilters( organizationId: string, filters: QuotationFilters, accessContext?: QuotationAccessContext ): SQL[] { const statuses = splitFilterValue(filters.status); const quotationTypes = splitFilterValue(filters.quotationType); const branches = splitFilterValue(filters.branch); const customers = splitFilterValue(filters.customer); const opportunities = splitFilterValue(filters.opportunity); return [ eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt), ...(accessContext ? buildQuotationAccessScopedFilters(accessContext) : []), ...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []), ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []), ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []), ...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []), ...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []), ...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []), ...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []), ...(filters.search ? [ or( ilike(crmQuotations.code, `%${filters.search}%`), ilike(crmQuotations.projectName, `%${filters.search}%`), ilike(crmQuotations.projectLocation, `%${filters.search}%`), ilike(crmQuotations.reference, `%${filters.search}%`), ilike(crmQuotations.competitor, `%${filters.search}%`) )! ] : []) ]; } async function refreshQuotationTotals( quotationId: string, organizationId: string, userId?: string ) { const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId); const items = await listQuotationItems(quotationId, organizationId); const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0); const headerDiscountAmount = calculateDiscountAmount( subtotal, quotation.discount, quotation.discountType ); const discountedSubtotal = Math.max(subtotal - headerDiscountAmount, 0); const taxAmount = discountedSubtotal * (quotation.taxRate / 100); const totalAmount = discountedSubtotal + taxAmount; const [updated] = await db .update(crmQuotations) .set({ subtotal, taxAmount, totalAmount, updatedAt: new Date(), ...(userId ? { updatedBy: userId } : {}) }) .where(eq(crmQuotations.id, quotationId)) .returning(); return updated; } async function listQuotationTopicItemsByTopicIds(topicIds: string[], organizationId: string) { if (!topicIds.length) { return []; } return db .select() .from(crmQuotationTopicItems) .where( and( eq(crmQuotationTopicItems.organizationId, organizationId), inArray(crmQuotationTopicItems.topicId, topicIds), isNull(crmQuotationTopicItems.deletedAt) ) ) .orderBy(asc(crmQuotationTopicItems.sortOrder), asc(crmQuotationTopicItems.createdAt)); } export async function getQuotationReferenceData( organizationId: string ): Promise { const [ branches, statuses, quotationTypes, currencies, discountTypes, units, sentVias, projectPartyRoles, topicTypes, followupTypes, productTypes, customers, contacts, opportunities, salesmen ] = await Promise.all([ getUserBranches(), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.status, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.quotationType, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.currency, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }), db .select() .from(crmCustomers) .where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt))) .orderBy(asc(crmCustomers.name)), db .select() .from(crmCustomerContacts) .where( and( eq(crmCustomerContacts.organizationId, organizationId), isNull(crmCustomerContacts.deletedAt) ) ) .orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)), db .select() .from(crmOpportunities) .where(and(eq(crmOpportunities.organizationId, organizationId), isNull(crmOpportunities.deletedAt))) .orderBy(desc(crmOpportunities.updatedAt)), db .select({ id: users.id, name: users.name }) .from(memberships) .innerJoin(users, eq(memberships.userId, users.id)) .where(eq(memberships.organizationId, organizationId)) .orderBy(asc(users.name)) ]); return { branches: branches.map(mapBranch), statuses: statuses.map(mapOption), quotationTypes: quotationTypes.map(mapOption), currencies: currencies.map(mapOption), discountTypes: discountTypes.map(mapOption), units: units.map(mapOption), sentVias: sentVias.map(mapOption), projectPartyRoles: projectPartyRoles.map(mapOption), topicTypes: topicTypes.map(mapOption), followupTypes: followupTypes.map(mapOption), productTypes: productTypes.map(mapOption), customers: customers.map((customer) => ({ id: customer.id, code: customer.code, name: customer.name, branchId: customer.branchId })), contacts: contacts.map((contact) => ({ id: contact.id, customerId: contact.customerId, name: contact.name, email: contact.email, mobile: contact.mobile, isPrimary: contact.isPrimary })), opportunities: opportunities.map((opportunity) => ({ id: opportunity.id, code: opportunity.code, customerId: opportunity.customerId, contactId: opportunity.contactId, title: opportunity.title, projectName: opportunity.projectName, projectLocation: opportunity.projectLocation, branchId: opportunity.branchId })), salesmen: salesmen.map((salesman) => ({ id: salesman.id, name: salesman.name })) }; } export async function listQuotations( organizationId: string, filters: QuotationFilters, accessContext?: QuotationAccessContext ): Promise<{ items: QuotationListItem[]; totalItems: number }> { const page = filters.page ?? 1; const limit = filters.limit ?? 10; const whereFilters = buildQuotationFilters(organizationId, filters, accessContext); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const offset = (page - 1) * limit; const [totalResult] = await db.select({ value: count() }).from(crmQuotations).where(where); const rows = await db .select() .from(crmQuotations) .where(where) .orderBy(parseSort(filters.sort)) .limit(limit) .offset(offset); const customerIds = [...new Set(rows.map((row) => row.customerId))]; const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean))] as string[]; const opportunityIds = [...new Set(rows.map((row) => row.opportunityId).filter(Boolean))] as string[]; const quotationIds = rows.map((row) => row.id); const [customers, contacts, opportunities, itemCounts] = await Promise.all([ customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [], contactIds.length ? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds)) : [], opportunityIds.length ? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds)) : [], quotationIds.length ? db .select({ quotationId: crmQuotationItems.quotationId, value: count() }) .from(crmQuotationItems) .where( and( eq(crmQuotationItems.organizationId, organizationId), inArray(crmQuotationItems.quotationId, quotationIds), isNull(crmQuotationItems.deletedAt) ) ) .groupBy(crmQuotationItems.quotationId) : [] ]); const customerMap = new Map(customers.map((item) => [item.id, item])); const contactMap = new Map(contacts.map((item) => [item.id, item])); const opportunityMap = new Map(opportunities.map((item) => [item.id, item])); const itemCountMap = new Map(itemCounts.map((item) => [item.quotationId, item.value])); return { items: rows.map((row) => ({ ...mapQuotationRecord(row), customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', contactName: row.contactId ? (contactMap.get(row.contactId)?.name ?? null) : null, opportunityCode: row.opportunityId ? (opportunityMap.get(row.opportunityId)?.code ?? null) : null, itemCount: itemCountMap.get(row.id) ?? 0, revisionLabel: toRevisionLabel(row.revision) })), totalItems: totalResult?.value ?? 0 }; } export async function getQuotationDetail( id: string, organizationId: string, accessContext?: QuotationAccessContext ): Promise { const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext); const [userDisplayMap, branchDisplayMap] = await Promise.all([ resolveUserDisplays([ quotation.salesmanId, quotation.createdBy, quotation.updatedBy ]), resolveBranchDisplays({ organizationId, branchIds: [quotation.branchId] }) ]); const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null; const baseDisplayOptions = { branchName: branchDisplay?.name ?? null, branchCode: branchDisplay?.code ?? null, salesmanName: quotation.salesmanId ? (userDisplayMap.get(quotation.salesmanId)?.displayName ?? null) : null, createdByName: userDisplayMap.get(quotation.createdBy)?.displayName ?? null, updatedByName: userDisplayMap.get(quotation.updatedBy)?.displayName ?? null }; const approvedArtifactId = quotation.approvedArtifactId ?? (quotation.approvedPdfUrl?.startsWith('artifact:') ? quotation.approvedPdfUrl.replace('artifact:', '') : null); if (!approvedArtifactId) { return mapQuotationRecord(quotation, baseDisplayOptions); } const [artifact] = await db .select() .from(crmDocumentArtifacts) .where( and( eq(crmDocumentArtifacts.id, approvedArtifactId), eq(crmDocumentArtifacts.organizationId, organizationId), isNull(crmDocumentArtifacts.deletedAt) ) ) .limit(1); if (!artifact) { return mapQuotationRecord(quotation); } const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]); return mapQuotationRecord(quotation, { ...baseDisplayOptions, approvedArtifact: mapApprovedArtifactSummary(artifact, { generatedByName: artifactActorMap.get(artifact.generatedBy)?.displayName ?? null, lockedByName: artifact.lockedBy ? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null) : null }) }); } export async function getQuotationActivity( id: string, organizationId: string ): Promise { const auditLogs = await listAuditLogs({ organizationId, entityId: id, limit: 50 }); const filtered = auditLogs.filter( (log) => log.entityType === 'crm_quotation' || log.entityType === 'crm_quotation_item' || log.entityType === 'crm_quotation_customer' || log.entityType === 'crm_quotation_topic' || log.entityType === 'crm_quotation_followup' || log.entityType === 'crm_quotation_attachment' || log.entityType === 'crm_document_artifact' ); const userIds = [...new Set(filtered.map((log) => log.userId))]; const actorRows = userIds.length ? await db .select({ id: users.id, name: users.name }) .from(users) .where(inArray(users.id, userIds)) : []; const actorMap = new Map(actorRows.map((row) => [row.id, row.name])); return filtered.map((log) => ({ id: log.id, action: log.action, entityType: log.entityType, entityId: log.entityId, createdAt: log.createdAt, userId: log.userId, actorName: actorMap.get(log.userId) ?? null })); } export async function createQuotation( organizationId: string, userId: string, payload: QuotationMutationPayload, accessContext?: QuotationAccessContext ) { await validateQuotationPayload(organizationId, payload, accessContext); const quotationStatusCode = await resolveOptionCodeById( organizationId, QUOTATION_OPTION_CATEGORIES.status, payload.status ); const opportunity = await assertOpportunityLinkIntegrity({ organizationId, opportunityId: payload.opportunityId, customerId: payload.customerId }); const documentCode = await generateNextDocumentCode({ organizationId, documentType: 'quotation', branchId: payload.branchId ?? opportunity?.branchId ?? '' }); return db.transaction(async (tx) => { const [created] = await tx .insert(crmQuotations) .values({ id: crypto.randomUUID(), organizationId, branchId: payload.branchId ?? opportunity?.branchId ?? null, code: documentCode.code, opportunityId: payload.opportunityId ?? null, customerId: payload.customerId, contactId: payload.contactId ?? opportunity?.contactId ?? null, quotationDate: new Date(payload.quotationDate), validUntil: payload.validUntil ? new Date(payload.validUntil) : null, quotationType: payload.quotationType, projectName: payload.projectName?.trim() || opportunity?.projectName || null, projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null, attention: payload.attention?.trim() || null, reference: payload.reference?.trim() || null, notes: payload.notes?.trim() || null, status: payload.status, revision: 0, revisionRemark: payload.revisionRemark?.trim() || null, currency: payload.currency, exchangeRate: payload.exchangeRate ?? 1, discount: payload.discount ?? 0, discountType: payload.discountType ?? null, taxRate: payload.taxRate ?? 0, chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null, isHotProject: payload.isHotProject ?? opportunity?.isHotProject ?? false, competitor: payload.competitor?.trim() || opportunity?.competitor || null, salesmanId: payload.salesmanId ?? null, isSent: false, sentVia: payload.sentVia ?? null, approvedAt: null, approvedArtifactId: null, approvedPdfUrl: null, approvedSnapshot: null, approvedTemplateVersionId: null, isActive: payload.isActive ?? true, createdBy: userId, updatedBy: userId }) .returning(); await syncQuotationProjectParties( tx, organizationId, created.id, payload.customerId, payload.projectParties ?? [] ); if (created.opportunityId) { await auditQuotationOpportunityLink({ organizationId, userId, quotationId: created.id, opportunityId: created.opportunityId, reason: 'quotation_created' }); await syncOpportunityFromQuotationCreated({ organizationId, userId, opportunityId: created.opportunityId, quotationId: created.id }); await syncOpportunityPipelineStageFromQuotationStatus( created.opportunityId, organizationId, quotationStatusCode ); } return created; }); } export async function updateQuotation( id: string, organizationId: string, userId: string, payload: QuotationMutationPayload, accessContext?: QuotationAccessContext ) { await validateQuotationPayload(organizationId, payload, accessContext); const quotationStatusCode = await resolveOptionCodeById( organizationId, QUOTATION_OPTION_CATEGORIES.status, payload.status ); const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext); const opportunity = await assertOpportunityLinkIntegrity({ organizationId, opportunityId: payload.opportunityId, customerId: payload.customerId }); const updated = await db.transaction(async (tx) => { const [row] = await tx .update(crmQuotations) .set({ branchId: payload.branchId ?? opportunity?.branchId ?? null, opportunityId: payload.opportunityId ?? null, customerId: payload.customerId, contactId: payload.contactId ?? opportunity?.contactId ?? null, quotationDate: new Date(payload.quotationDate), validUntil: payload.validUntil ? new Date(payload.validUntil) : null, quotationType: payload.quotationType, projectName: payload.projectName?.trim() || opportunity?.projectName || null, projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null, attention: payload.attention?.trim() || null, reference: payload.reference?.trim() || null, notes: payload.notes?.trim() || null, status: payload.status, revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null, currency: payload.currency, exchangeRate: payload.exchangeRate ?? 1, discount: payload.discount ?? 0, discountType: payload.discountType ?? null, taxRate: payload.taxRate ?? 0, chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null, isHotProject: payload.isHotProject ?? false, competitor: payload.competitor?.trim() || opportunity?.competitor || null, salesmanId: payload.salesmanId ?? null, sentVia: payload.sentVia ?? null, isActive: payload.isActive ?? true, updatedAt: new Date(), updatedBy: userId }) .where(eq(crmQuotations.id, id)) .returning(); await syncQuotationProjectParties( tx, organizationId, id, payload.customerId, payload.projectParties ?? [] ); if (row.opportunityId) { await auditQuotationOpportunityLink({ organizationId, userId, quotationId: row.id, opportunityId: row.opportunityId, previousOpportunityId: existing.opportunityId, reason: 'quotation_updated' }); await syncOpportunityPipelineStageFromQuotationStatus( row.opportunityId, organizationId, quotationStatusCode ); await syncOpportunityFromQuotationApproval({ organizationId, userId, opportunityId: row.opportunityId, quotationId: row.id, quotationStatusCode }); } return row; }); await refreshQuotationTotals(id, organizationId, userId); return updated; } export async function softDeleteQuotation( id: string, organizationId: string, userId: string, accessContext?: QuotationAccessContext ) { await assertQuotationBelongsToOrganization(id, organizationId, accessContext); const now = new Date(); const [updated] = await db .update(crmQuotations) .set({ deletedAt: now, updatedAt: now, updatedBy: userId, isActive: false }) .where(eq(crmQuotations.id, id)) .returning(); await Promise.all([ db .update(crmQuotationItems) .set({ deletedAt: now, updatedAt: now }) .where( and( eq(crmQuotationItems.quotationId, id), eq(crmQuotationItems.organizationId, organizationId) ) ), db .update(crmQuotationCustomers) .set({ deletedAt: now, updatedAt: now }) .where( and( eq(crmQuotationCustomers.quotationId, id), eq(crmQuotationCustomers.organizationId, organizationId) ) ), db .update(crmQuotationTopics) .set({ deletedAt: now, updatedAt: now }) .where( and( eq(crmQuotationTopics.quotationId, id), eq(crmQuotationTopics.organizationId, organizationId) ) ), db .update(crmQuotationFollowups) .set({ deletedAt: now, updatedAt: now, updatedBy: userId }) .where( and( eq(crmQuotationFollowups.quotationId, id), eq(crmQuotationFollowups.organizationId, organizationId) ) ), db .update(crmQuotationAttachments) .set({ deletedAt: now, updatedAt: now }) .where( and( eq(crmQuotationAttachments.quotationId, id), eq(crmQuotationAttachments.organizationId, organizationId) ) ) ]); return updated; } export async function listQuotationItems(id: string, organizationId: string) { await assertQuotationBelongsToOrganization(id, organizationId); const rows = await db .select() .from(crmQuotationItems) .where( and( eq(crmQuotationItems.quotationId, id), eq(crmQuotationItems.organizationId, organizationId), isNull(crmQuotationItems.deletedAt) ) ) .orderBy(asc(crmQuotationItems.sortOrder), asc(crmQuotationItems.itemNumber)); return rows.map(mapQuotationItemRecord); } export async function createQuotationItem( quotationId: string, organizationId: string, userId: string, payload: QuotationItemMutationPayload ) { await assertQuotationBelongsToOrganization(quotationId, organizationId); await validateQuotationItemPayload(organizationId, payload); const existingItems = await listQuotationItems(quotationId, organizationId); const [created] = await db .insert(crmQuotationItems) .values({ id: crypto.randomUUID(), organizationId, quotationId, itemNumber: existingItems.length + 1, productType: payload.productType, description: payload.description.trim(), quantity: payload.quantity, unit: payload.unit?.trim() || null, unitPrice: payload.unitPrice, discount: payload.discount ?? 0, discountType: payload.discountType ?? null, taxRate: payload.taxRate ?? 0, totalPrice: calculateLineTotal(payload), notes: payload.notes?.trim() || null, sortOrder: payload.sortOrder ?? existingItems.length }) .returning(); await refreshQuotationTotals(quotationId, organizationId, userId); return created; } export async function updateQuotationItem( quotationId: string, itemId: string, organizationId: string, userId: string, payload: QuotationItemMutationPayload ) { await validateQuotationItemPayload(organizationId, payload); await assertQuotationItemBelongsToQuotation(quotationId, itemId, organizationId); const [updated] = await db .update(crmQuotationItems) .set({ productType: payload.productType, description: payload.description.trim(), quantity: payload.quantity, unit: payload.unit?.trim() || null, unitPrice: payload.unitPrice, discount: payload.discount ?? 0, discountType: payload.discountType ?? null, taxRate: payload.taxRate ?? 0, totalPrice: calculateLineTotal(payload), notes: payload.notes?.trim() || null, sortOrder: payload.sortOrder ?? 0, updatedAt: new Date() }) .where(eq(crmQuotationItems.id, itemId)) .returning(); await refreshQuotationTotals(quotationId, organizationId, userId); return updated; } export async function softDeleteQuotationItem( quotationId: string, itemId: string, organizationId: string, userId: string ) { await assertQuotationItemBelongsToQuotation(quotationId, itemId, organizationId); const [updated] = await db .update(crmQuotationItems) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(crmQuotationItems.id, itemId)) .returning(); await refreshQuotationTotals(quotationId, organizationId, userId); return updated; } export async function listQuotationCustomers(id: string, organizationId: string) { await assertQuotationBelongsToOrganization(id, organizationId); const [relations, roleOptions] = await Promise.all([ db .select() .from(crmQuotationCustomers) .where( and( eq(crmQuotationCustomers.quotationId, id), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ) .orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.createdAt)), getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId }) ]); const customerIds = [...new Set(relations.map((row) => row.customerId))]; const customers = customerIds.length ? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : []; const customerMap = new Map(customers.map((item) => [item.id, item])); return relations .map((row) => { const resolvedRole = resolveProjectPartyRole(roleOptions, row.role); if (!resolvedRole) { return null; } return { ...mapQuotationCustomerRecord(row, { role: resolvedRole }), customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', customerCode: customerMap.get(row.customerId)?.code ?? '-' } satisfies QuotationCustomerListItem; }) .filter((item): item is QuotationCustomerListItem => item !== null); } export async function createQuotationCustomer( quotationId: string, organizationId: string, payload: QuotationCustomerMutationPayload ) { await assertQuotationBelongsToOrganization(quotationId, organizationId); await validateQuotationCustomerPayload(organizationId, payload); await assertQuotationProjectPartyNotDuplicated(quotationId, organizationId, payload); return db.transaction(async (tx) => { const roleOptions = await getActiveOptionsByCategory( QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId } ); const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role); if (!resolvedRole) { throw new AuthError('Invalid project party role', 400); } const isPrimary = resolvedRole.code === 'billing_customer'; if (isPrimary) { await tx .update(crmQuotationCustomers) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(crmQuotationCustomers.quotationId, quotationId), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ); } const [created] = await tx .insert(crmQuotationCustomers) .values({ id: crypto.randomUUID(), organizationId, quotationId, customerId: payload.customerId, role: resolvedRole.id, isPrimary, remark: payload.remark?.trim() || null }) .returning(); return created; }); } export async function updateQuotationCustomer( quotationId: string, relationId: string, organizationId: string, payload: QuotationCustomerMutationPayload ) { await validateQuotationCustomerPayload(organizationId, payload); await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId); await assertQuotationProjectPartyNotDuplicated( quotationId, organizationId, payload, relationId ); return db.transaction(async (tx) => { const roleOptions = await getActiveOptionsByCategory( QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId } ); const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role); if (!resolvedRole) { throw new AuthError('Invalid project party role', 400); } const isPrimary = resolvedRole.code === 'billing_customer'; if (isPrimary) { await tx .update(crmQuotationCustomers) .set({ isPrimary: false, updatedAt: new Date() }) .where( and( eq(crmQuotationCustomers.quotationId, quotationId), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ); } const [updated] = await tx .update(crmQuotationCustomers) .set({ customerId: payload.customerId, role: resolvedRole.id, isPrimary, remark: payload.remark?.trim() || null, updatedAt: new Date() }) .where(eq(crmQuotationCustomers.id, relationId)) .returning(); return updated; }); } export async function softDeleteQuotationCustomer( quotationId: string, relationId: string, organizationId: string ) { await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId); const [updated] = await db .update(crmQuotationCustomers) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(crmQuotationCustomers.id, relationId)) .returning(); return updated; } export async function listQuotationTopics(id: string, organizationId: string) { await assertQuotationBelongsToOrganization(id, organizationId); const topics = await db .select() .from(crmQuotationTopics) .where( and( eq(crmQuotationTopics.quotationId, id), eq(crmQuotationTopics.organizationId, organizationId), isNull(crmQuotationTopics.deletedAt) ) ) .orderBy(asc(crmQuotationTopics.sortOrder), asc(crmQuotationTopics.createdAt)); const topicItems = await listQuotationTopicItemsByTopicIds( topics.map((topic) => topic.id), organizationId ); const topicItemMap = new Map(); for (const item of topicItems.map(mapQuotationTopicItemRecord)) { const items = topicItemMap.get(item.topicId) ?? []; items.push(item); topicItemMap.set(item.topicId, items); } return topics.map((topic) => ({ id: topic.id, organizationId: topic.organizationId, quotationId: topic.quotationId, topicType: topic.topicType, title: topic.title, sortOrder: topic.sortOrder, createdAt: topic.createdAt.toISOString(), updatedAt: topic.updatedAt.toISOString(), deletedAt: topic.deletedAt?.toISOString() ?? null, items: topicItemMap.get(topic.id) ?? [] })); } export async function createQuotationTopic( quotationId: string, organizationId: string, payload: QuotationTopicMutationPayload ) { await assertQuotationBelongsToOrganization(quotationId, organizationId); await validateQuotationTopicPayload(organizationId, payload); return db.transaction(async (tx) => { const [createdTopic] = await tx .insert(crmQuotationTopics) .values({ id: crypto.randomUUID(), organizationId, quotationId, topicType: payload.topicType, title: payload.title.trim(), sortOrder: payload.sortOrder ?? 0 }) .returning(); if (payload.items.length) { await tx.insert(crmQuotationTopicItems).values( payload.items.map((item, index) => ({ id: crypto.randomUUID(), organizationId, topicId: createdTopic.id, content: item.content.trim(), sortOrder: item.sortOrder ?? index })) ); } return createdTopic; }); } export async function updateQuotationTopic( quotationId: string, topicId: string, organizationId: string, payload: QuotationTopicMutationPayload ) { await assertQuotationTopicBelongsToQuotation(quotationId, topicId, organizationId); await validateQuotationTopicPayload(organizationId, payload); return db.transaction(async (tx) => { const [updatedTopic] = await tx .update(crmQuotationTopics) .set({ topicType: payload.topicType, title: payload.title.trim(), sortOrder: payload.sortOrder ?? 0, updatedAt: new Date() }) .where(eq(crmQuotationTopics.id, topicId)) .returning(); await tx .update(crmQuotationTopicItems) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where( and( eq(crmQuotationTopicItems.topicId, topicId), eq(crmQuotationTopicItems.organizationId, organizationId), isNull(crmQuotationTopicItems.deletedAt) ) ); if (payload.items.length) { await tx.insert(crmQuotationTopicItems).values( payload.items.map((item, index) => ({ id: crypto.randomUUID(), organizationId, topicId, content: item.content.trim(), sortOrder: item.sortOrder ?? index })) ); } return updatedTopic; }); } export async function softDeleteQuotationTopic( quotationId: string, topicId: string, organizationId: string ) { await assertQuotationTopicBelongsToQuotation(quotationId, topicId, organizationId); const now = new Date(); const [updated] = await db .update(crmQuotationTopics) .set({ deletedAt: now, updatedAt: now }) .where(eq(crmQuotationTopics.id, topicId)) .returning(); await db .update(crmQuotationTopicItems) .set({ deletedAt: now, updatedAt: now }) .where( and( eq(crmQuotationTopicItems.topicId, topicId), eq(crmQuotationTopicItems.organizationId, organizationId) ) ); return updated; } export async function listQuotationFollowups(id: string, organizationId: string) { await assertQuotationBelongsToOrganization(id, organizationId); const rows = await db .select() .from(crmQuotationFollowups) .where( and( eq(crmQuotationFollowups.quotationId, id), eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt) ) ) .orderBy(desc(crmQuotationFollowups.followupDate), desc(crmQuotationFollowups.createdAt)); return rows.map(mapQuotationFollowupRecord); } export async function createQuotationFollowup( quotationId: string, organizationId: string, userId: string, payload: QuotationFollowupMutationPayload ) { await validateQuotationFollowupPayload(quotationId, organizationId, payload); const [created] = await db .insert(crmQuotationFollowups) .values({ id: crypto.randomUUID(), organizationId, quotationId, followupDate: new Date(payload.followupDate), followupType: payload.followupType, contactId: payload.contactId ?? null, outcome: payload.outcome?.trim() || null, notes: payload.notes?.trim() || null, nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null, nextAction: payload.nextAction?.trim() || null, createdBy: userId, updatedBy: userId }) .returning(); return created; } export async function updateQuotationFollowup( quotationId: string, followupId: string, organizationId: string, userId: string, payload: QuotationFollowupMutationPayload ) { await assertQuotationFollowupBelongsToQuotation(quotationId, followupId, organizationId); await validateQuotationFollowupPayload(quotationId, organizationId, payload); const [updated] = await db .update(crmQuotationFollowups) .set({ followupDate: new Date(payload.followupDate), followupType: payload.followupType, contactId: payload.contactId ?? null, outcome: payload.outcome?.trim() || null, notes: payload.notes?.trim() || null, nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null, nextAction: payload.nextAction?.trim() || null, updatedAt: new Date(), updatedBy: userId }) .where(eq(crmQuotationFollowups.id, followupId)) .returning(); return updated; } export async function softDeleteQuotationFollowup( quotationId: string, followupId: string, organizationId: string, userId: string ) { await assertQuotationFollowupBelongsToQuotation(quotationId, followupId, organizationId); const [updated] = await db .update(crmQuotationFollowups) .set({ deletedAt: new Date(), updatedAt: new Date(), updatedBy: userId }) .where(eq(crmQuotationFollowups.id, followupId)) .returning(); return updated; } export async function listQuotationAttachments(id: string, organizationId: string) { await assertQuotationBelongsToOrganization(id, organizationId); const rows = await db .select() .from(crmQuotationAttachments) .where( and( eq(crmQuotationAttachments.quotationId, id), eq(crmQuotationAttachments.organizationId, organizationId), isNull(crmQuotationAttachments.deletedAt) ) ) .orderBy(desc(crmQuotationAttachments.uploadedAt)); return rows.map(mapQuotationAttachmentRecord); } export async function createQuotationAttachment( quotationId: string, organizationId: string, userId: string, payload: QuotationAttachmentMutationPayload ) { await assertQuotationBelongsToOrganization(quotationId, organizationId); const [created] = await db .insert(crmQuotationAttachments) .values({ id: crypto.randomUUID(), organizationId, quotationId, fileName: payload.fileName.trim(), originalFileName: payload.originalFileName.trim(), filePath: payload.filePath.trim(), fileSize: payload.fileSize ?? null, fileType: payload.fileType ?? null, description: payload.description?.trim() || null, uploadedBy: userId }) .returning(); return created; } export async function updateQuotationAttachment( quotationId: string, attachmentId: string, organizationId: string, payload: QuotationAttachmentMutationPayload ) { await assertQuotationAttachmentBelongsToQuotation(quotationId, attachmentId, organizationId); const [updated] = await db .update(crmQuotationAttachments) .set({ fileName: payload.fileName.trim(), originalFileName: payload.originalFileName.trim(), filePath: payload.filePath.trim(), fileSize: payload.fileSize ?? null, fileType: payload.fileType ?? null, description: payload.description?.trim() || null, updatedAt: new Date() }) .where(eq(crmQuotationAttachments.id, attachmentId)) .returning(); return updated; } export async function softDeleteQuotationAttachment( quotationId: string, attachmentId: string, organizationId: string ) { await assertQuotationAttachmentBelongsToQuotation(quotationId, attachmentId, organizationId); const [updated] = await db .update(crmQuotationAttachments) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(crmQuotationAttachments.id, attachmentId)) .returning(); return updated; } export async function createQuotationRevision( quotationId: string, organizationId: string, userId: string, revisionRemark?: string ) { const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId); await assertRevisionOpportunityLinkIntegrity({ organizationId, quotationId }); const statusCode = await resolveOptionCodeById( organizationId, QUOTATION_OPTION_CATEGORIES.status, parent.status ); if (!statusCode || !REVISION_ALLOWED_STATUS_CODES.has(statusCode)) { throw new AuthError( 'Revision is allowed only for approved, sent, accepted, or rejected quotations', 400 ); } const revisedStatusId = (await resolveOptionIdByCode(organizationId, QUOTATION_OPTION_CATEGORIES.status, 'revised')) ?? parent.status; const familyRootId = parent.parentQuotationId ?? parent.id; const familyRows = await db .select() .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt), or(eq(crmQuotations.id, familyRootId), eq(crmQuotations.parentQuotationId, familyRootId))! ) ) .orderBy(desc(crmQuotations.revision)); const nextRevision = (familyRows[0]?.revision ?? parent.revision) + 1; const currentItems = await listQuotationItems(quotationId, organizationId); const currentCustomers = await listQuotationCustomers(quotationId, organizationId); const currentTopics = await listQuotationTopics(quotationId, organizationId); const documentCode = await generateNextDocumentCode({ organizationId, documentType: 'quotation', branchId: parent.branchId ?? '' }); return db.transaction(async (tx) => { const [created] = await tx .insert(crmQuotations) .values({ id: crypto.randomUUID(), organizationId, branchId: parent.branchId, code: documentCode.code, opportunityId: parent.opportunityId, customerId: parent.customerId, contactId: parent.contactId, quotationDate: new Date(), validUntil: parent.validUntil, quotationType: parent.quotationType, projectName: parent.projectName, projectLocation: parent.projectLocation, attention: parent.attention, reference: parent.reference, notes: parent.notes, status: revisedStatusId, revision: nextRevision, parentQuotationId: familyRootId, revisionRemark: revisionRemark?.trim() || null, currency: parent.currency, exchangeRate: parent.exchangeRate, subtotal: parent.subtotal, discount: parent.discount, discountType: parent.discountType, taxRate: parent.taxRate, taxAmount: parent.taxAmount, totalAmount: parent.totalAmount, chancePercent: parent.chancePercent, isHotProject: parent.isHotProject, competitor: parent.competitor, salesmanId: parent.salesmanId, isSent: false, sentVia: parent.sentVia, approvedAt: null, approvedArtifactId: null, approvedPdfUrl: null, approvedSnapshot: null, approvedTemplateVersionId: null, isActive: parent.isActive, createdBy: userId, updatedBy: userId }) .returning(); if (currentItems.length) { await tx.insert(crmQuotationItems).values( currentItems.map((item) => ({ id: crypto.randomUUID(), organizationId, quotationId: created.id, itemNumber: item.itemNumber, productType: item.productType, description: item.description, quantity: item.quantity, unit: item.unit, unitPrice: item.unitPrice, discount: item.discount, discountType: item.discountType, taxRate: item.taxRate, totalPrice: item.totalPrice, notes: item.notes, sortOrder: item.sortOrder })) ); } if (currentCustomers.length) { await tx.insert(crmQuotationCustomers).values( currentCustomers.map((item) => ({ id: crypto.randomUUID(), organizationId, quotationId: created.id, customerId: item.customerId, role: item.role, isPrimary: item.isPrimary, remark: item.remark })) ); } for (const topic of currentTopics) { const [createdTopic] = await tx .insert(crmQuotationTopics) .values({ id: crypto.randomUUID(), organizationId, quotationId: created.id, topicType: topic.topicType, title: topic.title, sortOrder: topic.sortOrder }) .returning(); if (topic.items.length) { await tx.insert(crmQuotationTopicItems).values( topic.items.map((item) => ({ id: crypto.randomUUID(), organizationId, topicId: createdTopic.id, content: item.content, sortOrder: item.sortOrder })) ); } } if (created.opportunityId) { await auditQuotationOpportunityLink({ organizationId, userId, quotationId: created.id, opportunityId: created.opportunityId, previousOpportunityId: parent.opportunityId, reason: 'quotation_revision_created' }); await syncOpportunityFromQuotationCreated({ organizationId, userId, opportunityId: created.opportunityId, quotationId: created.id }); } return created; }); } export async function listQuotationRevisions(id: string, organizationId: string) { const quotation = await assertQuotationBelongsToOrganization(id, organizationId); const familyRootId = quotation.parentQuotationId ?? quotation.id; const rows = await db .select() .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt), or(eq(crmQuotations.id, familyRootId), eq(crmQuotations.parentQuotationId, familyRootId))! ) ) .orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt)); return rows.map((row) => ({ id: row.id, code: row.code, status: row.status, revision: row.revision, quotationType: row.quotationType, totalAmount: row.totalAmount, currency: row.currency, validUntil: row.validUntil?.toISOString() ?? null, approvedAt: row.approvedAt?.toISOString() ?? null, acceptedAt: row.acceptedAt?.toISOString() ?? null, rejectedAt: row.rejectedAt?.toISOString() ?? null, rejectionReason: row.rejectionReason, updatedAt: row.updatedAt.toISOString() })); } export async function listOpportunityQuotationRelations( opportunityId: string, organizationId: string ): Promise { const rows = await db .select() .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, organizationId), eq(crmQuotations.opportunityId, opportunityId), isNull(crmQuotations.deletedAt) ) ) .orderBy(desc(crmQuotations.updatedAt)); return rows.map((row) => ({ id: row.id, code: row.code, status: row.status, revision: row.revision, quotationType: row.quotationType, totalAmount: row.totalAmount, currency: row.currency, validUntil: row.validUntil?.toISOString() ?? null, approvedAt: row.approvedAt?.toISOString() ?? null, acceptedAt: row.acceptedAt?.toISOString() ?? null, rejectedAt: row.rejectedAt?.toISOString() ?? null, rejectionReason: row.rejectionReason, updatedAt: row.updatedAt.toISOString() })); } export async function listCustomerQuotationRelations( customerId: string, organizationId: string, accessContext?: QuotationAccessContext ): Promise { const rows = await db .select() .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, organizationId), eq(crmQuotations.customerId, customerId), isNull(crmQuotations.deletedAt) ) ) .orderBy(desc(crmQuotations.updatedAt)); return rows .filter((row) => !accessContext || canAccessQuotationRow(row, accessContext)) .map((row) => ({ id: row.id, code: row.code, status: row.status, revision: row.revision, quotationType: row.quotationType, totalAmount: row.totalAmount, currency: row.currency, validUntil: row.validUntil?.toISOString() ?? null, approvedAt: row.approvedAt?.toISOString() ?? null, acceptedAt: row.acceptedAt?.toISOString() ?? null, rejectedAt: row.rejectedAt?.toISOString() ?? null, rejectionReason: row.rejectionReason, updatedAt: row.updatedAt.toISOString() })); }