task-d.5.7

This commit is contained in:
phaichayon
2026-06-25 15:41:59 +07:00
parent 65dd0e2e56
commit 69279fc341
9 changed files with 1111 additions and 29 deletions

View File

@@ -26,6 +26,15 @@ import {
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
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,
@@ -1297,12 +1306,11 @@ export async function createQuotation(
payload.status
);
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
if (opportunity) {
assertOpportunityOpenForQuotation(opportunity);
}
const opportunity = await assertOpportunityLinkIntegrity({
organizationId,
opportunityId: payload.opportunityId,
customerId: payload.customerId
});
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
@@ -1362,6 +1370,19 @@ export async function createQuotation(
);
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,
@@ -1387,9 +1408,11 @@ export async function updateQuotation(
payload.status
);
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
const opportunity = await assertOpportunityLinkIntegrity({
organizationId,
opportunityId: payload.opportunityId,
customerId: payload.customerId
});
const updated = await db.transaction(async (tx) => {
const [row] = await tx
@@ -1435,11 +1458,26 @@ export async function updateQuotation(
);
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;
@@ -2125,6 +2163,7 @@ export async function createQuotationRevision(
revisionRemark?: string
) {
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
await assertRevisionOpportunityLinkIntegrity({ organizationId, quotationId });
const statusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
@@ -2273,6 +2312,23 @@ export async function createQuotationRevision(
}
}
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;
});
}
@@ -2292,14 +2348,21 @@ export async function listQuotationRevisions(id: string, organizationId: string)
)
.orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt));
return rows.map<QuotationRelationItem>((row) => ({
id: row.id,
code: row.code,
status: row.status,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt.toISOString()
}));
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(
@@ -2318,14 +2381,21 @@ export async function listOpportunityQuotationRelations(
)
.orderBy(desc(crmQuotations.updatedAt));
return rows.map((row) => ({
id: row.id,
code: row.code,
status: row.status,
quotationType: row.quotationType,
totalAmount: row.totalAmount,
updatedAt: row.updatedAt.toISOString()
}));
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(
@@ -2351,8 +2421,15 @@ export async function listCustomerQuotationRelations(
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()
}));
}