This commit is contained in:
phaichayon
2026-06-16 08:43:19 +07:00
parent eae0dee1f2
commit b8f13e36b3
12 changed files with 547 additions and 21 deletions

View File

@@ -61,14 +61,17 @@ import type {
const QUOTATION_OPTION_CATEGORIES = {
status: 'crm_quotation_status',
quotationType: 'crm_quotation_type',
currency: 'currency',
currency: 'crm_currency',
discountType: 'crm_discount_type',
topicType: 'crm_topic_type',
unit: 'crm_unit',
sentVia: 'crm_sent_via',
customerRole: 'crm_quotation_customer_role',
topicType: 'crm_quotation_topic_type',
followupType: 'crm_followup_type',
productType: 'crm_product_type'
} as const;
const QUOTATION_CUSTOMER_ROLES = ['owner', 'consultant', 'contractor', 'billing'] as const;
const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);
function mapOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
@@ -291,7 +294,7 @@ function calculateDiscountAmount(baseAmount: number, discount = 0, discountType?
return 0;
}
if (discountType === 'percent') {
if (discountType === 'percent' || discountType === 'percentage') {
return (baseAmount * discount) / 100;
}
@@ -314,6 +317,28 @@ async function resolveValidOptionIds(category: string, organizationId: string) {
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,
@@ -591,6 +616,11 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
QUOTATION_OPTION_CATEGORIES.discountType,
payload.discountType ?? null
);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.sentVia,
payload.sentVia ?? null
);
}
async function validateQuotationItemPayload(
@@ -607,6 +637,11 @@ async function validateQuotationItemPayload(
QUOTATION_OPTION_CATEGORIES.discountType,
payload.discountType ?? null
);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.unit,
payload.unit ?? null
);
}
async function validateQuotationCustomerPayload(
@@ -614,10 +649,11 @@ async function validateQuotationCustomerPayload(
payload: QuotationCustomerMutationPayload
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
if (!QUOTATION_CUSTOMER_ROLES.includes(payload.role as (typeof QUOTATION_CUSTOMER_ROLES)[number])) {
throw new AuthError('Invalid quotation customer role', 400);
}
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.customerRole,
payload.role
);
}
async function validateQuotationTopicPayload(
@@ -739,6 +775,9 @@ export async function getQuotationReferenceData(
quotationTypes,
currencies,
discountTypes,
units,
sentVias,
customerRoles,
topicTypes,
followupTypes,
productTypes,
@@ -752,6 +791,9 @@ export async function getQuotationReferenceData(
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.customerRole, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
@@ -789,6 +831,9 @@ export async function getQuotationReferenceData(
quotationTypes: quotationTypes.map(mapOption),
currencies: currencies.map(mapOption),
discountTypes: discountTypes.map(mapOption),
units: units.map(mapOption),
sentVias: sentVias.map(mapOption),
customerRoles: customerRoles.map(mapOption),
topicTypes: topicTypes.map(mapOption),
followupTypes: followupTypes.map(mapOption),
productTypes: productTypes.map(mapOption),
@@ -1642,6 +1687,25 @@ export async function createQuotationRevision(
revisionRemark?: string
) {
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
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()
@@ -1683,7 +1747,7 @@ export async function createQuotationRevision(
attention: parent.attention,
reference: parent.reference,
notes: parent.notes,
status: parent.status,
status: revisedStatusId,
revision: nextRevision,
parentQuotationId: familyRootId,
revisionRemark: revisionRemark?.trim() || null,