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;
}