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

@@ -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.

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

544
plans/task-d.5.7.md Normal file
View File

@@ -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
```

View File

@@ -351,6 +351,23 @@ export function OpportunityDetail({
value={String(projectPartiesData.items.length)}
/>
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
{relatedQuotations.length ? (
<div className='space-y-2'>
{relatedQuotations.slice(0, 3).map((quotation) => (
<div key={quotation.id} className='rounded-lg border p-3 text-sm'>
<div className='flex items-center justify-between gap-3'>
<Link href={`/dashboard/crm/quotations/${quotation.id}`} className='font-medium hover:underline'>
{quotation.code}
</Link>
<Badge variant='outline'>Rev {quotation.revision}</Badge>
</div>
<div className='text-muted-foreground mt-1'>
{quotation.status} · {quotation.totalAmount.toLocaleString()} {quotation.currency}
</div>
</div>
))}
</div>
) : null}
</CardContent>
</Card>
</div>
@@ -358,4 +375,3 @@ export function OpportunityDetail({
</div>
);
}

View File

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

View File

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

View File

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

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) => ({
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) => ({
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()
}));
}

View File

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