2469 lines
74 KiB
TypeScript
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()
|
|
}));
|
|
}
|