task-d.5.7
This commit is contained in:
@@ -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
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
544
plans/task-d.5.7.md
Normal file
544
plans/task-d.5.7.md
Normal 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
|
||||||
|
```
|
||||||
@@ -351,6 +351,23 @@ export function OpportunityDetail({
|
|||||||
value={String(projectPartiesData.items.length)}
|
value={String(projectPartiesData.items.length)}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,4 +375,3 @@ export function OpportunityDetail({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
244
src/features/crm/opportunities/server/quotation-sync.service.ts
Normal file
244
src/features/crm/opportunities/server/quotation-sync.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -234,8 +234,15 @@ export interface QuotationRelationItem {
|
|||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
revision: number;
|
||||||
quotationType: string;
|
quotationType: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
validUntil: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
acceptedAt: string | null;
|
||||||
|
rejectedAt: string | null;
|
||||||
|
rejectionReason: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,4 +427,3 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ import {
|
|||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/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 {
|
import type {
|
||||||
QuotationActivityRecord,
|
QuotationActivityRecord,
|
||||||
QuotationAttachmentMutationPayload,
|
QuotationAttachmentMutationPayload,
|
||||||
@@ -1297,12 +1306,11 @@ export async function createQuotation(
|
|||||||
payload.status
|
payload.status
|
||||||
);
|
);
|
||||||
|
|
||||||
const opportunity = payload.opportunityId
|
const opportunity = await assertOpportunityLinkIntegrity({
|
||||||
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
|
organizationId,
|
||||||
: null;
|
opportunityId: payload.opportunityId,
|
||||||
if (opportunity) {
|
customerId: payload.customerId
|
||||||
assertOpportunityOpenForQuotation(opportunity);
|
});
|
||||||
}
|
|
||||||
const documentCode = await generateNextDocumentCode({
|
const documentCode = await generateNextDocumentCode({
|
||||||
organizationId,
|
organizationId,
|
||||||
documentType: 'quotation',
|
documentType: 'quotation',
|
||||||
@@ -1362,6 +1370,19 @@ export async function createQuotation(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (created.opportunityId) {
|
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(
|
await syncOpportunityPipelineStageFromQuotationStatus(
|
||||||
created.opportunityId,
|
created.opportunityId,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -1387,9 +1408,11 @@ export async function updateQuotation(
|
|||||||
payload.status
|
payload.status
|
||||||
);
|
);
|
||||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const opportunity = payload.opportunityId
|
const opportunity = await assertOpportunityLinkIntegrity({
|
||||||
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
|
organizationId,
|
||||||
: null;
|
opportunityId: payload.opportunityId,
|
||||||
|
customerId: payload.customerId
|
||||||
|
});
|
||||||
|
|
||||||
const updated = await db.transaction(async (tx) => {
|
const updated = await db.transaction(async (tx) => {
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
@@ -1435,11 +1458,26 @@ export async function updateQuotation(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (row.opportunityId) {
|
if (row.opportunityId) {
|
||||||
|
await auditQuotationOpportunityLink({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
quotationId: row.id,
|
||||||
|
opportunityId: row.opportunityId,
|
||||||
|
previousOpportunityId: existing.opportunityId,
|
||||||
|
reason: 'quotation_updated'
|
||||||
|
});
|
||||||
await syncOpportunityPipelineStageFromQuotationStatus(
|
await syncOpportunityPipelineStageFromQuotationStatus(
|
||||||
row.opportunityId,
|
row.opportunityId,
|
||||||
organizationId,
|
organizationId,
|
||||||
quotationStatusCode
|
quotationStatusCode
|
||||||
);
|
);
|
||||||
|
await syncOpportunityFromQuotationApproval({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
opportunityId: row.opportunityId,
|
||||||
|
quotationId: row.id,
|
||||||
|
quotationStatusCode
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -2125,6 +2163,7 @@ export async function createQuotationRevision(
|
|||||||
revisionRemark?: string
|
revisionRemark?: string
|
||||||
) {
|
) {
|
||||||
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
||||||
|
await assertRevisionOpportunityLinkIntegrity({ organizationId, quotationId });
|
||||||
const statusCode = await resolveOptionCodeById(
|
const statusCode = await resolveOptionCodeById(
|
||||||
organizationId,
|
organizationId,
|
||||||
QUOTATION_OPTION_CATEGORIES.status,
|
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;
|
return created;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2292,14 +2348,21 @@ export async function listQuotationRevisions(id: string, organizationId: string)
|
|||||||
)
|
)
|
||||||
.orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt));
|
.orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt));
|
||||||
|
|
||||||
return rows.map<QuotationRelationItem>((row) => ({
|
return rows.map<QuotationRelationItem>((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
quotationType: row.quotationType,
|
revision: row.revision,
|
||||||
totalAmount: row.totalAmount,
|
quotationType: row.quotationType,
|
||||||
updatedAt: row.updatedAt.toISOString()
|
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(
|
export async function listOpportunityQuotationRelations(
|
||||||
@@ -2318,14 +2381,21 @@ export async function listOpportunityQuotationRelations(
|
|||||||
)
|
)
|
||||||
.orderBy(desc(crmQuotations.updatedAt));
|
.orderBy(desc(crmQuotations.updatedAt));
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
quotationType: row.quotationType,
|
revision: row.revision,
|
||||||
totalAmount: row.totalAmount,
|
quotationType: row.quotationType,
|
||||||
updatedAt: row.updatedAt.toISOString()
|
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(
|
export async function listCustomerQuotationRelations(
|
||||||
@@ -2351,8 +2421,15 @@ export async function listCustomerQuotationRelations(
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
revision: row.revision,
|
||||||
quotationType: row.quotationType,
|
quotationType: row.quotationType,
|
||||||
totalAmount: row.totalAmount,
|
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()
|
updatedAt: row.updatedAt.toISOString()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
canAccessScopedCrmRecord
|
canAccessScopedCrmRecord
|
||||||
} from '@/features/crm/security/server/service';
|
} from '@/features/crm/security/server/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import { syncOpportunityFromQuotationApproval } from '@/features/crm/opportunities/server/quotation-sync.service';
|
||||||
import type {
|
import type {
|
||||||
ApprovalActionRecord,
|
ApprovalActionRecord,
|
||||||
ApprovalDetailRecord,
|
ApprovalDetailRecord,
|
||||||
@@ -576,14 +577,46 @@ async function syncEntityStatusFromApproval(
|
|||||||
return;
|
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) {
|
if (approvalStatus === APPROVAL_REQUEST_STATUSES.pending) {
|
||||||
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'pending_approval');
|
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'pending_approval');
|
||||||
|
if (quotation?.opportunityId) {
|
||||||
|
await syncOpportunityFromQuotationApproval({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
opportunityId: quotation.opportunityId,
|
||||||
|
quotationId: quotation.id,
|
||||||
|
quotationStatusCode: 'pending_approval'
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) {
|
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');
|
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved');
|
||||||
|
if (quotation?.opportunityId) {
|
||||||
|
await syncOpportunityFromQuotationApproval({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
opportunityId: quotation.opportunityId,
|
||||||
|
quotationId: quotation.id,
|
||||||
|
quotationStatusCode: 'approved'
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user