task-d.5.1
This commit is contained in:
phaichayon
2026-06-24 12:28:04 +07:00
parent c1ecd5ea50
commit 9c75788ba7
10 changed files with 794 additions and 1 deletions

View File

@@ -0,0 +1,117 @@
# ADR 0018: Lead / Enquiry Domain Separation
## Status
Proposed
## Context
ADR-0011 froze the current CRM lifecycle around a single persisted entity:
- `crm_enquiries` stores both marketing-owned leads and sales-owned enquiries
- `pipeline_stage` acts as the discriminator
- navigation separation exists, but domain separation does not
That model was an intentional stabilization step for Tasks D.3 and D.3.1. It reduced UX ambiguity without forcing a schema split during the first production rollout.
Task D.5 introduces a stronger business requirement:
- Marketing owns `Lead` as a dedicated entity
- Sales owns `Enquiry` as a dedicated entity
- one lead may create many enquiries
- sales may also create enquiries directly without a lead
- marketing outcome must be derived from sales execution, not manually controlled
The current single-record model now creates structural ambiguity in at least these areas:
1. Assignment currently mutates one record from `lead -> enquiry`, which destroys the original lead as a stable marketing record.
2. A true `1 Lead -> N Enquiries` relationship cannot be represented cleanly while the lead itself is also the enquiry record.
3. Dashboard and report logic still infer lead analytics from `crm_enquiries.pipeline_stage`, which couples marketing and sales datasets.
4. Outcome synchronization for marketing is harder to reason about because the source marketing entity does not exist independently.
## Decision
We will replace the single-record lead/enquiry persistence model with a split domain model:
- `crm_leads` becomes the dedicated marketing-owned lead table
- `crm_enquiries` remains the dedicated sales-owned enquiry table
- `crm_enquiries.lead_id` becomes nullable to support:
- lead-origin enquiries
- direct sales enquiries with `lead_id = null`
### Lifecycle
The target business flow is:
```txt
Lead
-> Enquiry
-> Quotation
-> Won / Lost
```
### Relationship rules
- `crm_leads 1 -> N crm_enquiries`
- `crm_enquiries 1 -> N crm_quotations`
- `crm_quotations` remains the owning record for quotation revisions and child resources
### Ownership rules
- marketing owns lead creation and lead follow-up
- sales owns enquiry creation, enquiry follow-up, quotation generation, and won/lost execution
- managers and admins retain cross-workspace monitoring based on resolved CRM access
### Outcome rules
- lead outcome is derived, never manually updated
- when any linked enquiry becomes won, the lead outcome becomes `won`
- when all linked enquiries are lost, the lead outcome becomes `lost`
- otherwise the lead outcome remains `open`
### Sequence rules
- lead codes use a dedicated lead document sequence
- enquiry codes keep a separate enquiry document sequence
### Reporting and dashboard rules
- lead KPI and lead reports read from `crm_leads`
- enquiry KPI and enquiry reports read from `crm_enquiries`
- no report should continue to treat `pipeline_stage` as the marketing/sales entity boundary after migration
## Consequences
### Positive
- aligns persistence with the real marketing-to-sales handoff
- supports true `1 Lead -> N Enquiries`
- preserves lead identity after assignment
- simplifies marketing analytics and awareness-source reporting
- makes direct sales enquiries first-class without overloading lead semantics
### Tradeoffs
- supersedes a previously accepted ADR and requires broad migration across services, reports, dashboard, and permissions
- requires data migration from existing `crm_enquiries.pipeline_stage`
- increases temporary compatibility complexity during rollout
## Migration Strategy
The migration should be delivered in explicit phases:
1. Introduce ADR and governance freeze for split-domain direction.
2. Add schema and seed support for `crm_leads`, `lead_id`, and lead master options.
3. Introduce lead services and lead APIs without deleting enquiry flows.
4. Migrate dashboard and reports to separated datasets.
5. Remove old single-record assumptions from shared enquiry services and UI.
## Supersedes
This ADR is intended to supersede the following sections of ADR-0011 once implemented:
- `Single record model`
- `Pipeline stage model` as the lead/enquiry persistence boundary
- `Navigation Separation` as the primary separation mechanism
Until implementation is complete, ADR-0011 remains the description of current production behavior, while this ADR defines the approved target direction for Task D.5.

View File

@@ -0,0 +1,134 @@
# Task D.5: Lead / Enquiry Domain Separation
## Status
In progress
## Objective
Replace the current single-record lead/enquiry persistence model with a split domain model that preserves marketing leads independently from sales enquiries.
## Review Summary
Reviewed before implementation:
- `AGENTS.md`
- `docs/standards/task-contract-template.md`
- `docs/standards/task-review-checklist.md`
- `docs/standards/task-catalog.md`
- `docs/standards/project-foundations.md`
- `docs/adr/0011-lead-enquiry-ownership-model.md`
- `docs/adr/0016-won-lost-lifecycle-governance.md`
- `docs/implementation/task-d3-lead-enquiry-ownership-refinement.md`
- `docs/implementation/task-d31-leads-enquiries-navigation-separation.md`
- `docs/implementation/task-d4-won-lost-lifecycle-governance.md`
- `docs/implementation/task-k2-pipeline-reports.md`
- `docs/security/crm-authorization-boundaries.md`
- `src/db/schema.ts`
- `src/features/crm/enquiries/server/service.ts`
## Historical Findings
### Current production truth
The current production CRM still follows ADR-0011:
- `crm_enquiries` is the single source of truth
- `pipeline_stage` distinguishes `lead`, `enquiry`, `closed_won`, `closed_lost`
- assigning a lead mutates the same record into an enquiry
- lead workspace and enquiry workspace are only UX separation
### Why D.5 is different
Task D.5 explicitly requires:
- dedicated `crm_leads`
- `crm_enquiries.lead_id`
- `1 Lead -> N Enquiries`
- direct sales enquiries with nullable `lead_id`
- dashboard/report split by entity rather than `pipeline_stage`
This is not an incremental extension of ADR-0011. It is a domain replacement.
## Governance Decision
Because Task D.5 conflicts with an accepted ADR, the first implementation step is to add a replacement ADR:
- `docs/adr/0018-lead-enquiry-domain-separation.md`
This task should not continue with schema or API changes while ADR-0011 remains the only source of truth for this area.
## Reuse Constraints
The split-domain refactor must still reuse existing foundations rather than rebuilding CRM from scratch:
- Audit Foundation for migration and lifecycle actions
- Master Data Foundation for lead awareness/status/follow-up/lost-reason options
- Document Sequence Foundation for lead and enquiry code generation
- Authorization Foundation for visibility and scope rules
- Dashboard Foundation for KPI composition
- Reporting Foundation for dataset and export contracts
- Won / Lost Governance Foundation for enquiry outcome transitions
## Phase Plan
### Phase 0
- freeze ADR-0018
- document the conflict with ADR-0011
- define migration boundaries
### Phase 1
- add `crm_leads`
- add `crm_enquiries.lead_id`
- add lead master option categories
- add lead document sequence type
### Phase 2
- create dedicated lead services and `/api/crm/leads/**`
- keep enquiry services focused on sales execution
- migrate assignment flow from `pipeline_stage` mutation to `lead -> create enquiry`
### Phase 3
- update dashboard datasets to separate lead/enquiry sources
- update report datasets and exports to separate lead/enquiry sources
- preserve quotation, approval, PDF, and pricing foundations
### Phase 4
- migrate legacy rows from `crm_enquiries.pipeline_stage`
- preserve follow-ups, project-party relations, attachments, quotations, and audit history
- remove single-record assumptions from surviving UI and server code
## Initial Risks
- this task affects schema, services, APIs, dashboard, reports, permissions, and migration logic at once
- existing lead reports and dashboard KPIs currently depend on `pipeline_stage`
- current enquiry services bundle lead creation defaults, assignment conversion, and won/lost logic together
- authorization rules for marketing monitoring are currently described against the shared enquiry table
## Deliverables Started
- added `docs/adr/0018-lead-enquiry-domain-separation.md`
- added `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
## Next Implementation Slice
The next safe code slice is:
1. add schema support for `crm_leads` and `crm_enquiries.lead_id`
2. seed new lead option categories and document sequence configuration
3. keep existing enquiry routes live while introducing lead-specific route contracts
## Verification Target
When code phases begin, verification must include:
- `npm exec tsc --noEmit`
- migration validation for existing `crm_enquiries`
- direct sales enquiry creation with `leadId = null`
- lead assignment creating enquiries without deleting the lead
- outcome synchronization from enquiry results back to lead outcome

View File

@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS "crm_leads" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"code" text NOT NULL,
"customer_id" text,
"contact_id" text,
"project_name" text,
"project_location" text,
"awareness_id" text,
"status" text DEFAULT 'new_job' NOT NULL,
"followup_status" text,
"lost_reason" text,
"outcome" text DEFAULT 'open' NOT NULL,
"owner_marketing_user_id" text,
"created_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "crm_leads_org_code_idx" ON "crm_leads" USING btree ("organization_id","code");
--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN IF NOT EXISTS "lead_id" text;

View File

@@ -141,6 +141,13 @@
"when": 1782137095907,
"tag": "0019_sleepy_ultron",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1782262800000,
"tag": "0020_lead_foundation_schema",
"breakpoints": true
}
]
}
}

30
package-lock.json generated
View File

@@ -58,6 +58,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.2",
"headroom-ai": "^0.22.4",
"input-otp": "^1.4.2",
"kbar": "^0.1.0-beta.48",
"match-sorter": "^8.2.0",
@@ -8000,6 +8001,35 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/headroom-ai": {
"version": "0.22.4",
"resolved": "https://registry.npmjs.org/headroom-ai/-/headroom-ai-0.22.4.tgz",
"integrity": "sha512-9a0rgB/jsWe8gs/ggyUwe6E8DYwKAuBvlUml2ApwlUjb5EfJ611X6X+WG0SiXw3nO6sdyV1/+Ah5uw9P7ecnjw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@ai-sdk/provider": ">=1.0.0",
"@anthropic-ai/sdk": ">=0.30.0",
"ai": ">=6.0.0",
"openai": ">=4.0.0"
},
"peerDependenciesMeta": {
"@ai-sdk/provider": {
"optional": true
},
"@anthropic-ai/sdk": {
"optional": true
},
"ai": {
"optional": true
},
"openai": {
"optional": true
}
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",

View File

@@ -86,6 +86,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.2",
"headroom-ai": "^0.22.4",
"input-otp": "^1.4.2",
"kbar": "^0.1.0-beta.48",
"match-sorter": "^8.2.0",

349
plans/task-d.5.1.md Normal file
View File

@@ -0,0 +1,349 @@
# Task D.5.1: Lead Foundation Schema & Compatibility Layer
## Status
Planned
## Objective
Create the foundational schema and compatibility layer required for Lead / Enquiry Domain Separation without breaking the existing CRM workflow.
This task introduces the Lead domain but does not yet migrate business flows.
The objective is to prepare the platform for D.5.2 and later phases while keeping all existing CRM features operational.
---
# Mandatory Review
Review:
* AGENTS.md
* Project Foundations Registry
* Task Catalog
* ADR-0011 Lead / Enquiry Ownership Model
* ADR-0016 Won / Lost Lifecycle Governance
* ADR-0018 Lead / Enquiry Domain Separation
* Task D.3
* Task D.3.1
* Task D.4
---
# Required Skills
## Governance
* AGENTS.md
* Architecture Rules
* Task Contract Template
* Task Review Checklist
## CRM
* Lead / Enquiry Governance
* Won / Lost Governance
* Customer Ownership Governance
## Backend
* Drizzle ORM Pattern
* Service Layer Pattern
* Route Handler Pattern
## Security
* CRM Authorization Foundation
---
# Scope D5.1.1 Create Lead Table
Add:
```txt
crm_leads
```
Initial fields:
```txt
id
organizationId
branchId
code
customerId
contactId
projectName
projectLocation
awarenessId
status
followupStatus
lostReason
outcome
ownerMarketingUserId
createdBy
createdAt
updatedAt
deletedAt
```
---
# Scope D5.1.2 Add Lead Reference to Enquiry
Modify:
```txt
crm_enquiries
```
Add:
```txt
leadId nullable
```
Relationship:
```txt
crm_leads 1
→ N crm_enquiries
```
No business flow changes yet.
---
# Scope D5.1.3 Master Data Foundation
Create categories:
```txt
crm_lead_awareness
crm_lead_status
crm_lead_followup_status
crm_lead_lost_reason
```
Seed:
## crm_lead_awareness
```txt
Google Search / Website
Social Media
Email Marketing
Visit
Exhibition / Event
Brochure / Catalog
Word of Mouth
Corporate Car
Product Label
Re-purchasing
Introducing
BCI
```
## crm_lead_status
```txt
new_job
follow_up
closed_lost
cancel
```
## crm_lead_followup_status
```txt
budget_pending
project_under_review
spec_clarification
site_survey_waiting
drawing_followup
quotation_preparation
waiting_po
```
## crm_lead_lost_reason
```txt
price
delivery_time
sales_person
service
customer
product_quality
other
transfer
```
---
# Scope D5.1.4 Document Sequence Foundation
Register:
```txt
crm_lead
crm_enquiry
```
Document codes:
```txt
Lead:
LD2606-001
Enquiry:
EN2606-001
```
Do not switch existing generation logic yet.
Only prepare configuration.
---
# Scope D5.1.5 Compatibility Layer
Keep existing:
```txt
crm_enquiries.pipeline_stage
```
unchanged.
No route behavior changes.
No dashboard changes.
No report changes.
No migration of existing records.
This task prepares schema only.
---
# Scope D5.1.6 Authorization Preparation
Add placeholders for future Lead permissions:
```txt
crm.lead.read
crm.lead.create
crm.lead.update
crm.lead.assign
crm.lead.delete
```
No enforcement changes yet.
---
# Deliverables
## New
* crm_leads schema
* lead master option categories
* lead document sequence configuration
* ADR-0018 references
---
## Modified
* crm_enquiries
* foundation.seed.ts
* document sequence seed
* CRM permission registry
---
# Explicit Non-Scope
Do NOT:
* create Lead APIs
* create Lead UI
* create Lead assignment workflow
* migrate existing records
* change dashboard
* change reports
* change security behavior
* remove pipeline_stage
Those belong to later D.5 phases.
---
# Verification
Verify:
```txt
npm exec tsc --noEmit
```
Verify:
```txt
crm_leads table exists
```
Verify:
```txt
crm_enquiries.leadId exists
```
Verify:
```txt
lead master options seeded
```
Verify:
```txt
document sequence configuration exists
```
Verify:
```txt
existing CRM workflow remains unchanged
```
---
# Definition of Done
Task is complete when:
* Lead schema exists
* Enquiry can reference Lead
* Lead master data exists
* Lead document sequence exists
* Existing CRM remains fully operational
* No business workflow regression occurs
Result:
Lead Foundation = Established
Compatibility Layer = Established
Ready for D.5.2 Lead APIs & UI

View File

@@ -266,6 +266,33 @@ export const crmCustomerContacts = pgTable('crm_customer_contacts', {
updatedBy: text('updated_by').notNull()
});
export const crmLeads = pgTable(
'crm_leads',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
code: text('code').notNull(),
customerId: text('customer_id'),
contactId: text('contact_id'),
projectName: text('project_name'),
projectLocation: text('project_location'),
awarenessId: text('awareness_id'),
status: text('status').default('new_job').notNull(),
followupStatus: text('followup_status'),
lostReason: text('lost_reason'),
outcome: text('outcome').default('open').notNull(),
ownerMarketingUserId: text('owner_marketing_user_id'),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
},
(table) => ({
organizationCodeIdx: uniqueIndex('crm_leads_org_code_idx').on(table.organizationId, table.code)
})
);
export const crmContactShares = pgTable(
'crm_contact_shares',
{
@@ -296,6 +323,7 @@ export const crmEnquiries = pgTable(
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
leadId: text('lead_id'),
code: text('code').notNull(),
customerId: text('customer_id').notNull(),
contactId: text('contact_id'),

View File

@@ -342,6 +342,105 @@ const FOUNDATION_OPTIONS = {
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 },
{ code: 'other', label: 'Other', value: 'other', sortOrder: 6 }
],
crm_lead_awareness: [
{
code: 'google_search_website',
label: 'Google Search / Website',
value: 'google_search_website',
sortOrder: 1
},
{ code: 'social_media', label: 'Social Media', value: 'social_media', sortOrder: 2 },
{
code: 'email_marketing',
label: 'Email Marketing',
value: 'email_marketing',
sortOrder: 3
},
{ code: 'visit', label: 'Visit', value: 'visit', sortOrder: 4 },
{
code: 'exhibition_event',
label: 'Exhibition / Event',
value: 'exhibition_event',
sortOrder: 5
},
{
code: 'brochure_catalog',
label: 'Brochure / Catalog',
value: 'brochure_catalog',
sortOrder: 6
},
{
code: 'word_of_mouth',
label: 'Word of Mouth',
value: 'word_of_mouth',
sortOrder: 7
},
{ code: 'corporate_car', label: 'Corporate Car', value: 'corporate_car', sortOrder: 8 },
{ code: 'product_label', label: 'Product Label', value: 'product_label', sortOrder: 9 },
{ code: 'repurchasing', label: 'Re-purchasing', value: 'repurchasing', sortOrder: 10 },
{ code: 'introducing', label: 'Introducing', value: 'introducing', sortOrder: 11 },
{ code: 'bci', label: 'BCI', value: 'bci', sortOrder: 12 }
],
crm_lead_status: [
{ code: 'new_job', label: 'New Job', value: 'new_job', sortOrder: 1 },
{ code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 2 },
{ code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 3 },
{ code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 4 }
],
crm_lead_followup_status: [
{
code: 'budget_pending',
label: 'Budget Pending',
value: 'budget_pending',
sortOrder: 1
},
{
code: 'project_under_review',
label: 'Project Under Review',
value: 'project_under_review',
sortOrder: 2
},
{
code: 'spec_clarification',
label: 'Spec Clarification',
value: 'spec_clarification',
sortOrder: 3
},
{
code: 'site_survey_waiting',
label: 'Site Survey Waiting',
value: 'site_survey_waiting',
sortOrder: 4
},
{
code: 'drawing_followup',
label: 'Drawing Follow-up',
value: 'drawing_followup',
sortOrder: 5
},
{
code: 'quotation_preparation',
label: 'Quotation Preparation',
value: 'quotation_preparation',
sortOrder: 6
},
{ code: 'waiting_po', label: 'Waiting PO', value: 'waiting_po', sortOrder: 7 }
],
crm_lead_lost_reason: [
{ code: 'price', label: 'Price', value: 'price', sortOrder: 1 },
{ code: 'delivery_time', label: 'Delivery Time', value: 'delivery_time', sortOrder: 2 },
{ code: 'sales_person', label: 'Sales Person', value: 'sales_person', sortOrder: 3 },
{ code: 'service', label: 'Service', value: 'service', sortOrder: 4 },
{ code: 'customer', label: 'Customer', value: 'customer', sortOrder: 5 },
{
code: 'product_quality',
label: 'Product Quality',
value: 'product_quality',
sortOrder: 6
},
{ code: 'other', label: 'Other', value: 'other', sortOrder: 7 },
{ code: 'transfer', label: 'Transfer', value: 'transfer', sortOrder: 8 }
],
crm_report_category: [
{ code: 'pipeline', label: 'Pipeline', value: 'pipeline', sortOrder: 1 },
{ code: 'sales', label: 'Sales', value: 'sales', sortOrder: 2 },
@@ -421,6 +520,8 @@ const DOCUMENT_SEQUENCES = [
{ documentType: 'customer', prefix: 'CUS' },
{ documentType: 'contact', prefix: 'CON' },
{ documentType: 'enquiry', prefix: 'ENQ' },
{ documentType: 'crm_lead', prefix: 'LD' },
{ documentType: 'crm_enquiry', prefix: 'EN' },
{ documentType: 'quotation', prefix: 'QT' },
{ documentType: 'approval', prefix: 'APV' }
];

View File

@@ -16,6 +16,8 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS',
contact: 'CON',
enquiry: 'ENQ',
crm_lead: 'LD',
crm_enquiry: 'EN',
quotation: 'QT',
approval: 'APV'
};