Files
alla-allaos-fullstack/src/features/crm/quotations/server/service.ts
phaichayon eae0dee1f2 task-e
2026-06-15 16:34:41 +07:00

1851 lines
55 KiB
TypeScript

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<ReturnType<typeof getActiveOptionsByCategory>>[number]
): QuotationOption {
return {
id: option.id,
code: option.code,
label: option.label,
value: option.value
};
}
function mapBranch(
branch: Awaited<ReturnType<typeof getUserBranches>>[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<QuotationReferenceData> {
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<QuotationCustomerLookup>((customer) => ({
id: customer.id,
code: customer.code,
name: customer.name,
branchId: customer.branchId
})),
contacts: contacts.map<QuotationContactLookup>((contact) => ({
id: contact.id,
customerId: contact.customerId,
name: contact.name,
email: contact.email,
mobile: contact.mobile,
isPrimary: contact.isPrimary
})),
enquiries: enquiries.map<QuotationEnquiryLookup>((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<QuotationSalesmanLookup>((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<QuotationRecord> {
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
return mapQuotationRecord(quotation);
}
export async function getQuotationActivity(
id: string,
organizationId: string
): Promise<QuotationActivityRecord[]> {
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<QuotationCustomerListItem>((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<string, QuotationTopicItemRecord[]>();
for (const item of topicItems.map(mapQuotationTopicItemRecord)) {
const items = topicItemMap.get(item.topicId) ?? [];
items.push(item);
topicItemMap.set(item.topicId, items);
}
return topics.map<QuotationTopicRecord>((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<QuotationRelationItem>((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<QuotationRelationItem[]> {
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<QuotationRelationItem[]> {
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()
}));
}