import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm'; import { crmCustomerContacts, crmCustomers, crmEnquiries, crmQuotationAttachments, crmQuotationCustomers, crmQuotationFollowups, crmQuotationItems, crmQuotationTopicItems, crmQuotationTopics, crmQuotations, memberships, users } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import type { QuotationActivityRecord, QuotationAttachmentMutationPayload, QuotationAttachmentRecord, QuotationBranchOption, QuotationContactLookup, QuotationCustomerListItem, QuotationCustomerLookup, QuotationCustomerMutationPayload, QuotationCustomerRecord, QuotationEnquiryLookup, QuotationFilters, QuotationFollowupMutationPayload, QuotationFollowupRecord, QuotationItemMutationPayload, QuotationItemRecord, QuotationListItem, QuotationMutationPayload, QuotationOption, QuotationRecord, QuotationReferenceData, QuotationRelationItem, QuotationSalesmanLookup, QuotationTopicItemRecord, QuotationTopicMutationPayload, QuotationTopicRecord } from '../api/types'; const QUOTATION_OPTION_CATEGORIES = { status: 'crm_quotation_status', quotationType: 'crm_quotation_type', currency: 'currency', discountType: 'crm_discount_type', topicType: 'crm_topic_type', followupType: 'crm_followup_type', productType: 'crm_product_type' } as const; const QUOTATION_CUSTOMER_ROLES = ['owner', 'consultant', 'contractor', 'billing'] as const; 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 mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRecord { return { id: row.id, organizationId: row.organizationId, branchId: row.branchId, code: row.code, enquiryId: row.enquiryId, 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, isSent: row.isSent, sentAt: row.sentAt?.toISOString() ?? null, sentVia: row.sentVia, 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, updatedBy: row.updatedBy }; } 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 ): QuotationCustomerRecord { return { id: row.id, organizationId: row.organizationId, quotationId: row.quotationId, customerId: row.customerId, role: row.role, isPrimary: row.isPrimary, 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') { 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 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 assertEnquiryBelongsToOrganization(id: string, organizationId: string) { const [enquiry] = await db .select() .from(crmEnquiries) .where( and( eq(crmEnquiries.id, id), eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt) ) ) .limit(1); if (!enquiry) { throw new AuthError('Enquiry not found', 404); } return enquiry; } async function assertQuotationBelongsToOrganization(id: string, organizationId: string) { 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); } return quotation; } 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) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); if (payload.contactId) { await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId); } if (payload.enquiryId) { const enquiry = await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId); if (enquiry.customerId !== payload.customerId) { throw new AuthError('Selected enquiry belongs to a different customer', 400); } } if (payload.branchId) { await validateBranchAccess(payload.branchId); } 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 ); } 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 ); } async function validateQuotationCustomerPayload( organizationId: string, payload: QuotationCustomerMutationPayload ) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); if (!QUOTATION_CUSTOMER_ROLES.includes(payload.role as (typeof QUOTATION_CUSTOMER_ROLES)[number])) { throw new AuthError('Invalid quotation customer role', 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): SQL[] { const statuses = splitFilterValue(filters.status); const quotationTypes = splitFilterValue(filters.quotationType); const branches = splitFilterValue(filters.branch); const customers = splitFilterValue(filters.customer); const enquiries = splitFilterValue(filters.enquiry); return [ eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt), ...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []), ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []), ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []), ...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []), ...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []), ...(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, topicTypes, followupTypes, productTypes, customers, contacts, enquiries, 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.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(crmEnquiries) .where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))) .orderBy(desc(crmEnquiries.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), 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 })), enquiries: enquiries.map((enquiry) => ({ id: enquiry.id, code: enquiry.code, customerId: enquiry.customerId, contactId: enquiry.contactId, title: enquiry.title, projectName: enquiry.projectName, projectLocation: enquiry.projectLocation, branchId: enquiry.branchId })), salesmen: salesmen.map((salesman) => ({ id: salesman.id, name: salesman.name })) }; } export async function listQuotations( organizationId: string, filters: QuotationFilters ): Promise<{ items: QuotationListItem[]; totalItems: number }> { const page = filters.page ?? 1; const limit = filters.limit ?? 10; const whereFilters = buildQuotationFilters(organizationId, filters); 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 enquiryIds = [...new Set(rows.map((row) => row.enquiryId).filter(Boolean))] as string[]; const quotationIds = rows.map((row) => row.id); const [customers, contacts, enquiries, 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)) : [], enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [], 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 enquiryMap = new Map(enquiries.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, enquiryCode: row.enquiryId ? (enquiryMap.get(row.enquiryId)?.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 ): Promise { const quotation = await assertQuotationBelongsToOrganization(id, organizationId); return mapQuotationRecord(quotation); } 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' ); 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 ) { await validateQuotationPayload(organizationId, payload); const enquiry = payload.enquiryId ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) : null; const documentCode = await generateNextDocumentCode({ organizationId, documentType: 'quotation', branchId: payload.branchId ?? enquiry?.branchId ?? '' }); const [created] = await db .insert(crmQuotations) .values({ id: crypto.randomUUID(), organizationId, branchId: payload.branchId ?? enquiry?.branchId ?? null, code: documentCode.code, enquiryId: payload.enquiryId ?? null, customerId: payload.customerId, contactId: payload.contactId ?? enquiry?.contactId ?? null, quotationDate: new Date(payload.quotationDate), validUntil: payload.validUntil ? new Date(payload.validUntil) : null, quotationType: payload.quotationType, projectName: payload.projectName?.trim() || enquiry?.projectName || null, projectLocation: payload.projectLocation?.trim() || enquiry?.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 ?? enquiry?.chancePercent ?? null, isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false, competitor: payload.competitor?.trim() || enquiry?.competitor || null, salesmanId: payload.salesmanId ?? null, isSent: false, sentVia: payload.sentVia ?? null, isActive: payload.isActive ?? true, createdBy: userId, updatedBy: userId }) .returning(); await db.insert(crmQuotationCustomers).values({ id: crypto.randomUUID(), organizationId, quotationId: created.id, customerId: created.customerId, role: 'owner', isPrimary: true }); return created; } export async function updateQuotation( id: string, organizationId: string, userId: string, payload: QuotationMutationPayload ) { await validateQuotationPayload(organizationId, payload); const existing = await assertQuotationBelongsToOrganization(id, organizationId); const enquiry = payload.enquiryId ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) : null; const [updated] = await db .update(crmQuotations) .set({ branchId: payload.branchId ?? enquiry?.branchId ?? null, enquiryId: payload.enquiryId ?? null, customerId: payload.customerId, contactId: payload.contactId ?? enquiry?.contactId ?? null, quotationDate: new Date(payload.quotationDate), validUntil: payload.validUntil ? new Date(payload.validUntil) : null, quotationType: payload.quotationType, projectName: payload.projectName?.trim() || enquiry?.projectName || null, projectLocation: payload.projectLocation?.trim() || enquiry?.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 ?? enquiry?.chancePercent ?? null, isHotProject: payload.isHotProject ?? false, competitor: payload.competitor?.trim() || enquiry?.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 refreshQuotationTotals(id, organizationId, userId); return updated; } export async function softDeleteQuotation(id: string, organizationId: string, userId: string) { await assertQuotationBelongsToOrganization(id, organizationId); 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 = await db .select() .from(crmQuotationCustomers) .where( and( eq(crmQuotationCustomers.quotationId, id), eq(crmQuotationCustomers.organizationId, organizationId), isNull(crmQuotationCustomers.deletedAt) ) ) .orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.role)); 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) => ({ ...mapQuotationCustomerRecord(row), customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', customerCode: customerMap.get(row.customerId)?.code ?? '-' })); } export async function createQuotationCustomer( quotationId: string, organizationId: string, payload: QuotationCustomerMutationPayload ) { await assertQuotationBelongsToOrganization(quotationId, organizationId); await validateQuotationCustomerPayload(organizationId, payload); return db.transaction(async (tx) => { if (payload.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: payload.role, isPrimary: payload.isPrimary ?? false }) .returning(); return created; }); } export async function updateQuotationCustomer( quotationId: string, relationId: string, organizationId: string, payload: QuotationCustomerMutationPayload ) { await validateQuotationCustomerPayload(organizationId, payload); await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId); return db.transaction(async (tx) => { if (payload.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: payload.role, isPrimary: payload.isPrimary ?? false, 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); 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, enquiryId: parent.enquiryId, 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: parent.status, 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, 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 })) ); } 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 })) ); } } 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, quotationType: row.quotationType, totalAmount: row.totalAmount, updatedAt: row.updatedAt.toISOString() })); } export async function listEnquiryQuotationRelations( enquiryId: string, organizationId: string ): Promise { const rows = await db .select() .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, organizationId), eq(crmQuotations.enquiryId, enquiryId), isNull(crmQuotations.deletedAt) ) ) .orderBy(desc(crmQuotations.updatedAt)); return rows.map((row) => ({ id: row.id, code: row.code, status: row.status, quotationType: row.quotationType, totalAmount: row.totalAmount, updatedAt: row.updatedAt.toISOString() })); } export async function listCustomerQuotationRelations( customerId: string, organizationId: string ): 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.map((row) => ({ id: row.id, code: row.code, status: row.status, quotationType: row.quotationType, totalAmount: row.totalAmount, updatedAt: row.updatedAt.toISOString() })); }