1851 lines
55 KiB
TypeScript
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()
|
|
}));
|
|
}
|