From 69279fc341ecba8af3b4113f35f7274a2fe695ff Mon Sep 17 00:00:00 2001 From: phaichayon Date: Thu, 25 Jun 2026 15:41:59 +0700 Subject: [PATCH] task-d.5.7 --- ...ty-quotation-synchronization-foundation.md | 65 +++ next-env.d.ts | 2 +- plans/task-d.5.7.md | 544 ++++++++++++++++++ .../components/opportunity-detail.tsx | 18 +- .../server/quotation-sync.service.ts | 244 ++++++++ src/features/crm/quotations/api/types.ts | 8 +- .../server/opportunity-link.service.ts | 97 ++++ src/features/crm/quotations/server/service.ts | 127 +++- .../foundation/approval/server/service.ts | 35 +- 9 files changed, 1111 insertions(+), 29 deletions(-) create mode 100644 docs/implementation/task-d57-opportunity-quotation-synchronization-foundation.md create mode 100644 plans/task-d.5.7.md create mode 100644 src/features/crm/opportunities/server/quotation-sync.service.ts create mode 100644 src/features/crm/quotations/server/opportunity-link.service.ts diff --git a/docs/implementation/task-d57-opportunity-quotation-synchronization-foundation.md b/docs/implementation/task-d57-opportunity-quotation-synchronization-foundation.md new file mode 100644 index 0000000..79be4ba --- /dev/null +++ b/docs/implementation/task-d57-opportunity-quotation-synchronization-foundation.md @@ -0,0 +1,65 @@ +# Task D.5.7 Opportunity / Quotation Synchronization Foundation + +## Summary + +- Reused `opportunityId` as the active quotation link field. +- Added service-side synchronization foundations instead of embedding sync logic in routes. +- Kept `Opportunity` as sales pipeline source of truth. +- Kept `Quotation` as commercial document source of truth. + +## New Service Foundations + +- `src/features/crm/opportunities/server/quotation-sync.service.ts` + - `syncOpportunityFromQuotationCreated()` + - `syncOpportunityFromQuotationApproval()` + - `syncOpportunityFromQuotationWon()` + - `syncOpportunityFromQuotationLost()` +- `src/features/crm/quotations/server/opportunity-link.service.ts` + - `assertOpportunityLinkIntegrity()` + - `assertRevisionOpportunityLinkIntegrity()` + - `auditQuotationOpportunityLink()` + +## Sync Trigger Map + +- Quotation created + - Opportunity stage moves to `quotation_created` + - Outcome stays `open` +- Quotation pending approval + - Opportunity stage stays `quotation_created` +- Quotation approved + - Opportunity stage moves to `negotiation` +- Quotation sent to customer + - Opportunity stage can move to `negotiation` +- Quotation won + - Opportunity stage becomes `closed` + - Opportunity outcome becomes `won` +- Quotation lost + - Opportunity closes as `lost` only when no other viable quotation remains + +## Guard Rules + +- Quotation cannot link to an opportunity outside the same organization. +- Closed opportunities cannot create new quotations. +- Quotation customer must match linked opportunity customer. +- Quotation product type must match linked opportunity product type. +- Revisions keep the same `opportunityId`. + +## Audit Actions + +- `sync_opportunity_from_quotation_created` +- `sync_opportunity_from_quotation_approved` +- `sync_opportunity_from_quotation_sent` +- `sync_opportunity_from_quotation_won` +- `sync_opportunity_from_quotation_lost` +- `sync_quotation_opportunity_link` + +## Current UI Integration + +- Opportunity detail now shows richer linked quotation context. +- Quotation detail linkage foundation remains ready for follow-up UI expansion. + +## Known Limitations + +- Manual quotation accept / reject operations are not fully exposed in UI yet. +- Opportunity detail shows linked quotations, but full quotation outcome highlighting can still be expanded. +- Quotation detail linked opportunity card can be extended further in a follow-up pass. diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/plans/task-d.5.7.md b/plans/task-d.5.7.md new file mode 100644 index 0000000..42a9826 --- /dev/null +++ b/plans/task-d.5.7.md @@ -0,0 +1,544 @@ +# Task D.5.7: Opportunity ↔ Quotation Synchronization Foundation + +## Status + +Planned + +## Objective + +Establish synchronization rules between Opportunity and Quotation after the Opportunity lifecycle foundation is in place. + +This task ensures that Opportunity stage and outcome are aligned with quotation creation, quotation approval, quotation acceptance, quotation rejection, revision, and future PO flow. + +Business flow: + +```txt +Lead → Opportunity → Quotation → Approval → Customer → PO / Lost +``` + +The objective is to make Opportunity the sales pipeline source of truth while keeping Quotation as the commercial document source of truth. + +--- + +## Mandatory Review + +Review before implementation: + +* `AGENTS.md` +* `plans/task-d.5.7.md` +* `docs/standards/project-foundations.md` +* `docs/standards/task-catalog.md` +* `docs/adr/0018-lead-enquiry-domain-separation.md` +* `docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md` +* `docs/implementation/task-d56-opportunity-lifecycle-won-lost-alignment.md` +* `docs/security/crm-authorization-boundaries.md` +* `src/db/schema.ts` +* `src/features/crm/opportunities/**` +* `src/features/crm/quotations/**` +* `src/features/crm/approval/**` +* `src/features/foundation/audit-log/**` + +--- + +## Current Foundation + +Already available: + +```txt +crm_leads +crm_opportunities +crm_opportunity_followups +crm_opportunity_customers +crm_quotations +crm_quotation_items +crm_quotation_followups +crm_approval_requests +crm_document_artifacts + +Lead → Opportunity assignment +Opportunity lifecycle +Quotation creation +Quotation approval +Approved PDF artifact +Audit foundation +``` + +--- + +## Business Ownership Rule + +Opportunity owns: + +```txt +sales pipeline +sales stage +business outcome +sales owner +expected value +chance percent +expected close date +``` + +Quotation owns: + +```txt +commercial document +quotation amount +revision +approval state +approved PDF +customer-facing proposal +``` + +--- + +## Scope D5.7.1 Relationship Naming Alignment + +Ensure active code uses: + +```txt +opportunityId +``` + +instead of: + +```txt +enquiryId +``` + +for quotation relationship fields and DTOs. + +If DB still contains legacy column name: + +```txt +crm_quotations.enquiry_id +``` + +then rename to: + +```txt +crm_quotations.opportunity_id +``` + +Because the project is still in dev/reset mode, prefer clean naming. + +Rules: + +* no active application code should expose `enquiryId` +* quotation APIs should accept `opportunityId` +* quotation detail should show linked Opportunity +* Opportunity detail should show linked Quotations + +--- + +## Scope D5.7.2 Quotation Creation Sync + +When creating a quotation from an Opportunity: + +* require `opportunityId` +* verify Opportunity exists and is open +* prevent quotation creation from closed opportunities +* copy baseline context from Opportunity: + + * customer + * contact + * project name + * project location + * product type + * salesman / owner + * chance percent + * hot project flag if available + +After quotation creation: + +```txt +opportunity.status = quotation_created +opportunity.outcomeStatus = open +``` + +Rules: + +* do not overwrite closed outcome +* do not auto mark won +* audit both quotation creation and opportunity sync + +--- + +## Scope D5.7.3 Quotation Revision Sync + +When creating quotation revision: + +* revision must remain linked to the same Opportunity +* parent and child quotation must share `opportunityId` +* Opportunity stays open unless already closed +* Opportunity stage remains `quotation_created` or moves to `negotiation` if supported + +Rules: + +* do not duplicate opportunity +* do not unlink old revisions +* detail UI should group revisions under the same opportunity + +--- + +## Scope D5.7.4 Approval Sync + +When quotation is submitted for approval: + +```txt +Opportunity stage may remain quotation_created +``` + +When quotation is approved: + +```txt +Opportunity stage may become negotiation +``` + +Recommended mapping: + +```txt +quotation draft → opportunity quotation_created +quotation pending_approval → opportunity quotation_created +quotation approved → opportunity negotiation +quotation sent_to_customer → opportunity negotiation +``` + +Rules: + +* approval sync must be non-destructive +* do not change opportunity outcome +* do not mark won on approval +* audit stage sync if changed + +--- + +## Scope D5.7.5 Quotation Outcome Sync + +Add explicit service operations or hooks for: + +```txt +acceptQuotation() +rejectQuotation() +markQuotationLost() +``` + +If such operations already exist, wire them to Opportunity lifecycle. + +When quotation is accepted or PO is confirmed: + +```txt +opportunity.outcomeStatus = won +opportunity.closedAt = now +opportunity.closedWonAt = now +``` + +When quotation is rejected/lost and no other active quotation remains viable: + +```txt +opportunity.outcomeStatus = lost +opportunity.closedAt = now +opportunity.closedLostAt = now +``` + +Rules: + +* one won quotation marks opportunity won +* one lost quotation must not automatically mark opportunity lost if another active quotation/revision is still open +* latest active revision should drive outcome when applicable +* lost requires reason +* won should eventually be driven by PO, but manual won remains allowed until PO module exists + +--- + +## Scope D5.7.6 Opportunity Detail Integration + +Update Opportunity detail to show linked quotations. + +Display: + +```txt +Quotation Code +Revision +Status +Approval Status +Total Amount +Currency +Valid Until +Accepted / Rejected state +``` + +Actions: + +```txt +Open Quotation +Create Quotation +Create Revision +Mark Won +Mark Lost +``` + +Rules: + +* Create Quotation action should preselect current opportunity +* closed opportunities disable create quotation +* winning quotation should be visibly highlighted + +--- + +## Scope D5.7.7 Quotation Detail Integration + +Update Quotation detail to show linked Opportunity. + +Display: + +```txt +Opportunity Code +Opportunity Stage +Opportunity Outcome +Sales Owner +Expected Close Date +``` + +Actions: + +```txt +Open Opportunity +``` + +Rules: + +* no broader permission leak +* show only if user can access quotation and linked opportunity context is safe to display + +--- + +## Scope D5.7.8 Data Consistency Guards + +Add guards: + +* quotation cannot link to opportunity from another organization +* quotation product type must match opportunity product type unless explicitly permitted +* quotation customer must match opportunity customer or opportunity project party unless explicitly permitted +* closed opportunity cannot create quotation +* quotation revision must keep the same opportunity + +--- + +## Scope D5.7.9 Audit Logging + +Required audit actions: + +```txt +sync_opportunity_from_quotation_created +sync_opportunity_from_quotation_approved +sync_opportunity_from_quotation_sent +sync_opportunity_from_quotation_won +sync_opportunity_from_quotation_lost +sync_quotation_opportunity_link +``` + +Audit must capture: + +```txt +opportunityId +quotationId +previousOpportunityStage +newOpportunityStage +previousOutcomeStatus +newOutcomeStatus +reason +actedBy +``` + +--- + +## Scope D5.7.10 Permissions + +Use existing permissions: + +```txt +crm.opportunity.read +crm.opportunity.update +crm.opportunity.lifecycle.update +crm.quotation.create +crm.quotation.update +crm.quotation.read +``` + +Rules: + +* quotation creation requires quotation create permission +* opportunity sync should be service-side and not require separate user UI permission if triggered by valid quotation action +* manual outcome changes still require opportunity lifecycle permission +* do not check role strings directly + +--- + +## Scope D5.7.11 Documentation + +Create: + +```txt +docs/implementation/task-d57-opportunity-quotation-synchronization-foundation.md +``` + +Document: + +```txt +relationship model +sync trigger map +stage mapping +outcome mapping +revision rules +guard rules +known limitations +``` + +--- + +## Recommended Sync Map + +```txt +Quotation Created +→ Opportunity stage = quotation_created +→ Outcome = open + +Quotation Submitted +→ Opportunity stage = quotation_created +→ Outcome = open + +Quotation Approved +→ Opportunity stage = negotiation +→ Outcome = open + +Quotation Sent +→ Opportunity stage = negotiation +→ Outcome = open + +Quotation Accepted / PO Confirmed +→ Opportunity stage = closed +→ Outcome = won + +Quotation Rejected / Lost +→ Opportunity stage = closed only if no viable quotation remains +→ Outcome = lost +``` + +--- + +## Deliverables + +Updated: + +```txt +src/db/schema.ts +src/features/crm/opportunities/** +src/features/crm/quotations/** +src/features/crm/approval/** +``` + +New or updated: + +```txt +src/features/crm/opportunities/server/quotation-sync.service.ts +src/features/crm/quotations/server/opportunity-link.service.ts +``` + +Updated APIs: + +```txt +/api/crm/quotations +/api/crm/quotations/[id] +/api/crm/quotations/[id]/revisions +``` + +Updated UI: + +```txt +Opportunity Detail linked quotations +Quotation Detail linked opportunity +Quotation Create form preselect opportunity +``` + +Documentation: + +```txt +docs/implementation/task-d57-opportunity-quotation-synchronization-foundation.md +``` + +--- + +## Explicit Non-Scope + +Do not: + +* create PO module +* create dashboard datasets +* create KPI cards +* create reports +* rewrite approval matrix +* create Sales Enquiry document +* rewrite PDF template +* implement revenue recognition +* migrate production data + +These belong to later phases. + +--- + +## Verification + +Run: + +```txt +npm exec tsc --noEmit +npm run build +``` + +Manual verification: + +```txt +Create quotation from opportunity +Opportunity stage becomes quotation_created +Submit quotation for approval +Approve quotation +Opportunity stage becomes negotiation if configured +Create quotation revision +Revision keeps same opportunityId +Mark quotation accepted / won +Opportunity outcome becomes won +Mark quotation lost +Opportunity becomes lost only when no viable quotation remains +Closed opportunity blocks quotation creation +Opportunity detail shows quotations +Quotation detail shows opportunity +Audit logs capture sync actions +Lead → Opportunity still works +Approval flow still works +PDF flow still works +``` + +--- + +## Definition of Done + +Task is complete when: + +* Quotation uses `opportunityId` +* Opportunity stage syncs from quotation lifecycle +* Opportunity outcome can sync from quotation won/lost events +* Closed opportunity blocks new quotations +* Opportunity detail shows linked quotations +* Quotation detail shows linked opportunity +* Revisions preserve opportunity linkage +* Audit logs capture synchronization +* Existing Lead, Opportunity, Quotation, Approval, and PDF flows remain operational + +Result: + +```txt +Opportunity ↔ Quotation Sync = Established +Pipeline Outcome Integrity = Strengthened +Ready for Pipeline KPI / Dashboard Foundation +``` diff --git a/src/features/crm/opportunities/components/opportunity-detail.tsx b/src/features/crm/opportunities/components/opportunity-detail.tsx index ef87c88..43a5ae9 100644 --- a/src/features/crm/opportunities/components/opportunity-detail.tsx +++ b/src/features/crm/opportunities/components/opportunity-detail.tsx @@ -351,6 +351,23 @@ export function OpportunityDetail({ value={String(projectPartiesData.items.length)} /> + {relatedQuotations.length ? ( +
+ {relatedQuotations.slice(0, 3).map((quotation) => ( +
+
+ + {quotation.code} + + Rev {quotation.revision} +
+
+ {quotation.status} · {quotation.totalAmount.toLocaleString()} {quotation.currency} +
+
+ ))} +
+ ) : null} @@ -358,4 +375,3 @@ export function OpportunityDetail({ ); } - diff --git a/src/features/crm/opportunities/server/quotation-sync.service.ts b/src/features/crm/opportunities/server/quotation-sync.service.ts new file mode 100644 index 0000000..0c07848 --- /dev/null +++ b/src/features/crm/opportunities/server/quotation-sync.service.ts @@ -0,0 +1,244 @@ +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { crmOpportunities, crmQuotations } from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { db } from '@/lib/db'; + +const QUOTATION_OPEN_CODES = new Set(['draft', 'revised', 'pending_approval', 'approved', 'sent_to_customer', 'sent']); +const QUOTATION_NEGOTIATION_CODES = new Set(['approved', 'sent_to_customer', 'sent']); + +async function getOpportunity(opportunityId: string, organizationId: string) { + const [opportunity] = await db + .select() + .from(crmOpportunities) + .where( + and( + eq(crmOpportunities.id, opportunityId), + eq(crmOpportunities.organizationId, organizationId), + isNull(crmOpportunities.deletedAt) + ) + ) + .limit(1); + + return opportunity ?? null; +} + +async function getActiveQuotationCodes(opportunityId: string, organizationId: string) { + const rows = await db + .select({ + status: crmQuotations.status, + acceptedAt: crmQuotations.acceptedAt, + rejectedAt: crmQuotations.rejectedAt + }) + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + eq(crmQuotations.opportunityId, opportunityId), + isNull(crmQuotations.deletedAt) + ) + ); + + return rows; +} + +async function updateOpportunityStage( + opportunityId: string, + organizationId: string, + userId: string, + nextStage: string, + action: string, + quotationId: string, + reason: string +) { + const opportunity = await getOpportunity(opportunityId, organizationId); + if (!opportunity || (opportunity.outcomeStatus ?? 'open') !== 'open' || opportunity.status === nextStage) { + return; + } + + const [updated] = await db + .update(crmOpportunities) + .set({ + status: nextStage, + pipelineStage: 'opportunity', + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmOpportunities.id, opportunityId)) + .returning(); + + await auditAction({ + organizationId, + branchId: updated.branchId, + userId, + entityType: 'crm_opportunity', + entityId: opportunityId, + action, + afterData: { + quotationId, + previousOpportunityStage: opportunity.status, + newOpportunityStage: updated.status, + previousOutcomeStatus: opportunity.outcomeStatus ?? 'open', + newOutcomeStatus: updated.outcomeStatus, + reason, + actedBy: userId + } + }); +} + +export async function syncOpportunityFromQuotationCreated(args: { + organizationId: string; + userId: string; + opportunityId: string; + quotationId: string; +}) { + await updateOpportunityStage( + args.opportunityId, + args.organizationId, + args.userId, + 'quotation_created', + 'sync_opportunity_from_quotation_created', + args.quotationId, + 'quotation_created' + ); +} + +export async function syncOpportunityFromQuotationApproval(args: { + organizationId: string; + userId: string; + opportunityId: string; + quotationId: string; + quotationStatusCode: string | null; +}) { + if (!args.quotationStatusCode) { + return; + } + + const nextStage = QUOTATION_NEGOTIATION_CODES.has(args.quotationStatusCode) + ? 'negotiation' + : 'quotation_created'; + + const action = QUOTATION_NEGOTIATION_CODES.has(args.quotationStatusCode) + ? args.quotationStatusCode === 'approved' + ? 'sync_opportunity_from_quotation_approved' + : 'sync_opportunity_from_quotation_sent' + : 'sync_opportunity_from_quotation_created'; + + await updateOpportunityStage( + args.opportunityId, + args.organizationId, + args.userId, + nextStage, + action, + args.quotationId, + args.quotationStatusCode + ); +} + +export async function syncOpportunityFromQuotationWon(args: { + organizationId: string; + userId: string; + opportunityId: string; + quotationId: string; + reason?: string | null; +}) { + const opportunity = await getOpportunity(args.opportunityId, args.organizationId); + if (!opportunity) { + return; + } + + const now = new Date(); + const [updated] = await db + .update(crmOpportunities) + .set({ + status: 'closed', + outcomeStatus: 'won', + pipelineStage: 'closed_won', + closedAt: now, + closedWonAt: now, + closedLostAt: null, + closedByUserId: args.userId, + updatedAt: now, + updatedBy: args.userId + }) + .where(eq(crmOpportunities.id, args.opportunityId)) + .returning(); + + await auditAction({ + organizationId: args.organizationId, + branchId: updated.branchId, + userId: args.userId, + entityType: 'crm_opportunity', + entityId: args.opportunityId, + action: 'sync_opportunity_from_quotation_won', + afterData: { + quotationId: args.quotationId, + previousOpportunityStage: opportunity.status, + newOpportunityStage: updated.status, + previousOutcomeStatus: opportunity.outcomeStatus ?? 'open', + newOutcomeStatus: updated.outcomeStatus, + reason: args.reason ?? 'quotation_won', + actedBy: args.userId + } + }); +} + +export async function syncOpportunityFromQuotationLost(args: { + organizationId: string; + userId: string; + opportunityId: string; + quotationId: string; + reason: string; +}) { + const opportunity = await getOpportunity(args.opportunityId, args.organizationId); + if (!opportunity) { + return; + } + + const rows = await getActiveQuotationCodes(args.opportunityId, args.organizationId); + const otherOpenExists = rows.some((row) => { + if (row.acceptedAt || row.rejectedAt) { + return false; + } + + return QUOTATION_OPEN_CODES.has(row.status); + }); + + if (otherOpenExists) { + return; + } + + const now = new Date(); + const [updated] = await db + .update(crmOpportunities) + .set({ + status: 'closed', + outcomeStatus: 'lost', + pipelineStage: 'closed_lost', + closedAt: now, + closedWonAt: null, + closedLostAt: now, + closedByUserId: args.userId, + updatedAt: now, + updatedBy: args.userId + }) + .where(eq(crmOpportunities.id, args.opportunityId)) + .returning(); + + await auditAction({ + organizationId: args.organizationId, + branchId: updated.branchId, + userId: args.userId, + entityType: 'crm_opportunity', + entityId: args.opportunityId, + action: 'sync_opportunity_from_quotation_lost', + afterData: { + quotationId: args.quotationId, + previousOpportunityStage: opportunity.status, + newOpportunityStage: updated.status, + previousOutcomeStatus: opportunity.outcomeStatus ?? 'open', + newOutcomeStatus: updated.outcomeStatus, + reason: args.reason, + actedBy: args.userId + } + }); +} diff --git a/src/features/crm/quotations/api/types.ts b/src/features/crm/quotations/api/types.ts index 650d1f9..a690ead 100644 --- a/src/features/crm/quotations/api/types.ts +++ b/src/features/crm/quotations/api/types.ts @@ -234,8 +234,15 @@ export interface QuotationRelationItem { id: string; code: string; status: string; + revision: number; quotationType: string; totalAmount: number; + currency: string; + validUntil: string | null; + approvedAt: string | null; + acceptedAt: string | null; + rejectedAt: string | null; + rejectionReason: string | null; updatedAt: string; } @@ -420,4 +427,3 @@ export interface MutationSuccessResponse { success: boolean; message: string; } - diff --git a/src/features/crm/quotations/server/opportunity-link.service.ts b/src/features/crm/quotations/server/opportunity-link.service.ts new file mode 100644 index 0000000..72cb171 --- /dev/null +++ b/src/features/crm/quotations/server/opportunity-link.service.ts @@ -0,0 +1,97 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { crmOpportunities, crmQuotations } from '@/db/schema'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; + +export async function assertOpportunityLinkIntegrity(args: { + organizationId: string; + opportunityId: string | null | undefined; + customerId: string; + productType?: string | null; +}) { + if (!args.opportunityId) { + return null; + } + + const [opportunity] = await db + .select() + .from(crmOpportunities) + .where( + and( + eq(crmOpportunities.id, args.opportunityId), + eq(crmOpportunities.organizationId, args.organizationId), + isNull(crmOpportunities.deletedAt) + ) + ) + .limit(1); + + if (!opportunity) { + throw new AuthError('Opportunity not found', 404); + } + + if ((opportunity.outcomeStatus ?? 'open') !== 'open') { + throw new AuthError('Closed opportunity cannot create quotation', 400); + } + + if (opportunity.customerId !== args.customerId) { + throw new AuthError('Quotation customer must match linked opportunity customer', 400); + } + + if (args.productType && opportunity.productType !== args.productType) { + throw new AuthError('Quotation product type must match linked opportunity product type', 400); + } + + return opportunity; +} + +export async function assertRevisionOpportunityLinkIntegrity(args: { + organizationId: string; + quotationId: string; +}) { + const [quotation] = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.id, args.quotationId), + eq(crmQuotations.organizationId, args.organizationId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1); + + if (!quotation) { + throw new AuthError('Quotation not found', 404); + } + + return quotation.opportunityId ?? null; +} + +export async function auditQuotationOpportunityLink(args: { + organizationId: string; + userId: string; + quotationId: string; + opportunityId: string | null; + previousOpportunityId?: string | null; + reason: string; +}) { + if (!args.opportunityId && !args.previousOpportunityId) { + return; + } + + await auditAction({ + organizationId: args.organizationId, + userId: args.userId, + entityType: 'crm_quotation', + entityId: args.quotationId, + action: 'sync_quotation_opportunity_link', + afterData: { + quotationId: args.quotationId, + opportunityId: args.opportunityId, + previousOpportunityId: args.previousOpportunityId ?? null, + reason: args.reason, + actedBy: args.userId + } + }); +} diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 00e10fc..0712112 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -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((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 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() })); } diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index 8384d06..bcd42f2 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -20,6 +20,7 @@ import { canAccessScopedCrmRecord } from '@/features/crm/security/server/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import { syncOpportunityFromQuotationApproval } from '@/features/crm/opportunities/server/quotation-sync.service'; import type { ApprovalActionRecord, ApprovalDetailRecord, @@ -576,14 +577,46 @@ async function syncEntityStatusFromApproval( return; } + const [quotation] = await db + .select({ + id: crmQuotations.id, + opportunityId: crmQuotations.opportunityId + }) + .from(crmQuotations) + .where( + and( + eq(crmQuotations.id, entityId), + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1); + if (approvalStatus === APPROVAL_REQUEST_STATUSES.pending) { await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'pending_approval'); + if (quotation?.opportunityId) { + await syncOpportunityFromQuotationApproval({ + organizationId, + userId, + opportunityId: quotation.opportunityId, + quotationId: quotation.id, + quotationStatusCode: 'pending_approval' + }); + } return; } if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) { - // TODO(task-h.1): add an async hook/job to auto-generate approved PDFs after final approval. await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved'); + if (quotation?.opportunityId) { + await syncOpportunityFromQuotationApproval({ + organizationId, + userId, + opportunityId: quotation.opportunityId, + quotationId: quotation.id, + quotationStatusCode: 'approved' + }); + } return; }