This commit is contained in:
phaichayon
2026-06-16 17:01:29 +07:00
parent 90ee59d388
commit 0a484e0b45
28 changed files with 6483 additions and 277 deletions

View File

@@ -1,16 +1,6 @@
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
type SQL
} from 'drizzle-orm';
import {
crmDocumentArtifacts,
crmCustomerContacts,
crmCustomers,
crmEnquiries,
@@ -34,6 +24,7 @@ import type {
QuotationActivityRecord,
QuotationAttachmentMutationPayload,
QuotationAttachmentRecord,
QuotationApprovedArtifactSummary,
QuotationBranchOption,
QuotationContactLookup,
QuotationCustomerListItem,
@@ -95,7 +86,30 @@ function mapBranch(
};
}
function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRecord {
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 }
): QuotationRecord {
return {
id: row.id,
organizationId: row.organizationId,
@@ -132,9 +146,15 @@ function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRe
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,
@@ -334,11 +354,7 @@ async function resolveOptionCodeById(
return options.find((option) => option.id === optionId)?.code ?? null;
}
async function resolveOptionIdByCode(
organizationId: string,
category: string,
code: string
) {
async function resolveOptionIdByCode(organizationId: string, category: string, code: string) {
const options = await getActiveOptionsByCategory(category, { organizationId });
return options.find((option) => option.code === code)?.id ?? null;
}
@@ -724,7 +740,11 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters
];
}
async function refreshQuotationTotals(quotationId: string, organizationId: string, userId?: string) {
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);
@@ -903,7 +923,9 @@ export async function listQuotations(
contactIds.length
? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds))
: [],
enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [],
enquiryIds.length
? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds))
: [],
quotationIds.length
? db
.select({ quotationId: crmQuotationItems.quotationId, value: count() })
@@ -942,7 +964,47 @@ export async function getQuotationDetail(
organizationId: string
): Promise<QuotationRecord> {
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
return mapQuotationRecord(quotation);
const approvedArtifactId =
quotation.approvedArtifactId ??
(quotation.approvedPdfUrl?.startsWith('artifact:')
? quotation.approvedPdfUrl.replace('artifact:', '')
: null);
if (!approvedArtifactId) {
return mapQuotationRecord(quotation);
}
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 relatedUserIds = [artifact.generatedBy, artifact.lockedBy].filter(Boolean) as string[];
const actorRows = relatedUserIds.length
? await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, relatedUserIds))
: [];
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
return mapQuotationRecord(quotation, {
approvedArtifact: mapApprovedArtifactSummary(artifact, {
generatedByName: actorMap.get(artifact.generatedBy) ?? null,
lockedByName: artifact.lockedBy ? (actorMap.get(artifact.lockedBy) ?? null) : null
})
});
}
export async function getQuotationActivity(
@@ -961,11 +1023,15 @@ export async function getQuotationActivity(
log.entityType === 'crm_quotation_customer' ||
log.entityType === 'crm_quotation_topic' ||
log.entityType === 'crm_quotation_followup' ||
log.entityType === 'crm_quotation_attachment'
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))
? 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]));
@@ -1029,6 +1095,7 @@ export async function createQuotation(
isSent: false,
sentVia: payload.sentVia ?? null,
approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,
@@ -1119,23 +1186,48 @@ export async function softDeleteQuotation(id: string, organizationId: string, us
db
.update(crmQuotationItems)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationItems.quotationId, id), eq(crmQuotationItems.organizationId, organizationId))),
.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))),
.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))),
.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))),
.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)))
.where(
and(
eq(crmQuotationAttachments.quotationId, id),
eq(crmQuotationAttachments.organizationId, organizationId)
)
)
]);
return updated;
@@ -1506,7 +1598,12 @@ export async function softDeleteQuotationTopic(
await db
.update(crmQuotationTopicItems)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(crmQuotationTopicItems.topicId, topicId), eq(crmQuotationTopicItems.organizationId, organizationId)));
.where(
and(
eq(crmQuotationTopicItems.topicId, topicId),
eq(crmQuotationTopicItems.organizationId, organizationId)
)
);
return updated;
}
@@ -1708,11 +1805,8 @@ export async function createQuotationRevision(
);
}
const revisedStatusId =
(await resolveOptionIdByCode(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
'revised'
)) ?? parent.status;
(await resolveOptionIdByCode(organizationId, QUOTATION_OPTION_CATEGORIES.status, 'revised')) ??
parent.status;
const familyRootId = parent.parentQuotationId ?? parent.id;
const familyRows = await db
@@ -1774,6 +1868,7 @@ export async function createQuotationRevision(
isSent: false,
sentVia: parent.sentVia,
approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,