Files
alla-allaos-fullstack/src/features/crm/quotations/server/service.ts
2026-06-27 22:14:16 +07:00

2469 lines
74 KiB
TypeScript

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