taks-d.2.1

This commit is contained in:
phaichayon
2026-06-17 14:46:52 +07:00
parent 0a484e0b45
commit 5be6c54272
39 changed files with 6254 additions and 316 deletions

View File

@@ -48,6 +48,11 @@ import type {
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',
@@ -56,7 +61,7 @@ const QUOTATION_OPTION_CATEGORIES = {
discountType: 'crm_discount_type',
unit: 'crm_unit',
sentVia: 'crm_sent_via',
customerRole: 'crm_quotation_customer_role',
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY,
topicType: 'crm_quotation_topic_type',
followupType: 'crm_followup_type',
productType: 'crm_product_type'
@@ -191,15 +196,19 @@ function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): Quo
}
function mapQuotationCustomerRecord(
row: typeof crmQuotationCustomers.$inferSelect
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: row.role,
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
@@ -641,6 +650,15 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
QUOTATION_OPTION_CATEGORIES.sentVia,
payload.sentVia ?? null
);
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
projectParty.role
);
}
}
async function validateQuotationItemPayload(
@@ -671,11 +689,156 @@ async function validateQuotationCustomerPayload(
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.customerRole,
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
@@ -801,7 +964,7 @@ export async function getQuotationReferenceData(
discountTypes,
units,
sentVias,
customerRoles,
projectPartyRoles,
topicTypes,
followupTypes,
productTypes,
@@ -817,7 +980,7 @@ export async function getQuotationReferenceData(
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.customerRole, { 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 }),
@@ -857,7 +1020,7 @@ export async function getQuotationReferenceData(
discountTypes: discountTypes.map(mapOption),
units: units.map(mapOption),
sentVias: sentVias.map(mapOption),
customerRoles: customerRoles.map(mapOption),
projectPartyRoles: projectPartyRoles.map(mapOption),
topicTypes: topicTypes.map(mapOption),
followupTypes: followupTypes.map(mapOption),
productTypes: productTypes.map(mapOption),
@@ -1062,59 +1225,60 @@ export async function createQuotation(
branchId: payload.branchId ?? enquiry?.branchId ?? ''
});
const [created] = await db
.insert(crmQuotations)
.values({
id: crypto.randomUUID(),
return db.transaction(async (tx) => {
const [created] = await tx
.insert(crmQuotations)
.values({
id: crypto.randomUUID(),
organizationId,
branchId: payload.branchId ?? enquiry?.branchId ?? null,
code: documentCode.code,
enquiryId: payload.enquiryId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
status: payload.status,
revision: 0,
revisionRemark: payload.revisionRemark?.trim() || null,
currency: payload.currency,
exchangeRate: payload.exchangeRate ?? 1,
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
salesmanId: payload.salesmanId ?? null,
isSent: false,
sentVia: payload.sentVia ?? null,
approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
await syncQuotationProjectParties(
tx,
organizationId,
branchId: payload.branchId ?? enquiry?.branchId ?? null,
code: documentCode.code,
enquiryId: payload.enquiryId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
status: payload.status,
revision: 0,
revisionRemark: payload.revisionRemark?.trim() || null,
currency: payload.currency,
exchangeRate: payload.exchangeRate ?? 1,
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
salesmanId: payload.salesmanId ?? null,
isSent: false,
sentVia: payload.sentVia ?? null,
approvedAt: null,
approvedArtifactId: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
created.id,
payload.customerId,
payload.projectParties ?? []
);
await db.insert(crmQuotationCustomers).values({
id: crypto.randomUUID(),
organizationId,
quotationId: created.id,
customerId: created.customerId,
role: 'owner',
isPrimary: true
return created;
});
return created;
}
export async function updateQuotation(
@@ -1129,39 +1293,51 @@ export async function updateQuotation(
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
: null;
const [updated] = await db
.update(crmQuotations)
.set({
branchId: payload.branchId ?? enquiry?.branchId ?? null,
enquiryId: payload.enquiryId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
status: payload.status,
revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
currency: payload.currency,
exchangeRate: payload.exchangeRate ?? 1,
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
salesmanId: payload.salesmanId ?? null,
sentVia: payload.sentVia ?? null,
isActive: payload.isActive ?? true,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmQuotations.id, id))
.returning();
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(crmQuotations)
.set({
branchId: payload.branchId ?? enquiry?.branchId ?? null,
enquiryId: payload.enquiryId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
status: payload.status,
revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
currency: payload.currency,
exchangeRate: payload.exchangeRate ?? 1,
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
salesmanId: payload.salesmanId ?? null,
sentVia: payload.sentVia ?? null,
isActive: payload.isActive ?? true,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmQuotations.id, id))
.returning();
await syncQuotationProjectParties(
tx,
organizationId,
id,
payload.customerId,
payload.projectParties ?? []
);
return row;
});
await refreshQuotationTotals(id, organizationId, userId);
return updated;
@@ -1341,28 +1517,41 @@ export async function softDeleteQuotationItem(
export async function listQuotationCustomers(id: string, organizationId: string) {
await assertQuotationBelongsToOrganization(id, organizationId);
const relations = await db
.select()
.from(crmQuotationCustomers)
.where(
and(
eq(crmQuotationCustomers.quotationId, id),
eq(crmQuotationCustomers.organizationId, organizationId),
isNull(crmQuotationCustomers.deletedAt)
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.role));
.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<QuotationCustomerListItem>((row) => ({
...mapQuotationCustomerRecord(row),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
customerCode: customerMap.get(row.customerId)?.code ?? '-'
}));
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(
@@ -1372,9 +1561,22 @@ export async function createQuotationCustomer(
) {
await assertQuotationBelongsToOrganization(quotationId, organizationId);
await validateQuotationCustomerPayload(organizationId, payload);
await assertQuotationProjectPartyNotDuplicated(quotationId, organizationId, payload);
return db.transaction(async (tx) => {
if (payload.isPrimary) {
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() })
@@ -1394,8 +1596,9 @@ export async function createQuotationCustomer(
organizationId,
quotationId,
customerId: payload.customerId,
role: payload.role,
isPrimary: payload.isPrimary ?? false
role: resolvedRole.id,
isPrimary,
remark: payload.remark?.trim() || null
})
.returning();
@@ -1411,9 +1614,27 @@ export async function updateQuotationCustomer(
) {
await validateQuotationCustomerPayload(organizationId, payload);
await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);
await assertQuotationProjectPartyNotDuplicated(
quotationId,
organizationId,
payload,
relationId
);
return db.transaction(async (tx) => {
if (payload.isPrimary) {
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() })
@@ -1430,8 +1651,9 @@ export async function updateQuotationCustomer(
.update(crmQuotationCustomers)
.set({
customerId: payload.customerId,
role: payload.role,
isPrimary: payload.isPrimary ?? false,
role: resolvedRole.id,
isPrimary,
remark: payload.remark?.trim() || null,
updatedAt: new Date()
})
.where(eq(crmQuotationCustomers.id, relationId))
@@ -1908,7 +2130,8 @@ export async function createQuotationRevision(
quotationId: created.id,
customerId: item.customerId,
role: item.role,
isPrimary: item.isPrimary
isPrimary: item.isPrimary,
remark: item.remark
}))
);
}