Compare commits
18 Commits
a9018ab0ff
...
80495a78a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 80495a78a6 | |||
| 29d67f6ca6 | |||
| ac31b9ba57 | |||
| 061dc8e260 | |||
| c9c03f8761 | |||
| 9c6c585073 | |||
| f5d7ccc1ab | |||
| f5022b613d | |||
| e99a28b7f7 | |||
| fb07f7ab61 | |||
| eeae163782 | |||
| 0ab90ee356 | |||
| 5ddbe525e6 | |||
| 2b73036bb9 | |||
| 3c95146f4a | |||
| 8d7805acc9 | |||
| ede68caefc | |||
| b6938abc7c |
27
.env.example
27
.env.example
@ -19,7 +19,8 @@ DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_SSL=false
|
||||
|
||||
# Email Configuration
|
||||
# Email: ENABLE_SMTP=true → real SMTP (GSM / EMAIL_*); false → Ethereal test inbox
|
||||
ENABLE_SMTP=false
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_SECURE=true
|
||||
@ -27,6 +28,14 @@ EMAIL_USER=your-email@gmail.com
|
||||
EMAIL_PASSWORD=your-app-password
|
||||
EMAIL_FROM="Royal Enfield <noreply@royalenfield.com>"
|
||||
|
||||
# Google Secret Manager (optional — fetches SMTP_* secrets into EMAIL_* env vars)
|
||||
USE_GOOGLE_SECRET_MANAGER=false
|
||||
# Optional — defaults to project_id inside the credentials JSON
|
||||
# GCP_PROJECT_ID=your-gcp-project-id
|
||||
GCP_KEY_FILE=./credentials/your-service-account.json
|
||||
GCP_SECRET_PREFIX=
|
||||
GCP_SECRET_MAP_FILE=./config/gcp-secret-map.smtp.example.json
|
||||
|
||||
# Web Push Notifications (VAPID)
|
||||
VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||
VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||
@ -35,3 +44,19 @@ VAPID_EMAIL=admin@royalenfield.com
|
||||
# File Uploads
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
|
||||
# Redis / BullMQ (required for scheduled jobs: SLA, LWD, questionnaire reminders, notifications)
|
||||
ENABLE_REDIS=false
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
# DEBUG_SLA_FAST_MODE=true # Internal SLA checks every minute (dev only)
|
||||
# DEBUG_SLA_REPEAT_IN_FAST_MODE=true # Also send repeat-overdue every minute in fast mode (usually OFF)
|
||||
# SLA_BUSINESS_HOURS=false # Disable Mon–Fri 9–18h TAT counting (default: on unless fast mode)
|
||||
# DEBUG_OFFBOARDING_LWD_FAST_MODE=true # LWD reminder sweep every 15 min (dev only)
|
||||
|
||||
# Prospect questionnaire reminders (scheduled — NOT internal SLA)
|
||||
QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED=true
|
||||
QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS=1
|
||||
QUESTIONNAIRE_REMINDER_INTERVAL_DAYS=2
|
||||
QUESTIONNAIRE_REMINDER_MAX_COUNT=5
|
||||
# DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE=true # Sweep every 15 min (dev only)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
build/assets/index-DqVo88us.css
Normal file
6
build/assets/index-DqVo88us.css
Normal file
File diff suppressed because one or more lines are too long
793
build/assets/index-XdyJ-8da.js
Normal file
793
build/assets/index-XdyJ-8da.js
Normal file
File diff suppressed because one or more lines are too long
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Royal Enfield Onboarding</title>
|
||||
<script type="module" crossorigin src="/assets/index-CAe70IkM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DkEVuJwH.css">
|
||||
<script type="module" crossorigin src="/assets/index-XdyJ-8da.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DqVo88us.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
8
config/gcp-secret-map.smtp.example.json
Normal file
8
config/gcp-secret-map.smtp.example.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"SMTP_HOST": "EMAIL_HOST",
|
||||
"SMTP_PORT": "EMAIL_PORT",
|
||||
"SMTP_USER": "EMAIL_USER",
|
||||
"SMTP_PASSWORD": "EMAIL_PASSWORD",
|
||||
"SMTP_SECURE": "EMAIL_SECURE",
|
||||
"EMAIL_FROM": "EMAIL_FROM"
|
||||
}
|
||||
13
credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
Normal file
13
credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "service_account",
|
||||
"project_id": "re-platform-workflow-dealer",
|
||||
"private_key_id": "89a922d4d0ea7743669856c4c56908e76d35a46b",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC+LBc4luOn56vz\n+lOarDmT9W9SaSpCysLbGFELAebwwEbzV/0AdbSSZCPezJgMeIjbgtbKMVoVXA79\nqFbeT4tNWrg+4+kSxHaUyaDnIStpc/x1WRAxpfzYDnmDrTuHuvJXJxs7zOlZO3Tz\nVfRFWLwiuwbOqj/sggmusMd9n8b2oJrtWAwGGnHLePo63kU+rz/DtDlhP9da7LG8\nTEgu7yRJ+CHI1OQnEz+HcjSxa2S2qhYIZkwjTy6DwyMBBO4vamou//RDUW+26Dxb\nDMstqS9KRtxfr4VENChn1TwOAQ1doCulzkmaptMwgYiJryO6XFx3T2KO4yDCUWTk\nuyA4gqQxAgMBAAECggEABmHxUlLVn5G15qeN2WbK/KtXzfO1wXW/zBXNCAYbgbRp\nWTLYfOTGb4SdA9X46y+7M6dYtULqZaXAIs0s47OWnr0zCnjQnnKxsmkRPXqic+zK\nsgdQBOOP2LJFK6lDcojwp+JKDqPwJ8CYKm/VxcNOdqmxZvulrzbW03ovStkDRu0S\ndM8Iw1NqpymbPDV57YPiXszx7mHnEIptpM1ODruulNnFMDXWImkmcWdRtJP0abHk\nz9hOQEC/KXCuoHXZl7EYdDhvrHssfzGTBajb0W2X3e5S25pmIyc9k1d8Jamvfyr0\nZYiOF1MaiGaUH39qI7rtWHic9vbxVC2BN/mUIXgYYQKBgQD493/C5fsFI40V1n9P\nM9RRTdmXtz20Tu/zOC/d8ohnqmc5Eyi2NwGIz99UqsVIIt/droPouz+pAJgrF6/8\nWv2CJsYh8d9beN4L8HCd8AEWbKzCZxcb2KQIVbVPJ7c0O8RmcNTqow2l8qqSRI+1\naX7kiQxD0Dbmmf43+Laf2INuUQKBgQDDi2VNPLQyxCEuG7TYjcwzN1bl+V/XeYmD\nrXTC+feuh4juluZiNdbLn8OR/ktF10BUgOOqJ9KaXDffTQbuJOoBVNBZ3+2VImHj\n+zZzMxceG4T1S8KcQrQgrBjtzmIYo2jux7x24HkQM9qU7sRFAOgq0IIi7tasmGkC\nkvYuTVz/4QKBgFgzxs2TkJTHfYpJDZ1PrV6IiBgZ0QB6HsQ6Gas162Fem2c7BGdZ\noW+IxYRHY9Ekmc79rrna7LjA/yf1ImHzEnDzr6oC+LB9Z50vN5acmqYJJkNRJny1\nCZfyVWOPnHYi3ne0bZoa3hD2obtkEs2gbFYmv3Oe5nRYBhpqQLjsidOhAoGAes6l\n3V8dcLCaggGmj0Zmk1fS/HWkSogq5AbgyL8CXZsDVYxxvgZAEvwQcDT7gy5PWYLk\n+G0wJ/94m4YdrxyB1jo06+zlof7I6cxQgwL4JtFzrDZbT5XY2Jgcw+UU2JJwCV5p\nr2MExTc7tMNLgmayaIkw4c2MBzNk59fyQlwV5yECgYBMOzR9dxY+8ExjK4OHCXqo\nu/ZM0VKz5dorS8QA2wq2xfl27wTe+4Kl4veMGQ74iO6Pb1z6buORXjTD2S4/SvPN\nl0LtrkftjHZVN9/X2NfOh4YmUUg5U6lXDFQJ6F1NLcS+ZRaIG5zKJNX3zLH0HHmb\nL+V24teGBIY4sN3OzJ2s9A==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "re-bridge-workflow@re-platform-workflow-dealer.iam.gserviceaccount.com",
|
||||
"client_id": "108776059196607325512",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/re-bridge-workflow%40re-platform-workflow-dealer.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
@ -1,28 +1,30 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Redis - Required for BullMQ background jobs (notifications, SLAs)
|
||||
# Uses host networking so Compose does not create a bridge (avoids DOCKER-FORWARD / iptables failures on some hosts).
|
||||
# Redis listens on the host on port 6379 (same as REDIS_PORT in .env).
|
||||
redis:
|
||||
image: redis:7.2-alpine
|
||||
container_name: re-onboarding-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
network_mode: host
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
|
||||
# RedisInsight - GUI for monitoring Redis/BullMQ (Optional)
|
||||
# Connect in the UI to host.docker.internal:6379 (extra_hosts maps that to the host where Redis runs).
|
||||
redis-insight:
|
||||
image: redislabs/redisinsight:latest
|
||||
container_name: re-onboarding-redis-insight
|
||||
restart: always
|
||||
ports:
|
||||
- "8001:8001"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# PostgreSQL - Application Database
|
||||
# PostgreSQL - Uses the default bridge; `docker compose up` (full stack) still needs iptables/Docker networking working.
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: re-onboarding-db
|
||||
|
||||
@ -32,7 +32,7 @@ A major addition allowing onboarded dealers to manage their own lifecycle via th
|
||||
- **Manual Trigger:** Dealer codes are no longer auto-generated; they require a manual trigger by **DD Admin** for creation in SAP Master.
|
||||
|
||||
### 3.2 Sequence Corrections
|
||||
- **LOA before EOR:** The workflow now ensures the **Letter of Authorization (LOA)** is issued *before* starting the **Essential Operating Requirements (EOR)** checklist.
|
||||
- **LOA before EOR:** The workflow now ensures the **Letter of Agreement (LOA)** is issued *before* starting the **Essential Operating Requirements (EOR)** checklist.
|
||||
|
||||
### 3.3 Settlement Logic
|
||||
- **LWD-Based Trigger:** F&F (Full & Final) settlements are strictly triggered on the **Last Working Day (LWD)**, ensuring accuracy regardless of when the resignation was approved.
|
||||
|
||||
@ -40,6 +40,8 @@ These constants are the primary drivers of application logic and stage-gate cont
|
||||
| `LOA Issued` | Final Appointment Letter issued |
|
||||
| `Onboarded` | System provisioning complete (Active Dealer) |
|
||||
|
||||
> **Full status list (sequential order, UI mapping, parallel statutory/architecture):** see [ONBOARDING_APPLICATION_STATUSES.md](./ONBOARDING_APPLICATION_STATUSES.md).
|
||||
|
||||
### 📦 Request Types (`REQUEST_TYPES`)
|
||||
- `application`: New Onboarding
|
||||
- `resignation`: Voluntary Exit
|
||||
|
||||
237
docs/ONBOARDING_APPLICATION_STATUSES.md
Normal file
237
docs/ONBOARDING_APPLICATION_STATUSES.md
Normal file
@ -0,0 +1,237 @@
|
||||
# Onboarding — Application Status Reference
|
||||
|
||||
This document lists every **`overallStatus`** value used in dealer onboarding: what the UI shows, how statuses map to pipeline progress, and the typical lifecycle order from application submission through inauguration and onboarding.
|
||||
|
||||
**Source of truth (code):**
|
||||
|
||||
| Area | Path |
|
||||
|------|------|
|
||||
| Status constants | `backend/src/common/config/constants.ts` → `APPLICATION_STATUS` |
|
||||
| Status → pipeline stage | `backend/src/common/utils/progress.ts` → `PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS` |
|
||||
| Progress milestones | `backend/src/common/utils/progress.ts` → `ONBOARDING_STAGES` |
|
||||
| Status → DB `currentStage` | `backend/src/common/config/constants.ts` → `OVERALL_STATUS_TO_DB_CURRENT_STAGE` |
|
||||
| UI type / badges | `frontend/src/lib/mock-data.ts` → `ApplicationStatus` |
|
||||
|
||||
---
|
||||
|
||||
## Three layers (do not confuse)
|
||||
|
||||
| Layer | Field / table | Shown in UI as | Purpose |
|
||||
|-------|----------------|----------------|---------|
|
||||
| **Overall status** | `Application.overallStatus` | Status badge on lists, cards, Application Details sidebar | Fine-grained lifecycle label (this document) |
|
||||
| **Current stage** | `Application.currentStage` | Sometimes in filters / audit; coarser enum | Role-based workflow stage (`DD`, `FDD`, `LOI`, `LOA`, `EOR`, etc.) |
|
||||
| **Progress milestones** | `ApplicationProgress.stageName` | Application Details → **Progress** tab (“Application Journey”) | Ordered pipeline steps (`Submitted`, `Questionnaire`, `Shortlist`, …) |
|
||||
|
||||
`overallStatus` is what users most often see as **Status**. Progress tab labels can differ (e.g. badge **Level 1 Interview Pending** maps to milestone **1st Level Interview**).
|
||||
|
||||
---
|
||||
|
||||
## Happy path — sequential `overallStatus` (submission → inauguration)
|
||||
|
||||
Typical forward flow. Not every application visits every row (policy gates, parallel work, and admin shortcuts can skip or reorder steps).
|
||||
|
||||
| # | `overallStatus` (UI label) | Phase | Notes |
|
||||
|---|---------------------------|-------|-------|
|
||||
| 1 | **Submitted** | Intake | Default when no active opportunity; non-opportunity applications stay here until questionnaire is sent |
|
||||
| 2 | **Questionnaire Pending** | Questionnaire | Default when application is tied to an active opportunity |
|
||||
| 3 | **Questionnaire Completed** | Questionnaire | Set when prospect submits public questionnaire |
|
||||
| 4 | **Shortlisted** | Evaluation | DD shortlists; interviews can be scheduled |
|
||||
| 5 | **Level 1 Interview Pending** | Interview L1 | Set when Level 1 interview is scheduled |
|
||||
| 6 | **Level 1 Approved** | Interview L1 | Level 1 panel decision (policy-managed) |
|
||||
| 7 | **Level 2 Interview Pending** | Interview L2 | Level 2 scheduled |
|
||||
| 8 | **Level 2 Approved** | Interview L2 | Level 2 passed |
|
||||
| 9 | **Level 3 Interview Pending** | Interview L3 | Level 3 scheduled |
|
||||
| 10 | **Level 3 Approved** | Interview L3 | Optional; admin quick-approve path before FDD |
|
||||
| 11 | **FDD Verification** | FDD | Financial due diligence; interview L3 approval may jump here directly |
|
||||
| 12 | **LOI In Progress** | LOI | LOI approval workflow (DD Head / NBH policy) |
|
||||
| 13 | **Payment Pending** | Security deposit | Finance verification of security deposit |
|
||||
| 14 | **Security Deposit** | Security deposit | LOI approved; deposit verified; gate before LOI issue (`SECURITY_DETAILS` constant) |
|
||||
| 15 | **LOI Issued** | LOI | Letter of Intent issued to applicant |
|
||||
| 16 | **Dealer Code Generation** | SAP / codes | Dealer codes generated (DD Admin trigger) |
|
||||
| 17 | **Architecture Team Assigned** | Architecture (parallel) | Architecture team assigned |
|
||||
| 18 | **Architecture Document Upload** | Architecture (parallel) | Site / blueprint uploads |
|
||||
| 19 | **Architecture Team Completion** | Architecture (parallel) | Architecture track complete |
|
||||
| 20 | **LOA Pending** | LOA | LOA approval; may require First Fill verified by Finance |
|
||||
| 21 | **LOA Issued** | LOA | LOA document generated (policy path) |
|
||||
| 22 | **EOR In Progress** | EOR | EOR checklist started |
|
||||
| 23 | **EOR Complete** | EOR | All EOR items compliant |
|
||||
| 24 | **Inauguration** | Go-live | EOR audit submitted as Completed; dealership ready for inauguration |
|
||||
| 25 | **Approved** | Go-live | Treated like inauguration in progress / progress sync |
|
||||
| 26 | **Onboarded** | Active dealer | Final onboarding after inauguration action; dealer profile active |
|
||||
|
||||
**Short path (badge only):**
|
||||
`Submitted` → `Questionnaire Pending` → `Questionnaire Completed` → `Shortlisted` → … → `Inauguration` → `Onboarded`
|
||||
|
||||
---
|
||||
|
||||
## Statutory sub-statuses (parallel with architecture)
|
||||
|
||||
After **Dealer Code Generation**, statutory compliance can surface granular statuses (all map to pipeline **Statutory Work**):
|
||||
|
||||
| `overallStatus` | Meaning |
|
||||
|-----------------|---------|
|
||||
| **Statutory GST** | GST certificate step |
|
||||
| **Statutory PAN** | PAN step |
|
||||
| **Statutory Nodal** | Nodal agreement |
|
||||
| **Statutory Check** | Cancelled check / bank proof |
|
||||
| **Statutory Partnership** | Partnership / LLP / MOA documents |
|
||||
| **Statutory Firm Reg** | Firm registration |
|
||||
| **Statutory Rental** | Property / rental agreement |
|
||||
| **Statutory Virtual Code** | Virtual code |
|
||||
| **Statutory Domain** | Domain setup |
|
||||
| **Statutory MSD** | MSD configuration |
|
||||
| **Statutory LOI Ack** | LOI acknowledgement copy |
|
||||
| **Statutory Work** | Generic statutory milestone |
|
||||
|
||||
Generic architecture labels: **Architecture Work**, plus team assignment statuses in the happy-path table above.
|
||||
|
||||
---
|
||||
|
||||
## Alternate / branch statuses
|
||||
|
||||
| `overallStatus` | When used |
|
||||
|-----------------|-----------|
|
||||
| **Pending** | Legacy / initial; progress sync maps to **Submitted** |
|
||||
| **In Review** | Legacy; maps to pipeline **Shortlist** |
|
||||
| **Level 2 Recommended** | Alternate Level 2 outcome (still 2nd Level Interview pipeline) |
|
||||
| **Returned to FDD** | Application sent back to FDD from a later stage |
|
||||
| **Security Details** | **Deprecated** DB label; same stage as **Security Deposit** — migrate to **Security Deposit** |
|
||||
|
||||
Frontend also styles optional labels that may appear from older APIs or composite UI (see `frontend/src/lib/mock-data.ts`): e.g. **LOI Approved**, **Security Deposit In Progress**, **Architecture Work In Progress**, **Dealer Code Generated**, etc.
|
||||
|
||||
---
|
||||
|
||||
## Terminal (end-state) statuses
|
||||
|
||||
| `overallStatus` | Outcome |
|
||||
|-----------------|---------|
|
||||
| **Rejected** | Application rejected (any stage) |
|
||||
| **Disqualified** | Disqualified |
|
||||
| **LOI Rejected** | LOI approval rejected |
|
||||
| **LOA Rejected** | LOA approval rejected |
|
||||
|
||||
---
|
||||
|
||||
## Progress tab milestones (`ONBOARDING_STAGES`)
|
||||
|
||||
Shown on Application Details → **Progress** (not always identical to the status badge text):
|
||||
|
||||
| Order | Milestone name | Parallel |
|
||||
|------:|----------------|----------|
|
||||
| 1 | Submitted | |
|
||||
| 2 | Questionnaire | |
|
||||
| 3 | Shortlist | |
|
||||
| 4 | 1st Level Interview | |
|
||||
| 5 | 2nd Level Interview | |
|
||||
| 6 | 3rd Level Interview | |
|
||||
| 7 | FDD | |
|
||||
| 8 | LOI Approval | |
|
||||
| 9 | Security Deposit | |
|
||||
| 10 | LOI Issue | |
|
||||
| 11 | Dealer Code Generation | |
|
||||
| 12 | Architecture Work | Yes (order 12) |
|
||||
| 12 | Statutory Work | Yes (order 12) |
|
||||
| 13 | LOA | |
|
||||
| 14 | EOR Complete | |
|
||||
| 15 | Inauguration | |
|
||||
| 16 | Onboarded | |
|
||||
|
||||
UI journey definition: `frontend/src/features/onboarding/hooks/useApplicationDetailsStageData.ts` (`processStages`).
|
||||
|
||||
---
|
||||
|
||||
## Mapping: `overallStatus` → progress milestone
|
||||
|
||||
Used by `syncApplicationProgress()` and SLA stage naming:
|
||||
|
||||
| `overallStatus` | Pipeline milestone (`ApplicationProgress.stageName`) |
|
||||
|-----------------|-----------------------------------------------------|
|
||||
| Submitted | Submitted |
|
||||
| Questionnaire Pending / Completed | Questionnaire |
|
||||
| Shortlisted, In Review | Shortlist |
|
||||
| Level 1 Interview Pending / Approved | 1st Level Interview |
|
||||
| Level 2 Interview Pending / Approved / Recommended | 2nd Level Interview |
|
||||
| Level 3 Interview Pending / Approved | 3rd Level Interview |
|
||||
| FDD Verification, Returned to FDD | FDD |
|
||||
| LOI In Progress | LOI Approval |
|
||||
| Payment Pending, Security Deposit, Security Details | Security Deposit |
|
||||
| LOI Issued, Statutory LOI Ack, LOI Rejected | LOI Issue |
|
||||
| Dealer Code Generation | Dealer Code Generation |
|
||||
| Architecture Team * / Architecture Work | Architecture Work |
|
||||
| Statutory * / Statutory Work | Statutory Work |
|
||||
| LOA Pending / LOA Issued / LOA Rejected | LOA |
|
||||
| EOR In Progress / EOR Complete | EOR Complete |
|
||||
| Inauguration / Approved | Inauguration |
|
||||
| Onboarded | Onboarded |
|
||||
| Rejected / Disqualified | Rejected |
|
||||
|
||||
Full map: `PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS` in `progress.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Mapping: `overallStatus` → `currentStage` (DB enum)
|
||||
|
||||
Coarser internal stage on `Application.currentStage` (see `OVERALL_STATUS_TO_DB_CURRENT_STAGE`):
|
||||
|
||||
| `currentStage` | Example `overallStatus` values |
|
||||
|----------------|--------------------------------|
|
||||
| `DD` | Submitted, Questionnaire *, Shortlisted, Level * Pending |
|
||||
| `Level 1 Approved` | Level 1 Approved |
|
||||
| `Level 2 Approved` | Level 2 Approved |
|
||||
| `Level 2 Recommended` | Level 2 Recommended |
|
||||
| `Level 3 Approved` | Level 3 Approved |
|
||||
| `FDD` | FDD Verification, Returned to FDD |
|
||||
| `LOI` | LOI In Progress, Security Deposit, Payment Pending, LOI Issued, Dealer Code Generation, Statutory LOI Ack |
|
||||
| `Architecture Work` | Architecture Team *, Architecture Work |
|
||||
| `Statutory Work` | Statutory * |
|
||||
| `LOA` | LOA Pending, LOA Issued, LOA Rejected |
|
||||
| `EOR` | EOR In Progress, EOR Complete |
|
||||
| `Approved` | Inauguration, Onboarded, Approved |
|
||||
| `Rejected` | Rejected, Disqualified, LOI Rejected |
|
||||
|
||||
---
|
||||
|
||||
## Key transitions (implementation notes)
|
||||
|
||||
| Trigger | Typical transition |
|
||||
|---------|-------------------|
|
||||
| Application create (opportunity) | → **Questionnaire Pending** |
|
||||
| Application create (no opportunity) | → **Submitted** |
|
||||
| Public questionnaire submit | → **Questionnaire Completed** |
|
||||
| Bulk / single shortlist | → **Shortlisted** |
|
||||
| Schedule interview level N | → **Level N Interview Pending** |
|
||||
| Interview L1/L2 approve (policy) | → **Level 1 Approved** / **Level 2 Approved** |
|
||||
| Interview L3 approve (policy) | → **FDD Verification** (may skip **Level 3 Approved**) |
|
||||
| FDD complete / admin approve | → **LOI In Progress** |
|
||||
| LOI policy + deposit verified | → **Security Deposit** |
|
||||
| LOI issued | → **LOI Issued** → **Dealer Code Generation** |
|
||||
| Dealer codes created (integrity service) | → **LOA Pending** (architecture/statutory may continue in parallel) |
|
||||
| LOA policy met | → **EOR In Progress** (or **LOA Issued** on policy path) |
|
||||
| EOR checklist Completed | → **Inauguration** |
|
||||
| Dealer onboard action | → **Onboarded** (requires **Inauguration** / **Approved** and EOR complete) |
|
||||
|
||||
Admin sequential override (frontend): `useApplicationDetailsAdminActions.ts` — manual **Approve** button steps through statuses when policy does not apply.
|
||||
|
||||
---
|
||||
|
||||
## UI surfaces by page
|
||||
|
||||
| Page / component | Statuses typically shown |
|
||||
|------------------|-------------------------|
|
||||
| **Opportunity Requests** | Questionnaire Pending, Questionnaire Completed, Shortlisted |
|
||||
| **All Applications (DD)** | Early statuses (Submitted, Questionnaire *, Shortlisted, interview pending) |
|
||||
| **Applications / cards** | All `overallStatus` values from API |
|
||||
| **Application Details** | Current `overallStatus` badge + Progress tab milestones |
|
||||
| **Non-Opportunities** | **Submitted** (not yet in opportunity flow) |
|
||||
|
||||
---
|
||||
|
||||
## Related documentation
|
||||
|
||||
- Module overview: `modular_wise/01_Dealer_Onboarding.md`
|
||||
- SLA vs applicant reminders: `sla/ONBOARDING_SLA_RULES.md`
|
||||
- Data flows summary: `Detailed_Module_Data_Flows.md`
|
||||
|
||||
---
|
||||
|
||||
*Last aligned with codebase: `APPLICATION_STATUS` and `progress.ts` as of document creation.*
|
||||
118
docs/RE_Dealer_Email_Content_Client_Brief.md
Normal file
118
docs/RE_Dealer_Email_Content_Client_Brief.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Royal Enfield Dealer Onboarding — Email Content Brief (Client & Stakeholders)
|
||||
|
||||
**Audience:** Marketing, Legal, Dealer Development leadership, and anyone preparing or approving customer-facing copy.
|
||||
**Not a technical document:** This brief explains *what* each communication is for, *who* sees it, and *what your team should supply*. Engineering implements delivery; you own the words and policy.
|
||||
|
||||
**Where to edit live copy:** After go-live, authorised users can change subject lines and HTML bodies in the application under **Master Configuration → Email Templates** without developer involvement. Run the seed script only when adding *new* template types.
|
||||
|
||||
**Companion (technical) catalogue:** For merge-field names, file paths, and automation triggers tied to code, see `RE_Dealer_Email_Templates.md`.
|
||||
|
||||
---
|
||||
|
||||
## How to use this document
|
||||
|
||||
1. **Review each row** in the tables below. Decide whether the default copy fits your brand and regulatory needs.
|
||||
2. **Fill the “Your team should confirm” column** with decisions (e.g. exact deposit amounts, bank account text, legal disclaimers, contact paths).
|
||||
3. **Send your marked-up copy** back to the implementation team *or* enter it directly in **Email Templates** in the admin portal.
|
||||
4. **Placeholders** shown in curly braces (e.g. `{{applicantName}}`) are replaced automatically by the system—do not remove them unless Product agrees to drop that personalisation.
|
||||
|
||||
**Tone guidance (default):** Professional, warm, concise; Royal Enfield voice; avoid jargon where a plain “next step” works; always include a clear call-to-action and support path.
|
||||
|
||||
---
|
||||
|
||||
## A. Onboarding — Applicant (prospect) journey
|
||||
|
||||
| # | Working name | Purpose (plain English) | Who receives it | When it is sent (business event) | Your team should confirm |
|
||||
|---|--------------|-------------------------|-----------------|----------------------------------|---------------------------|
|
||||
| A1 | Opportunity available | Invite applicant to complete the assessment questionnaire | Applicant email | Location is in an open opportunity window | Questionnaire link text, support contact |
|
||||
| A2 | Non-opportunity regret | Politely close the lead when no window exists | Applicant | Application submitted, no opportunity | Regret wording, future contact policy |
|
||||
| A3 | Questionnaire submitted | Acknowledge receipt of assessment | Applicant | Questionnaire submitted | Optional next-step expectation |
|
||||
| A4 | Questionnaire reminder | Nudge to complete pending questionnaire | Applicant (+ WhatsApp if mobile on file) | Bulk reminder from DD-Admin | Reminder frequency policy |
|
||||
| A5 | Shortlisted | Congratulate and point to portal | Applicant | Bulk or single shortlist | Portal URL / “what happens next” |
|
||||
| A6 | **Prospect document request** | Ask for KYC / business documents after shortlist | Applicant (+ WhatsApp if available) | Immediately after shortlist | **Full checklist** (PAN, net worth, bank statements, etc.), deadlines, file rules |
|
||||
| A7 | Application rejected | Formal rejection with optional reason | Applicant | Rejection at any evaluation stage | Standard reason codes, escalation contact |
|
||||
| A8 | Interview (applicant / panelist) | Schedule, reschedule, or cancel interviews | Applicant and/or panelists | Interview actions in assessment module | Meeting etiquette, virtual link policy |
|
||||
| A9 | LOI issued | LOI available on portal | Applicant (email only per policy) | LOI document generated | LOI legal disclaimer, portal path |
|
||||
| A10 | **Statutory document request** | Collect licences, NOCs, deeds, GST, etc. post-LOI | Applicant | After LOI issuance email | **Authoritative statutory list** for your state/entity types |
|
||||
| A11 | **LOI acknowledgement reminder** | Chase if LOI not acknowledged | Applicant (+ WhatsApp if available) | **Manual:** DD-Admin sends bulk “LOI ack reminders” for selected applications | Deadline text, consequences of non-ack |
|
||||
| A12 | **Security deposit request** | Request deposit and proof upload | Applicant (+ WhatsApp if available) | After applicant acknowledges LOI | **Amount**, bank vs online flow, timeline |
|
||||
| A13 | Payment verified (internal) | Finance verified deposit | DD-Admin / DD-Lead | Finance marks payment paid | Internal wording only |
|
||||
| A14 | LOA issued | Appointment letter ready | Applicant + internal teams | LOA fully approved | Celebration tone, dealer code display rules |
|
||||
| A15 | **Dealership agreement signature** | E-sign request for agreement | Applicant | Same event as LOA issuance (second email) | **Agreement** legal intro, signing deadline |
|
||||
| A16 | Dealer codes ready | SAP sales/service codes | Applicant | Dealer code generation stage | Code formatting, next operational steps |
|
||||
| A17 | **FDD document request** | Financial due diligence checklist | Applicant | FDD agency assigned | **FDD checklist**, partner naming, confidentiality line |
|
||||
| A18 | **Architecture / site inputs** | Request drawings, photos, civic approvals | Applicant | Architecture lead assigned on application | **Input list** aligned with your architecture SOP |
|
||||
| A19 | **Document received acknowledgement** | Confirm each upload | Applicant | Each document upload to the application | Whether per-file email is acceptable volume-wise |
|
||||
| A20 | **Document submission reminder** | Remind pending uploads | Applicant (+ WhatsApp if available) | **Manual:** bulk “document reminders” from DD-Admin | Default pending text vs customised list per campaign |
|
||||
| A21 | **Document rejected — resubmit** | Explain rejection and ask for corrected file | Applicant (+ WhatsApp if available) | DD-Admin / DD-Lead / Legal rejects a document on portal | Standard rejection categories, appeal path |
|
||||
| A22 | Onboarding status update | Generic status change | Applicant | Many workflow transitions | When to use vs specific templates |
|
||||
| A23 | EOR complete | All EOR items verified | DD-Head / NBH (configurable) | EOR checklist 100% | Internal handoff language |
|
||||
| A24 | Inauguration logged | Dealership live | Internal teams | Inauguration recorded | Distribution list |
|
||||
|
||||
---
|
||||
|
||||
## B. Collaboration & workflow (internal and dealer)
|
||||
|
||||
| # | Working name | Purpose | Who receives it | When | Your team should confirm |
|
||||
|---|--------------|---------|-----------------|------|---------------------------|
|
||||
| B1 | User assigned | Someone joined the case as participant | Assigned user | Participant added | Role titles |
|
||||
| B2 | Worknote mention | @mention in worknote | Mentioned user | Mention in collaboration | — |
|
||||
| B3 | Action required | Next approver’s turn | Next actor (+ WhatsApp if phone) | Workflow advance / send-back | CTA labels |
|
||||
| B4 | Dealer status update | Milestone to dealer | Dealer / applicant | Interim or terminal workflow events | Terminal vs interim policy |
|
||||
|
||||
---
|
||||
|
||||
## C. Resignation, termination, relocation, constitutional change, F&F, SLA
|
||||
|
||||
Use the same pattern: confirm legal wording for **termination**, **show-cause**, **F&F amounts**, and **SLA** escalation text with Legal and Finance. Detailed rows can mirror Section A format in your next revision; the technical catalogue lists every code already live.
|
||||
|
||||
---
|
||||
|
||||
## D. Operational notes for your team
|
||||
|
||||
1. **Bulk actions (DD-Admin):**
|
||||
- **Questionnaire reminders** — existing endpoint.
|
||||
- **Document submission reminders** — new; send to selected `applicationIds`; optional custom “pending” paragraph.
|
||||
- **LOI acknowledgement reminders** — new; only sends if LOI is approved, a generated LOI document exists, and **no** acknowledgement record is present yet.
|
||||
|
||||
2. **Document rejection:** DD-Admin, DD-Lead, Legal Admin, or Super Admin can call the reject API (or future UI button) with a **mandatory reason**; the applicant receives the resubmission email.
|
||||
|
||||
3. **WhatsApp:** Where a template supports WhatsApp, the system uses the mobile number on the applicant or user record. If no number is stored, email is still sent.
|
||||
|
||||
4. **Default checklists in code:** Until you replace them in **Email Templates**, the system uses sensible default bullet lists for prospect documents, statutory documents, FDD, and architecture inputs. **Legal should replace these** with your official lists.
|
||||
|
||||
---
|
||||
|
||||
## E. Sign-off sheet (optional)
|
||||
|
||||
| Template (working name) | Owner | Copy approved (Y/N) | Legal approved (Y/N) | Effective date |
|
||||
|-------------------------|-------|---------------------|------------------------|------------------|
|
||||
| Prospect document request | | | | |
|
||||
| Statutory document request | | | | |
|
||||
| LOI acknowledgement reminder | | | | |
|
||||
| Security deposit request | | | | |
|
||||
| Dealership agreement signature | | | | |
|
||||
| FDD document request | | | | |
|
||||
| Architecture / site inputs | | | | |
|
||||
| Document received acknowledgement | | | | |
|
||||
| Document submission reminder | | | | |
|
||||
| Document rejected — resubmit | | | | |
|
||||
|
||||
---
|
||||
|
||||
## F. Reference — API endpoints (for IT / integration; not for dealers)
|
||||
|
||||
All routes require an authenticated staff token (same as other onboarding APIs).
|
||||
|
||||
| Action | Method & path | JSON body |
|
||||
|--------|----------------|-----------|
|
||||
| Bulk document upload reminder | `POST /api/onboarding/applications/document-reminders` | `{ "applicationIds": ["<uuid>", ...], "pendingDocuments": "<optional paragraph>", "dueDate": "<optional display string>" }` |
|
||||
| Bulk LOI acknowledgement reminder | `POST /api/onboarding/applications/loi-ack-reminders` | `{ "applicationIds": ["<uuid>", ...], "dueDate": "<optional>" }` — skips applications that already have an LOI acknowledgement on the latest generated LOI document |
|
||||
| Reject an uploaded document | `POST /api/onboarding/applications/:id/documents/:documentId/reject` | `{ "rejectionReason": "<required>", "dueDate": "<optional>" }` — roles: DD Admin, DD Lead, Legal Admin, Super Admin |
|
||||
|
||||
---
|
||||
|
||||
**Document version:** 1.0
|
||||
**Aligned with application build:** May 2026
|
||||
|
||||
When you update copy in production, record the change owner and date in your own change log; the portal stores template versions in the database.
|
||||
1848
docs/RE_Dealer_Email_Templates.md
Normal file
1848
docs/RE_Dealer_Email_Templates.md
Normal file
File diff suppressed because it is too large
Load Diff
1793
docs/RE_Dealer_System_TestStories.md
Normal file
1793
docs/RE_Dealer_System_TestStories.md
Normal file
File diff suppressed because it is too large
Load Diff
163
docs/RE_Dealer_Test_Coverage_Tracker.md
Normal file
163
docs/RE_Dealer_Test_Coverage_Tracker.md
Normal file
@ -0,0 +1,163 @@
|
||||
# RE Dealer System - Test Coverage Tracker
|
||||
|
||||
This tracker is maintained separately from the test stories document.
|
||||
Update module by module as execution progresses.
|
||||
|
||||
---
|
||||
|
||||
## Status Legend
|
||||
|
||||
- `Not Started` - Test story not executed yet
|
||||
- `In Progress` - Execution started but not finalized
|
||||
- `Executed` - Execution completed
|
||||
- `Blocked` - Cannot execute due to dependency/environment issue
|
||||
|
||||
Result values:
|
||||
- `Pass`
|
||||
- `Fail`
|
||||
- `NA` (Not applicable or not executed yet)
|
||||
|
||||
---
|
||||
|
||||
## Overall Coverage
|
||||
|
||||
| Metric | Count |
|
||||
|---|---:|
|
||||
| Total Test Stories | 61 |
|
||||
| Executed | 0 |
|
||||
| Passed | 0 |
|
||||
| Failed | 0 |
|
||||
| Blocked | 0 |
|
||||
| Remaining | 61 |
|
||||
|
||||
---
|
||||
|
||||
## Module Summary
|
||||
|
||||
| Module | Total | Executed | Passed | Failed | Blocked | Remaining |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Module 1 - Dealer Onboarding | 22 | 0 | 0 | 0 | 0 | 22 |
|
||||
| Module 2 - Dealer Resignation | 9 | 0 | 0 | 0 | 0 | 9 |
|
||||
| Module 3 - Dealer Termination | 12 | 0 | 0 | 0 | 0 | 12 |
|
||||
| Module 4 - Constitutional Change | 3 | 0 | 0 | 0 | 0 | 3 |
|
||||
| Module 5 - Dealer Relocation | 3 | 0 | 0 | 0 | 0 | 3 |
|
||||
| Module 6 - Full and Final (F&F) Settlement | 6 | 0 | 0 | 0 | 0 | 6 |
|
||||
| Module 7 - Finance Dashboard | 2 | 0 | 0 | 0 | 0 | 2 |
|
||||
| Module 8 - Admin and System Configuration | 4 | 0 | 0 | 0 | 0 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## Module 1 - Dealer Onboarding
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1.1.1 | Applicant Submits Application (Opportunity Location) | Not Started | NA | | | |
|
||||
| 1.1.2 | Applicant Submits Application (Non-Opportunity Location) | Not Started | NA | | | |
|
||||
| 1.1.3 | Application Submission Validation Failure | Not Started | NA | | | |
|
||||
| 1.2.1 | Applicant Completes Questionnaire | Not Started | NA | | | |
|
||||
| 1.3.1 | DD-Admin Shortlists Application | Not Started | NA | | | |
|
||||
| 1.3.2 | DD-Admin Archives Application | Not Started | NA | | | |
|
||||
| 1.4.1 | DD-Admin Schedules Interview (Level 1) | Not Started | NA | | | |
|
||||
| 1.4.2 | DD-ZM + RBM Fill KT Matrix and Feedback (Level 1) | Not Started | NA | | | |
|
||||
| 1.4.3 | Level 2 Evaluation (DD-Lead + ZBH) | Not Started | NA | | | |
|
||||
| 1.4.4 | Level 3 Final Evaluation and AI Summary (NBH + DD-Head) | Not Started | NA | | | |
|
||||
| 1.5.1 | FDD Partner Submits Due Diligence Report | Not Started | NA | | | |
|
||||
| 1.5.2 | Finance Team Reviews FDD Report | Not Started | NA | | | |
|
||||
| 1.6.1 | DD-Admin Triggers LOI Document Request | Not Started | NA | | | |
|
||||
| 1.6.2 | Security Deposit Validation | Not Started | NA | | | |
|
||||
| 1.6.3 | LOI Approval Chain (Finance -> DD-Head -> NBH) | Not Started | NA | | | |
|
||||
| 1.6.4 | LOI Issuance to Applicant | Not Started | NA | | | |
|
||||
| 1.7.1 | DD-Admin Triggers Dealer Code Creation | Not Started | NA | | | |
|
||||
| 1.8.1 | Architectural Work Assignment and Completion | Not Started | NA | | | |
|
||||
| 1.8.2 | Statutory Document Collection and Verification | Not Started | NA | | | |
|
||||
| 1.9.1 | LOA Approval and Issuance | Not Started | NA | | | |
|
||||
| 1.10.1 | EOR Completion by Functional Teams | Not Started | NA | | | |
|
||||
| 1.11.1 | Dealership Inauguration and Closure | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 2 - Dealer Resignation
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2.1 | Dealer Initiates Resignation via Portal | Not Started | NA | | | |
|
||||
| 2.2 | DD-ASM Reviews and Forwards Resignation | Not Started | NA | | | |
|
||||
| 2.3 | RBM + DD-ZM Joint Evaluation | Not Started | NA | | | |
|
||||
| 2.4 | ZBH Review | Not Started | NA | | | |
|
||||
| 2.5 | DD-Lead Review and Presentation | Not Started | NA | | | |
|
||||
| 2.6 | NBH Final Approval | Not Started | NA | | | |
|
||||
| 2.7 | Legal Issues Resignation Acceptance Letter | Not Started | NA | | | |
|
||||
| 2.8 | DD-Admin Closure and F&F Trigger | Not Started | NA | | | |
|
||||
| 2.9 | Dealer Withdraws Resignation Request | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 3 - Dealer Termination
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 3.1 | ASM Creates Termination Request | Not Started | NA | | | |
|
||||
| 3.2 | RBM + DD-ZM Review | Not Started | NA | | | |
|
||||
| 3.3 | ZBH Review | Not Started | NA | | | |
|
||||
| 3.4 | DD-Lead Review and Legal Assignment | Not Started | NA | | | |
|
||||
| 3.5 | Legal Verification | Not Started | NA | | | |
|
||||
| 3.6 | DD-Head Review -> NBH Evaluation | Not Started | NA | | | |
|
||||
| 3.7 | Show Cause Notice (SCN) Issuance | Not Started | NA | | | |
|
||||
| 3.8 | Joint Review of Dealer Response -> NBH Final Decision | Not Started | NA | | | |
|
||||
| 3.9 | CEO and CCO Final Authorization | Not Started | NA | | | |
|
||||
| 3.10 | Legal Issues Termination Letter | Not Started | NA | | | |
|
||||
| 3.11 | DD-Admin Communication and F&F Trigger | Not Started | NA | | | |
|
||||
| 3.12 | Immediate Termination (Unethical Practice) | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 4 - Constitutional Change
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 4.1 | Dealer Initiates Constitutional Change Request | Not Started | NA | | | |
|
||||
| 4.2 | Multi-Level Review and Approval | Not Started | NA | | | |
|
||||
| 4.3 | Legal Validation and Master Data Update | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 5 - Dealer Relocation
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 5.1 | Dealer Initiates Relocation Request | Not Started | NA | | | |
|
||||
| 5.2 | Multi-Level Review and Approval | Not Started | NA | | | |
|
||||
| 5.3 | Final Approval and Master Data Update | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 6 - Full and Final (F&F) Settlement
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 6.1 | F&F Case Initiation | Not Started | NA | | | |
|
||||
| 6.2 | Department Clearance Submission (All 16 Departments) | Not Started | NA | | | |
|
||||
| 6.3 | Finance Consolidates F&F Summary | Not Started | NA | | | |
|
||||
| 6.4 | Dealer Discussion and Acknowledgment | Not Started | NA | | | |
|
||||
| 6.5 | Final Finance Approval and Payment Processing | Not Started | NA | | | |
|
||||
| 6.6 | F&F Closure | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 7 - Finance Dashboard
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 7.1 | Security Deposit Verification (Onboarding) | Not Started | NA | | | |
|
||||
| 7.2 | Finance Flags Payment Discrepancy | Not Started | NA | | | |
|
||||
|
||||
---
|
||||
|
||||
## Module 8 - Admin and System Configuration
|
||||
|
||||
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 8.1.1 | Admin Configures SLA for a Workflow Activity | Not Started | NA | | | |
|
||||
| 8.1.2 | SLA Breach and Escalation Flow | Not Started | NA | | | |
|
||||
| 8.2.1 | Admin Creates Email Template | Not Started | NA | | | |
|
||||
| 8.3.1 | Admin Creates New Opportunity Window | Not Started | NA | | | |
|
||||
@ -518,9 +518,9 @@ application progresses — from initial registration to final inauguration and o
|
||||
achieved.
|
||||
|
||||
```
|
||||
4.1.1.11 LOA (Letter of Authorization) & Final Go-Live
|
||||
4.1.1.11 LOA (Letter of Agreement) & Final Go-Live
|
||||
```
|
||||
- After LOI issuance and Dealer Code generation, the **Letter of Authorization (LOA) is**
|
||||
- After LOI issuance and Dealer Code generation, the **Letter of Agreement (LOA) is**
|
||||
**generated and approved by NBH and DD-Head**. Upon successful LOA issuance, the
|
||||
|
||||
|
||||
@ -2823,11 +2823,11 @@ The **LOA Issuance, Essential Operating Requirements (EOR) & Inauguration** modu
|
||||
the final execution phase of the dealer onboarding lifecycle. It ensures that only those dealerships
|
||||
which have fulfilled all architectural, statutory, and financial prerequisites are authorized to
|
||||
commence operations under Royal Enfield’s network.
|
||||
This module manages the formal **Letter of Authorization (LOA)** release, verification of **EOR
|
||||
This module manages the formal **Letter of Agreement (LOA)** release, verification of **EOR
|
||||
compliance** , and the **dealership inauguration process** , providing complete visibility, audit
|
||||
control, and cross-departmental coordination before official go-live.
|
||||
|
||||
The **Letter of Authorization (LOA) is a parallel statutory activity** and is **not dependent on
|
||||
The **Letter of Agreement (LOA) is a parallel statutory activity** and is **not dependent on
|
||||
infrastructure readiness** for issuance. LOA processing and issuance proceed **in parallel with**
|
||||
|
||||
|
||||
@ -2845,7 +2845,7 @@ go-live.
|
||||
**6.18.3 Depth**
|
||||
|
||||
```
|
||||
6.18.3.1 LOA (Letter of Authorization) Issuance
|
||||
6.18.3.1 LOA (Letter of Agreement) Issuance
|
||||
```
|
||||
- Once statutory verification and site readiness are complete, **DD-Admin** initiates the **LOA**
|
||||
**document preparation**.
|
||||
@ -3160,7 +3160,7 @@ o Statutory Documents: Eleven-step checklist for compliance uploads including:
|
||||
▪ Domain ID
|
||||
▪ MSD Configuration
|
||||
▪ LOI Acknowledgement Copy
|
||||
o LOA (Letter of Authorization): Issued after LOI acceptance.
|
||||
o LOA (Letter of Agreement): Issued after LOI acceptance.
|
||||
o EOR (Essential Operating Requirements): Verification of pre-opening operational
|
||||
criteria.
|
||||
o Inauguration: Final dealership launch milestone.
|
||||
|
||||
2719
docs/modular_wise/01_Dealer_Onboarding.md
Normal file
2719
docs/modular_wise/01_Dealer_Onboarding.md
Normal file
File diff suppressed because it is too large
Load Diff
712
docs/modular_wise/02_Dealer_Resignation.md
Normal file
712
docs/modular_wise/02_Dealer_Resignation.md
Normal file
@ -0,0 +1,712 @@
|
||||
# RE Offboarding System
|
||||
|
||||
# Requirements
|
||||
|
||||
|
||||
**1.1.5 Dealer Resignation Access & Workflow Enhancements**
|
||||
|
||||
- Enabled **dealer portal access** for initiating resignation requests and uploading required
|
||||
information.
|
||||
|
||||
|
||||
- Clarified that the **Legal team issues the Resignation Acceptance Letter** in all cases.
|
||||
- Expanded review authority to allow **ZBH, DD Lead, DD Head, and NBH** to **Send Back or**
|
||||
**Revoke resignation requests** , with communication routed through **Work Notes**.
|
||||
- Confirmed that **Full & Final (F&F) settlement is triggered strictly on the Last Working**
|
||||
**Day (LWD)** and not based on approval date.
|
||||
|
||||
**1.1.7 Role & Persona Alignment**
|
||||
|
||||
- Added **NBH** to the personas section.
|
||||
- Added **RBM** to applicable review and approval tables.
|
||||
- Clarified that **DD ASM is responsible for interview scheduling and coordination** , with
|
||||
no Admin involvement.
|
||||
|
||||
**1.1.8 Access Control & Visibility Refinements**
|
||||
|
||||
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
|
||||
- Granted **approval visibility** to DD Lead where applicable.
|
||||
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
|
||||
with **DD Lead, RBM, and ZBH** having view access.
|
||||
- Limited applicant and dealer portal access to **stage-specific and context-specific**
|
||||
**scenarios only**.
|
||||
- Confirmed that **dealer portal access is revoked after resignation or termination**.
|
||||
|
||||
|
||||
**1.1.10 Super Admin Role Introduction**
|
||||
|
||||
- Introduced a **Super Admin (Master Role)** with end-to-end access and workflow control
|
||||
across modules.
|
||||
- Defined segregation of duties by splitting Super Admin into **two DD Admin roles** with
|
||||
clearly scoped responsibilities.
|
||||
|
||||
|
||||
**1.2.2 Dealer Resignation Enablement**
|
||||
|
||||
- Enabled **dealer-initiated resignation requests** at outlet level via the portal.
|
||||
- Added structured resignation submission with:
|
||||
o Last Operational Date (Sales & Services)
|
||||
o Reason for resignation
|
||||
o Mandatory document readiness guidance
|
||||
- Enabled **dealer withdrawal option** for resignation requests **only until the case is**
|
||||
**pending with NBH**.
|
||||
- Clarified that **Legal team issues the Resignation Acceptance Letter** post approvals.
|
||||
- Ensured **F&F settlement is triggered based on Last Working Day (LWD)** and not
|
||||
approval date.
|
||||
- Restricted dealer portal access **post resignation closure**.
|
||||
|
||||
**1.2.5 Post-Exit Access Control**
|
||||
|
||||
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
|
||||
completed.
|
||||
|
||||
|
||||
|
||||
### 2.1 Business & Functional Users
|
||||
|
||||
**2.1.1 Dealer Development (DD) Team**
|
||||
|
||||
- **Super Admin (Master Role):**
|
||||
The **Super Admin has unrestricted access** across all modules and workflows, with
|
||||
authority to **configure, override, and influence workflow behavior** at every level.
|
||||
|
||||
|
||||
```
|
||||
The Super Admin role is segregated into two DD Admin roles , each with clearly defined
|
||||
scopes to ensure segregation of duties and governance control.
|
||||
```
|
||||
- **DD-Admin:** System administrator responsible for user setup, role mapping, hierarchy
|
||||
configuration, and workflow management.
|
||||
- **DD-AM (Area Manager):** Reviews and manages applications within assigned regions;
|
||||
performs preliminary screening.
|
||||
- **DD-ZM (Zonal Manager):** Conducts the first level of dealer evaluation along with RBM;
|
||||
prepares presentation decks for final interviews.
|
||||
- **DD-Lead:** Reviews zonal evaluations, validates recommendations, and forwards
|
||||
shortlisted applicants for senior-level approval.
|
||||
- **DD-Head: DD Head** is engaged in the **final review and approval** of shortlisted dealer
|
||||
applications before the **NBH interview** , and later **oversees final verification and LOI**
|
||||
**issuance** after all evaluations are complete.
|
||||
|
||||
**2.1.2 Regional Sales & Business Team**
|
||||
|
||||
- **RBM (Regional Business Manager):** Participates in early-stage evaluations, provides
|
||||
ground-level business insights, and recommends suitable candidates.
|
||||
- **ZBH (Zonal Business Head):** Conducts the second-level review along with DD-Lead;
|
||||
provides strategic feedback on market and location viability.
|
||||
- **NBH (National Business Head):** Holds final authority for approval or rejection of dealer
|
||||
onboarding; reviews consolidated feedback from all levels.
|
||||
|
||||
**2.1.3 Supporting Departments**
|
||||
|
||||
- **Finance Team:** Reviews financial due diligence reports, validates F&F (Full and Final)
|
||||
settlements, and manages monetary closure during offboarding.
|
||||
- **Legal Team:** Reviews agreements, issues **Letters of Intent (LOI)** or **Termination Letters** ,
|
||||
and ensures all documentation aligns with company policy.
|
||||
- **Brand Experience / Architecture Team:** Manages **EOR (Essential Operating**
|
||||
**Requirements)** and ensures adherence to brand and infrastructure standards.
|
||||
|
||||
**2.1.4 Dealers**
|
||||
|
||||
Once a dealer is **successfully onboarded and activated in the system** , the Dealer role is enabled
|
||||
with controlled, role-based access to initiate and track select lifecycle requests. This
|
||||
enhancement introduces **structured self-service capabilities for dealers** , while ensuring all
|
||||
actions remain governed by defined validations, internal reviews, and approval workflows as per
|
||||
RE standards.
|
||||
|
||||
The Dealer role is enabled to perform the following activities:
|
||||
|
||||
|
||||
- **Resignation Initiation**
|
||||
|
||||
```
|
||||
The dealer can initiate the resignation process directly through the portal , submit the
|
||||
reason for exit, and track the status of the request across the defined review, clearance,
|
||||
and closure stages.
|
||||
```
|
||||
Acronym Full Form / Description
|
||||
RE Royal Enfield
|
||||
DD Dealer Development
|
||||
DD-AM Dealer Development – Area Manager
|
||||
DD-ZM Dealer Development – Zonal Manager
|
||||
DD-Lead Dealer Development – Lead
|
||||
DD-Head Dealer Development – Head
|
||||
RBM Regional Business Manager
|
||||
ZBH Zonal Business Head
|
||||
NBH National Business Head
|
||||
ASM Area Sales Manager
|
||||
FDD Financial Due Diligence (External Partner/Agency)
|
||||
LOI Letter of Intent
|
||||
EOR Essential Operating Requirements
|
||||
LOA Letter of Appointment
|
||||
F&F Full and Final (Dealer Settlement)
|
||||
KT Matrix Evaluation Matrix used for scoring applicants
|
||||
```
|
||||
4.1.1.13 System-Driven Governance & Audit
|
||||
```
|
||||
- Each stage automatically logs:
|
||||
o User action, timestamp, and remarks
|
||||
o Uploaded artefacts and version control
|
||||
o Notifications sent and approvals received
|
||||
- The entire lifecycle remains accessible under **Audit Trail** for future reference, compliance,
|
||||
or offboarding workflows.
|
||||
|
||||
### 4.2 Dealer Resignation – Process Flow Overview
|
||||
|
||||
**4.2.1.1 Overview**
|
||||
|
||||
```
|
||||
The Dealer Resignation Process manages the structured offboarding of a dealership initiated
|
||||
by the dealer. The process begins when a dealer formally submits their resignation via
|
||||
email to the Area Sales Manager (ASM) , after which the workflow transitions into the
|
||||
system-managed approval sequence.
|
||||
```
|
||||
```
|
||||
Dealer resignation requests are initiated by the dealer through the portal and subsequently
|
||||
reviewed and processed by Admin, Finance, Legal, and relevant business stakeholders.
|
||||
```
|
||||
```
|
||||
This flow ensures that each resignation is verified, discussed, and approved across all
|
||||
required levels — maintaining proper documentation, compliance, and traceability until the
|
||||
final Legal Acceptance Letter is issued.
|
||||
```
|
||||
|
||||
**4.2.2 Step-by-Step Process Flow**
|
||||
|
||||
```
|
||||
4.2.2.1 Dealer Initiation
|
||||
```
|
||||
- The dealer submits a **formal resignation email** on the dealership’s official letterhead to
|
||||
the **ASM**.
|
||||
- The resignation reason must be clearly stated (e.g., personal, financial, business
|
||||
restructuring).
|
||||
- The **dealer is provided portal access** to initiate the resignation request directly through
|
||||
the system. The dealer submits resignation details, reason for exit, and proposed
|
||||
timeline via the portal, after which the request enters the internal review and clearance
|
||||
workflow.
|
||||
|
||||
```
|
||||
4.2.2.2 ASM Review
|
||||
```
|
||||
- The **ASM** reviews the dealer’s resignation request and supporting letter.
|
||||
- Uploads the **resignation email** and **dealer’s letterhead document** onto the portal.
|
||||
- Adds remarks summarizing the discussion and reason for resignation.
|
||||
- Forwards the request to **RBM + DD-ZM** for evaluation.
|
||||
|
||||
```
|
||||
4.2.2.3 RBM + DD-ZM Joint Evaluation
|
||||
```
|
||||
- The **Regional Business Manager (RBM)** and **Dealer Development Zonal Manager (DD-**
|
||||
**ZM)** review the uploaded documents.
|
||||
- Conduct a joint discussion with the dealer to confirm the intent and understand any
|
||||
issues.
|
||||
- Uploads the **Minutes of Meeting (MOM)** or discussion summary.
|
||||
- Adds comments and recommendations before forwarding to **Zonal Business Head**
|
||||
**(ZBH)**.
|
||||
- Actions available at this stage:
|
||||
o **Approve** → Send forward for next-level review
|
||||
o **Send Back for Clarification** → Returns to ASM
|
||||
o **Withdraw** → Cancels the request (with remarks logged)
|
||||
|
||||
```
|
||||
4.2.2.4 ZBH Review
|
||||
```
|
||||
- The **Zonal Business Head (ZBH)** reviews the resignation summary and all remarks.
|
||||
- Adds their comments and recommendations.
|
||||
- Forwards the request to **DD-Lead** through the system.
|
||||
- Worknote is updated automatically to reflect action and timestamp.
|
||||
- The resignation request is reviewed by authorized business stakeholders,
|
||||
including **RBM, ZBH, and DD-Head**. During the review stage, the **ZBH is authorized to**
|
||||
|
||||
|
||||
```
|
||||
Send Back or Revoke the resignation request for clarification or correction. Send Back
|
||||
actions are communicated to the dealer and internal teams through Work Notes , with
|
||||
mandatory remarks captured for traceability.
|
||||
```
|
||||
```
|
||||
4.2.2.5 DD-Lead Review
|
||||
```
|
||||
- The **DD-Lead** consolidates all discussions, documents, and feedback.
|
||||
- Prepares a **Resignation Presentation** with recommendations and supporting data.
|
||||
- Uploads the presentation to the portal.
|
||||
- Forwards the case to **DD-Head** for the next-level Dealer Development review.
|
||||
- At this stage, the DD-Lead is authorized to **Send Back or Revoke** the resignation
|
||||
request for clarification, correction, or reconsideration. **Send Back actions are**
|
||||
**communicated through Work Notes** , with **mandatory remarks** recorded for audit and
|
||||
traceability.
|
||||
|
||||
```
|
||||
4.2.2.6 DD-Head Review
|
||||
```
|
||||
- The **DD-Head** reviews the DD-Lead's consolidated dossier, presentation, and the
|
||||
cumulative feedback captured by ASM, RBM/DD-ZM, ZBH, and DD-Lead.
|
||||
- Validates that all Dealer Development checks (asset recovery readiness, transition
|
||||
plan, territory impact) have been adequately addressed at the DD layer.
|
||||
- Adds final Dealer Development remarks and either:
|
||||
o **Approve** → Forwards the case to **NBH** for the National-level decision.
|
||||
o **Send Back for Clarification** → Returns the case to **DD-Lead** with mandatory
|
||||
remarks logged through Work Notes.
|
||||
o **Revoke** → Closes the request with a documented rationale captured in Work Notes.
|
||||
- The transition from **DD-Lead → DD-Head → NBH** is fully inline; on DD-Head approval
|
||||
the workflow moves to NBH **without manual intervention** , and the SLA timer for the
|
||||
next stage starts automatically.
|
||||
|
||||
```
|
||||
4.2.2.7 NBH Final Approval
|
||||
```
|
||||
- The **National Business Head (NBH)** reviews the entire resignation dossier.
|
||||
- Adds final remarks with one of the following outcomes:
|
||||
o **Approve** → Case moves automatically to Legal for letter issuance.
|
||||
o **Send Back for Clarification** → Returns to DD-Lead or ZBH for revalidation.
|
||||
o **Hold** → Temporarily pauses the process pending further discussion.
|
||||
- Upon approval, the system triggers a **Worknote Notification** to DD-Lead, RBM, ZBH, and
|
||||
Finance teams.
|
||||
- The resignation request is reviewed by the **NBH** , who may **Approve, Send Back, or**
|
||||
**Revoke** the request based on business considerations. Any **Send Back or Revoke action**
|
||||
**must be accompanied by remarks recorded in Work Notes** , ensuring transparent
|
||||
communication and governance.
|
||||
|
||||
```
|
||||
4.2.2.8 Legal Acceptance Letter
|
||||
```
|
||||
- Once approved by **NBH** , the request is **auto-assigned to the Legal team**.
|
||||
- Legal verifies the uploaded resignation and issues a **Resignation Acceptance Letter**.
|
||||
- The letter is uploaded to the portal, visible to all relevant personas including **DD-**
|
||||
**Admin** and **DD-AM**.
|
||||
- Legal can also raise clarifications through worknotes if required.
|
||||
- Upon completion of all approvals, the **Legal team issues the official Resignation**
|
||||
**Acceptance Letter** and shares it with the dealer through authorized communication
|
||||
channels.
|
||||
|
||||
|
||||
```
|
||||
4.2.2.9 DD-Admin Closure
|
||||
```
|
||||
- The **DD-Admin** downloads and shares the final **Resignation Acceptance Letter** with the
|
||||
dealer.
|
||||
- Marks the resignation as completed and triggers the **F&F (Full and Final) process** by
|
||||
forwarding the case to the Finance team.
|
||||
- The **Full & Final (F&F) settlement process is initiated only on the Last Working Day**
|
||||
**(LWD) of the dealership**. The system shall **enable and trigger the F&F workflow strictly**
|
||||
**based on the LWD date** , and **not based on the resignation approval date**.
|
||||
|
||||
```
|
||||
|
||||
|
||||
## 7 Dealer Resignation
|
||||
|
||||
The **Dealer Resignation** process enables an existing Royal Enfield dealer to formally
|
||||
|
||||
initiate their intent to discontinue the dealership through a structured and transparent
|
||||
workflow. This process captures the dealer’s resignation details, reasons for exit, and
|
||||
proposed timeline, ensuring all associated departments — including **DD-Admin, DD-**
|
||||
|
||||
**Head, Finance, Legal, and Regional Teams** — are informed and involved in the validation
|
||||
and clearance stages. Each resignation request undergoes systematic review, covering
|
||||
|
||||
asset recovery, financial reconciliation, documentation verification, and contractual
|
||||
obligations before final approval and closure.
|
||||
|
||||
|
||||
### 7.1 Dealer Resignation Request (Initiation)
|
||||
|
||||
**7.1.1 Functionality Scope**
|
||||
|
||||
The **Dealer Resignation Request** process begins when a dealer formally communicates their
|
||||
intent to resign via an **official email** to ASM. Once received, the **DD-ASM** initiates the resignation
|
||||
process in the system by creating a digital record using the _Create Resignation Request_ form. The
|
||||
form captures critical dealership, operational, and contextual information — such as business
|
||||
constitution, sales data, and closure type — ensuring that the request is documented in a
|
||||
structured, traceable, and standardized manner. This process establishes a single source of truth
|
||||
for all resignation-related data, facilitating transparent coordination among **DD-Head, Finance,
|
||||
Legal, and Regional Teams** for subsequent review and action. Dealer can login exclusively and
|
||||
can only initiate the Resignation request.
|
||||
|
||||
The **Dealer Resignation Request is initiated by the dealer through the portal** , providing a
|
||||
structured mechanism to formally submit the intent to discontinue the dealership. The dealer
|
||||
captures resignation details, reason for exit, and the proposed effective date. Upon submission,
|
||||
the request is routed to the internal stakeholders for review, validation, and subsequent
|
||||
clearance processes. The **dealer logs into the portal and initiates the resignation request** by
|
||||
submitting the required details and supporting information.
|
||||
|
||||
|
||||
**7.1.2 Width**
|
||||
|
||||
- Accessible exclusively to **DD-ASM** through the **“Create Resignation Request”** interface.
|
||||
- Includes the following mandatory and optional input fields:
|
||||
o **Dealer Code** (it will be fed to SAP API to pull details.)
|
||||
o **Inauguration** , **LOA** , and **LOI Dates** (Will be fetched from system DB, if available)
|
||||
o **Last 6 Months Sales**
|
||||
o **Number of Dealerships / Studios**
|
||||
o **Constitution** (Proprietorship, Partnership, LLP, Pvt. Ltd., etc.)
|
||||
o **Dealership Type** (Main, Satellite, Studio, etc.)
|
||||
o **Type of Closure** (Voluntary, Business Transfer, Termination, etc.)
|
||||
o **Format Category** (Urban, Rural, etc.)
|
||||
o **Dealer Scorecard Band**
|
||||
o **Resignation Reason** (brief summary)
|
||||
o **Dealer Voice** (detailed justification or remarks from dealer’s email)
|
||||
o **Upload Document** (resignation email copy or supporting documents)
|
||||
- **Buttons:**
|
||||
o **Submit Request:** validates data and triggers routing to the next stage of review.
|
||||
o **Cancel:** exits without saving.
|
||||
|
||||
**7.1.3 Depth**
|
||||
|
||||
- Upon submission by **DD-Admin** , the system performs the following
|
||||
o Validates the **Dealer Code** against the dealership master from SAP API to be
|
||||
provided by RE
|
||||
o Generates a unique **Resignation Request ID** and logs submission details
|
||||
(timestamp, user, and role).
|
||||
o Stores the uploaded resignation email or document in the **Central Document**
|
||||
**Repository** for reference.
|
||||
o Automatically notifies the **DD-Head** and relevant stakeholders that a new
|
||||
resignation has been logged.
|
||||
o Marks the case status as **“Resignation Initiated”** in the workflow tracker.
|
||||
o He will also upload the resignation PPT which is build off the system.
|
||||
|
||||
**7.1.4 Personas-Wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona Accessibility Visibility Scope
|
||||
Dealer /
|
||||
Applicant
|
||||
```
|
||||
```
|
||||
Sends official resignation email to Royal Enfield.
|
||||
The dealer is provided portal access to upload
|
||||
resignation-related documents and
|
||||
responses during the applicable workflow
|
||||
stages.
|
||||
```
|
||||
```
|
||||
Email communication
|
||||
only (no direct system
|
||||
access).
|
||||
```
|
||||
```
|
||||
Creates resignation request in system, uploads
|
||||
dealer’s email, validates data, and submits for
|
||||
approval.
|
||||
```
|
||||
```
|
||||
Receives system notification upon submission;
|
||||
can view request details and attached
|
||||
resignation communication.
|
||||
```
|
||||
```
|
||||
Background operation.
|
||||
```
|
||||
### 7.2 Resignation Management Dashboard
|
||||
|
||||
**7.2.1 Functionality Scope**
|
||||
|
||||
The **Resignation Management Dashboard** serves as the central workspace for monitoring and
|
||||
managing all dealer resignation requests initiated within the system. It provides a consolidated
|
||||
view of active, pending, and completed cases, enabling stakeholders such as **DD-Admin, ASM,
|
||||
DD-Lead, ZBH, NBH, and Legal Teams** to review progress, take required actions, and ensure
|
||||
compliance with the defined offboarding workflow.
|
||||
|
||||
The **ZBH can review resignation requests and perform Send Back or Revoke actions** prior to final
|
||||
approval. Each action requires **mandatory remarks** and is recorded against the resignation case.
|
||||
|
||||
|
||||
RBM, **ZBH, DD-Lead, DD-Head, and NBH** can review resignation requests and are authorized
|
||||
to **Send Back or Revoke** requests at their respective stages. All such actions require **mandatory
|
||||
remarks** and are logged for audit purposes.
|
||||
|
||||
**7.2.2 Width**
|
||||
|
||||
- Displays a **summary header** with following key counters:
|
||||
o **All Requests:** Total number of resignation requests recorded.
|
||||
o **Open:** Requests currently under review or action.
|
||||
o **Completed:** Finalized resignations where closure is approved.
|
||||
o **Requires Your Action:** Highlights cases awaiting action from the logged-in user.
|
||||
- Shows a **list view** of all resignation requests with the following details:
|
||||
o **Request ID (e.g., RES-001)**
|
||||
o **Dealer Name, Dealer Code, and Location**
|
||||
o **Format Category** (A+, A, B, etc.)
|
||||
o **Dealership Type** (Main, Studio, etc.)
|
||||
o **Reason for Resignation**
|
||||
o **Current Stage** (e.g., ASM Review, DD-Lead Review, NBH Approved, Legal)
|
||||
o **Submitted On** (auto-captured timestamp)
|
||||
- Action options:
|
||||
o **View Details:** Opens complete resignation record and attached documents.
|
||||
o **Create Resignation Request:** Accessible only to **DD-Admin** for entering new
|
||||
requests (from dealer emails).
|
||||
- Filter tabs:
|
||||
o **All Requests** , **Open** , **Completed**
|
||||
|
||||
**7.2.3 Depth**
|
||||
|
||||
- **Workflow Synchronization:** Each resignation request dynamically updates its stage label
|
||||
(e.g., _ASM Review_ , _DD-Lead Review_ , _NBH Approved_ ) based on workflow transitions.
|
||||
- **Notification Logic:**
|
||||
o The assigned reviewer (ASM, DD-Lead, or NBH) receives automated alerts for
|
||||
action items.
|
||||
o Status changes trigger notifications to the next role in sequence.
|
||||
|
||||
|
||||
```
|
||||
### 7.3 Resignation Details & Review
|
||||
|
||||
**7.3.1 Functionality Scope**
|
||||
|
||||
The **Resignation Details & Review** module provides a comprehensive view of all dealer
|
||||
resignation information captured during initiation. It enables authorized reviewers to validate
|
||||
dealer data, evaluate the reason and context for resignation, and take appropriate workflow
|
||||
|
||||
|
||||
actions such as **Approval, Withdrawal, Send Back, or Push to Full & Final (F&F)**. The screen
|
||||
consolidates dealer master data, operational metrics, and resignation specifics, ensuring
|
||||
reviewers have complete visibility before making decisions.
|
||||
|
||||
**7.3.2 Width**
|
||||
|
||||
- **Header Actions:**
|
||||
o **Approve:** Marks resignation as validated and forwards it to the next workflow
|
||||
stage (DD-Head / NBH).
|
||||
o **Withdrawal:** Used if the dealer retracts the resignation request or if withdrawal
|
||||
is approved internally.
|
||||
o **Send Back:** Returns the request to DD-Admin for correction or additional details.
|
||||
o **Push to F&F:** Moves the case to the **Full & Final Settlement** process after all
|
||||
approvals are secured.
|
||||
o **Assign User:** Allows reallocation of review responsibility to another internal user.
|
||||
o **View Work Notes:** Opens the shared comment thread for internal collaboration
|
||||
and tagging.
|
||||
- **Tabs:**
|
||||
o **Details** – Displays complete resignation information and dealer data.
|
||||
o **Progress** – Shows stage-wise workflow journey and current reviewer.
|
||||
o **Documents** – Lists uploaded resignation documents and correspondence.
|
||||
o **Audit Trail** – Records every action, decision, and timestamp for traceability.
|
||||
|
||||
**7.3.3 3. Depth**
|
||||
|
||||
- **Information Segments:**
|
||||
o **Request Information:** Pull dealer master details such as Dealer Code, GST,
|
||||
Address, Domain & Service Codes, City Category, and Dealership Name.
|
||||
o **Operational Details:** Displays dealership metrics including inauguration and LOA
|
||||
dates, number of outlets, last six-month sales, business constitution, format
|
||||
category, and dealer scorecard band.
|
||||
o **Resignation Details:** Summarizes the **Resignation Reason** and **Dealer Voice**
|
||||
**(Customer Description)** derived from the dealer’s email submission.
|
||||
|
||||
### 7.4 Resignation Request Review & Action Management
|
||||
|
||||
**7.4.1 Functionality Scope**
|
||||
|
||||
The **Resignation Progress Timeline** provides a transparent, stepwise view of the dealer
|
||||
resignation workflow — from initial submission to the issuance of the final **Acceptance Letter**.
|
||||
Since the **Dealer does not have portal access** for resignation, the process starts through an **email
|
||||
submission to the Area Sales Manager (ASM)** , followed by progressive reviews and comments
|
||||
at multiple organizational levels. Each approver in the chain can perform one of three key actions
|
||||
— **Approve** , **Send Back for Clarification** , or **Withdraw** — with remarks captured in **Work
|
||||
Notes** for audit and traceability. Once approved by the **National Business Head (NBH)** , the
|
||||
request automatically routes to the **Legal Team** for the issuance of the acceptance letter, visible
|
||||
to both the DD Admin and DD-ASM.
|
||||
|
||||
The **dealer is provided portal access** to **upload resignation-related documents and
|
||||
responses** during the applicable workflow stages. For termination cases, **dealer upload access is
|
||||
restricted** as per defined governance rules.
|
||||
|
||||
**7.4.2 Width**
|
||||
|
||||
```
|
||||
7.4.2.1 Stage-wise Flow
|
||||
Stage Responsible
|
||||
Role
|
||||
```
|
||||
```
|
||||
System / Process Description
|
||||
```
|
||||
|
||||
1. Dealer
|
||||
Resignation
|
||||
Submission
|
||||
|
||||
```
|
||||
Dealer → via
|
||||
Email to ASM
|
||||
```
|
||||
- Dealer submits resignation via official email and
|
||||
signed letterhead.
|
||||
- No direct portal access.
|
||||
- ASM receives and verifies authenticity.
|
||||
2. ASM Review DD-ASM • Uploads resignation email and presentation
|
||||
(e.g., _Sample resignation.pptx_ ) to portal.
|
||||
- Adds remarks summarizing dealer’s reason and
|
||||
operational background.
|
||||
- Forwards case to **RBM + DD-ZM** for evaluation.
|
||||
3. RBM + DD-ZM
|
||||
Review
|
||||
|
||||
```
|
||||
RBM & DD-ZM • Conduct joint discussion with dealer to understand
|
||||
cause and alternatives.
|
||||
```
|
||||
- Uploads discussion notes and remarks in **Work Notes**.
|
||||
- The final output will be submitted as Approve,
|
||||
Withdrawal or send back.
|
||||
- Has three action options:
|
||||
- **Approve:** Forwards case to ZBH for further review.
|
||||
- **Send Back:** Requests ASM to provide additional
|
||||
details or clarifications (remark mandatory).
|
||||
- **Withdraw:** Stops process if dealer withdraws or
|
||||
case found invalid (remark mandatory).
|
||||
4. ZBH Review Zonal Business
|
||||
Head
|
||||
- Reviews RBM + DD-ZM inputs and validates zonal
|
||||
implications.
|
||||
- Adds comments in **Work Notes** and forwards to **DD
|
||||
Lead**.
|
||||
- Can perform **Approve** , **Send Back** ,
|
||||
or **Withdraw** actions.
|
||||
5. DD Lead
|
||||
Review
|
||||
|
||||
```
|
||||
DD Lead • Prepares a formal Resignation Presentation
|
||||
PPT summarizing business rationale, sales history,
|
||||
dealer feedback, and proposed recommendation.
|
||||
```
|
||||
- Uploads the presentation and comments to the
|
||||
portal.
|
||||
- Approves and shares with **NBH** for final decision.
|
||||
6. NBH Approval National
|
||||
Business Head
|
||||
- Reviews all inputs and puts **final decision remarks** in
|
||||
Work Notes.
|
||||
- On approval, system triggers notification to **DD Lead,
|
||||
ZBH, Zonal Team, Business Zonal Manager, and F&F**.
|
||||
- Automatically routes the case to **Legal Team** for
|
||||
Acceptance Letter issuance.
|
||||
7. Legal Review &
|
||||
Acceptance Letter
|
||||
|
||||
```
|
||||
Legal Team • Prepares and uploads Resignation Acceptance
|
||||
Letter on portal.
|
||||
```
|
||||
- Can raise queries in Work Notes if required.
|
||||
- Uploaded document is visible to **DD-Admin** and **DD-**
|
||||
|
||||
|
||||
#### ASM.
|
||||
|
||||
- Legal completion closes workflow for the request.
|
||||
8. DD Admin &
|
||||
ASM Notification
|
||||
|
||||
```
|
||||
DD Admin +
|
||||
DD-ASM
|
||||
```
|
||||
- DD Admin reviews the uploaded acceptance letter.
|
||||
- Shares with respective **ASM (Field Team)** to
|
||||
communicate official closure to the dealer.
|
||||
|
||||
**7.4.3 3. Depth**
|
||||
|
||||
- **Action Modes Across Stages:**
|
||||
o **Approve:** Advances the resignation request to the next level of the workflow.
|
||||
_Example:_ “Reviewed with dealer and validated. Forwarding to ZBH for next stage.”
|
||||
o **Send Back:** Returns to the previous user or ASM for clarifications.
|
||||
_Example:_ “Incomplete documentation. Dealer statement on financials missing.”
|
||||
o **Withdraw:** Ends the process if dealer withdraws voluntarily or management
|
||||
disapproves continuation.
|
||||
_Example:_ “Dealer requested withdrawal of resignation via email dated 15-Oct.”
|
||||
- **Audit and Transparency:**
|
||||
o All actions (including remarks, uploads, and timestamps) are auto-captured
|
||||
in **Work Notes** and the **Audit Trail**.
|
||||
o Every document and PPT uploaded (e.g., _Sample resignation.pptx_ ) is linked to its
|
||||
stage for version tracking.
|
||||
- **System Automation:**
|
||||
o NBH approval automatically triggers Legal assignment.
|
||||
o SLA tracking continues at each step; escalation is logged in Work Notes if delayed.
|
||||
o Notifications are sent to all relevant stakeholders upon approval, send-back, or
|
||||
withdrawal.
|
||||
|
||||
**7.4.4 Worknotes**
|
||||
|
||||
The **Work Notes** feature acts as the central communication and collaboration thread
|
||||
within the resignation workflow. It captures all user interactions, remarks, and system-
|
||||
|
||||
triggered updates in a structured, time-stamped format. Each stakeholder — from
|
||||
ASM to NBH and Legal — uses Work Notes to record discussions, queries,
|
||||
|
||||
clarifications, and final decisions related to the resignation case will be submitted from
|
||||
Approval, Withdrawal or send back action.
|
||||
|
||||
### 7.5 Resignation Progress Tracker
|
||||
|
||||
|
||||
**7.5.1 Functionality Scope**
|
||||
|
||||
The **Progress** section provides a stage-wise, visual representation of the entire dealer resignation
|
||||
workflow. It enables authorized users to track each approval checkpoint — from **request
|
||||
submission** through **multi-level review** to **final legal acceptance**. Every stage dynamically
|
||||
updates based on workflow actions such as _Approve_ , _Send Back_ , or _Withdraw_ , with complete
|
||||
traceability of remarks, uploaded documents, and timestamps. This ensures full transparency,
|
||||
accountability, and operational consistency across all hierarchical levels.
|
||||
|
||||
**7.5.2 Width**
|
||||
|
||||
- Presents a **chronological timeline** of the resignation process, beginning with _Request
|
||||
Submitted_ and concluding with _Legal – Resignation Letter_.
|
||||
- Each stage displays **status indicators** (Pending, In Progress, Approved, or Withdrawn) along
|
||||
with the **responsible reviewer role**.
|
||||
- Shows the **number of documents uploaded** at each stage, with direct view/download options.
|
||||
- Allows reviewers to perform three key actions — _Approve_ , _Send Back_ , and _Withdraw_ — with
|
||||
remarks made mandatory.
|
||||
- If a request is **Sent Back** , it automatically reverts to the previous stage, recording remarks
|
||||
in **Work Notes** and notifying the concerned user.
|
||||
- On **Withdrawal** , the timeline is locked and marked _Closed – Withdrawn_ for historical reference.
|
||||
- Once **NBH** provides final approval, the request is automatically assigned to **Legal** for
|
||||
acceptance letter issuance.
|
||||
- The **Legal stage** finalizes the process upon letter upload, marking the case _Completed_ and
|
||||
notifying DD-Admin and field hierarchy.
|
||||
|
||||
**7.5.3 Depth**
|
||||
|
||||
- Each stage retains all **remarks, approvals, timestamps, and supporting documents** for
|
||||
complete traceability.
|
||||
- Integrates seamlessly with **Work Notes** and **Audit Trail** , ensuring real-time visibility of all
|
||||
communications and escalations.
|
||||
- Supports SLA-driven reminders and escalations that reflect directly in the timeline view.
|
||||
- All uploaded documents (emails, resignation PPT, acceptance letter) remain permanently
|
||||
mapped to their respective stages.
|
||||
- Once the resignation is finalized, historical data stays accessible for compliance and audit
|
||||
review.
|
||||
|
||||
|
||||
### 7.6 Documents & Audit Trail
|
||||
|
||||
**7.6.1 Functionality Scope**
|
||||
|
||||
The **Documents** and **Audit Trail** sections collectively ensure complete transparency and
|
||||
traceability across the resignation workflow. The **Documents** tab serves as a centralized
|
||||
repository of all artefacts submitted or generated during the process — including resignation
|
||||
letters, presentations, communications, and acceptance letters. The **Audit Trail** automatically
|
||||
captures every workflow action, recording who performed it, what was changed, and when,
|
||||
ensuring full accountability and data integrity.
|
||||
|
||||
**7.6.2 Width**
|
||||
|
||||
- Allows upload and viewing of all resignation-related documents with type, uploader, and
|
||||
upload date clearly listed.
|
||||
- Supports restricted document viewing to authorized personas with download control.
|
||||
- Provides versioned tracking of uploaded artefacts for compliance.
|
||||
- The **Audit Trail** logs every stage transition, approval, comment, or document addition with
|
||||
precise timestamps.
|
||||
- Automatically records system-triggered events such as SLA reminders or email notifications.
|
||||
|
||||
|
||||
**7.6.3 Depth**
|
||||
|
||||
- Each document remains linked to its respective workflow stage and accessible through
|
||||
the **Progress Timeline**.
|
||||
- All actions — _Approve_ , _Send Back_ , _Withdraw_ , _Upload_ , and _Assign_ — are recorded for
|
||||
traceability.
|
||||
- The system maintains an immutable historical log for governance and audit purposes.
|
||||
- Entries in the Audit Trail display both user-driven and automated actions to ensure
|
||||
comprehensive visibility.
|
||||
|
||||
756
docs/modular_wise/03_Termination.md
Normal file
756
docs/modular_wise/03_Termination.md
Normal file
@ -0,0 +1,756 @@
|
||||
# Offboarding System
|
||||
|
||||
**1.1.6 Termination Workflow Governance Updates**
|
||||
|
||||
- Clarified that **CEO is the final approving authority** for dealer termination cases.
|
||||
- Included **CCO and CEO** as approval authorities with **Approve / Hold / Reject** options.
|
||||
- Confirmed that the **Legal team issues termination letters only after CEO approval**.
|
||||
- Removed **dealer portal access** from termination workflows.
|
||||
- Extended **Send Back / Revoke** authority to **ZBH and DD Lead** for termination reviews.
|
||||
- Aligned **F&F trigger for termination** to occur strictly on the **Last Working Day (LWD)**.
|
||||
|
||||
**1.1.7 Role & Persona Alignment**
|
||||
|
||||
- Added **NBH** to the personas section.
|
||||
- Added **RBM** to applicable review and approval tables.
|
||||
- Clarified that **DD ASM is responsible for interview scheduling and coordination** , with
|
||||
no Admin involvement.
|
||||
|
||||
**1.1.8 Access Control & Visibility Refinements**
|
||||
|
||||
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
|
||||
- Granted **approval visibility** to DD Lead where applicable.
|
||||
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
|
||||
with **DD Lead, RBM, and ZBH** having view access.
|
||||
- Limited applicant and dealer portal access to **stage-specific and context-specific**
|
||||
**scenarios only**.
|
||||
- Confirmed that **dealer portal access is revoked after resignation or termination**.
|
||||
|
||||
|
||||
|
||||
|
||||
**1.2.5 Post-Exit Access Control**
|
||||
|
||||
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
|
||||
completed.
|
||||
|
||||
|
||||
|
||||
## 3 Definitions and Acronyms
|
||||
|
||||
```
|
||||
Acronym Full Form / Description
|
||||
RE Royal Enfield
|
||||
DD Dealer Development
|
||||
DD-AM Dealer Development – Area Manager
|
||||
DD-ZM Dealer Development – Zonal Manager
|
||||
DD-Lead Dealer Development – Lead
|
||||
DD-Head Dealer Development – Head
|
||||
RBM Regional Business Manager
|
||||
ZBH Zonal Business Head
|
||||
NBH National Business Head
|
||||
ASM Area Sales Manager
|
||||
FDD Financial Due Diligence (External Partner/Agency)
|
||||
LOI Letter of Intent
|
||||
EOR Essential Operating Requirements
|
||||
LOA Letter of Appointment
|
||||
F&F Full and Final (Dealer Settlement)
|
||||
KT Matrix Evaluation Matrix used for scoring applicants
|
||||
```
|
||||
|
||||
|
||||
### 4.3 Dealer Termination – Process Flow Overview
|
||||
|
||||
```
|
||||
4.3.1.1 Overview
|
||||
```
|
||||
```
|
||||
The Dealer Termination Process governs the structured offboarding of a dealership initiated
|
||||
internally by Royal Enfield due to operational, contractual, or ethical concerns.
|
||||
It ensures that any termination—whether due to working-capital issues, poor performance,
|
||||
or unethical practices —is investigated, documented, reviewed at multiple managerial levels,
|
||||
and legally validated before final execution. The process maintains full transparency and
|
||||
traceability through digital records, comments, and worknotes until the Termination
|
||||
Letter is issued and the Full & Final (F&F) settlement begins.
|
||||
```
|
||||
**4.3.2 Step-by-Step Process Flow**
|
||||
|
||||
```
|
||||
4.3.2.1 ASM – Case Initiation
|
||||
```
|
||||
- The **Area Sales Manager (ASM)** regularly visits dealers and records **Minutes of Meeting**
|
||||
**(MOM)** for performance or compliance concerns.
|
||||
- After two consecutive unsatisfactory commitments or escalations, the ASM initiates
|
||||
a **Termination Request** in the portal.
|
||||
- Fills all operational details (Dealer Code, LOI, LOA, Sales Data, etc.), selects
|
||||
a **Termination Category** (Working Capital, Performance, Unethical Practice), and
|
||||
uploads supporting documents (MOMs, commitments, dealer letters).
|
||||
- Submits the case to **RBM + DD-ZM** for review.
|
||||
|
||||
```
|
||||
4.3.2.2 RBM + DD-ZM Review
|
||||
```
|
||||
- The **Regional Business Manager (RBM)** and **Dealer Development Zonal Manager (DD-**
|
||||
**ZM)** jointly evaluate the case.
|
||||
|
||||
|
||||
- Conduct a meeting with the dealer and record fresh MOMs; upload dealer
|
||||
commitments on letterhead.
|
||||
- Provide remarks and supporting evidence.
|
||||
- Actions available:
|
||||
o **Approve** → Forward to ZBH
|
||||
o **Send Back for Clarification** → Returns to ASM with comments
|
||||
o **Withdraw** → Terminates workflow with justification
|
||||
|
||||
```
|
||||
4.3.2.3 ZBH Review
|
||||
```
|
||||
- The **Zonal Business Head (ZBH)** reviews the full chronology (ASM visits, RBM/DD-ZM
|
||||
remarks, uploaded MOMs).
|
||||
- Validates escalation authenticity and dealer communication record.
|
||||
- Adds remarks and forwards to **DD-Lead** for deeper review.
|
||||
- The termination request is reviewed by the **ZBH** , who is authorized to **Approve, Send**
|
||||
**Back, or Revoke** the termination request. **Send Back actions are communicated**
|
||||
**through Work Notes** , with **mandatory remarks** recorded for traceability.
|
||||
|
||||
```
|
||||
4.3.2.4 DD-Lead Review & Legal Assignment
|
||||
```
|
||||
- The **DD-Lead** cross-verifies case chronology with all stakeholders (ASM, RBM, ZBH).
|
||||
- Prepares a **Termination Presentation** summarizing facts, dealer history, and
|
||||
recommendations.
|
||||
- Assigns the case to **Legal Team** for inputs through the system (visible in worknotes).
|
||||
- The termination request is reviewed by the **DD-Lead** , who is authorized to **Send Back or**
|
||||
**Revoke** the termination request for clarification or reconsideration. All such actions
|
||||
require **mandatory remarks captured in Work Notes**.
|
||||
|
||||
```
|
||||
4.3.2.5 Legal Verification
|
||||
```
|
||||
- The **Legal Team** reviews documentation, ensures contractual breaches are well-
|
||||
supported, and checks all precedents.
|
||||
- May raise queries via **Worknotes** or **Send Back** the case to DD-Lead for clarification.
|
||||
- Once satisfied, forwards the verified case back to **DD-Lead** for next action.
|
||||
|
||||
```
|
||||
4.3.2.6 DD-Lead → DD-Head Review
|
||||
```
|
||||
- The **DD-Lead** attaches Legal’s feedback and forwards the case to **DD-Head** for strategic
|
||||
review.
|
||||
- **DD-Head** validates the case, evaluates impact, and presents it to **National Business**
|
||||
**Head (NBH)** for final business decision.
|
||||
|
||||
|
||||
```
|
||||
4.3.2.7 NBH Evaluation
|
||||
```
|
||||
- The **NBH** reviews all documentation and Legal remarks.
|
||||
- May choose one of three actions:
|
||||
o **Go Ahead** → Approve for issuance of **Show Cause Notice (SCN)**
|
||||
o **Hold Decision** → Pause temporarily for further monitoring or negotiation
|
||||
o **Raise Query** → Sends back to DD-Lead for additional input
|
||||
|
||||
```
|
||||
4.3.2.8 Show Cause Notice (SCN) Issuance
|
||||
```
|
||||
- Upon NBH approval, the system triggers Legal to prepare and issue the **SCN**.
|
||||
- The **DD-Lead** formally shares the SCN with the dealer through **DD-Admin**.
|
||||
- Dealer replies to the SCN by email or letter, which **DD-Admin uploads** to the portal.
|
||||
- For termination cases, the **F&F settlement process is triggered only on the Last**
|
||||
**Working Day (LWD)**. The system shall **control the F&F trigger based on the LWD date** ,
|
||||
irrespective of the termination approval date.
|
||||
|
||||
```
|
||||
4.3.2.9 Evaluation of Dealer Response
|
||||
```
|
||||
- The **DD-Lead** , **ZBH** , **RBM** , and **DD-Head** jointly review the dealer’s SCN response.
|
||||
- Uploads internal comments, Legal feedback, and recommendation for NBH’s final
|
||||
decision.
|
||||
|
||||
```
|
||||
4.3.2.10 NBH Final Decision
|
||||
```
|
||||
- The **NBH** reviews the compiled case with Legal advice and decides among:
|
||||
o **Approve Termination** → Moves to CEO/CCO for confirmation
|
||||
o **Reconsider** → Allow additional time or corrective action
|
||||
o **Reject** → Case closed without termination
|
||||
|
||||
```
|
||||
4.3.2.11 11. CEO & CCO Authorization
|
||||
```
|
||||
- **CEO** and **Chief Commercial Officer (CCO)** review the NBH-approved termination.
|
||||
- Provide authorization on the portal.
|
||||
- Once signed off, the decision becomes final.
|
||||
|
||||
```
|
||||
4.3.2.12 12. Legal Termination Letter
|
||||
```
|
||||
- The **Legal Team** generates the **Termination Letter** to the portal.
|
||||
- The letter is auto-visible to **DD-Lead** , **DD-Admin** , and **Finance**.
|
||||
- A system notification is triggered to all linked personas.
|
||||
|
||||
|
||||
```
|
||||
4.3.2.13 13. DD-Admin Communication & F&F Trigger
|
||||
```
|
||||
- The **DD-Admin** shares the official **Termination Letter** with the dealer and field team.
|
||||
- Marks the case as “Terminated” in the portal.
|
||||
- Forwards the case to **Finance** for **Full & Final Settlement** initiation.
|
||||
- Updates the worknote with final remarks and due-date for settlement.
|
||||
|
||||
### 4.4 Dealer Full & Final (F&F) Settlement – Process Flow
|
||||
|
||||
```
|
||||
4.4.1.1 Overview
|
||||
```
|
||||
The **Full & Final (F&F) Settlement Process** governs the financial closure of a dealership
|
||||
following **Resignation** or **Termination**.
|
||||
It ensures that all financial obligations between Royal Enfield and the dealer —
|
||||
including **security deposits, recoveries, payables, and department-wise dues** — are
|
||||
transparently reconciled, verified, and documented before closure.
|
||||
|
||||
**4.4.2 Step-by-Step Process Flow**
|
||||
|
||||
```
|
||||
4.4.2.1 F&F Initiation
|
||||
```
|
||||
- Triggered automatically once the **Resignation Acceptance Letter** or **Termination**
|
||||
**Letter** is uploaded by **Legal**.
|
||||
- The **DD-Admin** or **DD-Lead** initiates the F&F case in the **Finance Dashboard** , which
|
||||
creates a unique **FNF Case ID** linked to the dealer code.
|
||||
- The system auto-fetches dealer details, associated documents, resignation/termination
|
||||
date, and due dates.
|
||||
- Notification is sent to the **Finance Team** and all functional departments to begin the
|
||||
clearance process.
|
||||
|
||||
|
||||
## 8 Termination
|
||||
|
||||
A **Dealer Termination** process is initiated when a dealership’s continuation is deemed
|
||||
non-viable due to business, financial, or ethical reasons. The termination may arise
|
||||
|
||||
from three primary causes — **working capital inadequacy** , **continued underperformance** ,
|
||||
or **unethical practices**. Cases involving working capital or performance issues follow a
|
||||
|
||||
structured review and approval process, allowing the concerned dealer to provide
|
||||
clarification and supporting data before final decision. However, any instance
|
||||
|
||||
of **unethical practice** — including fraud, policy breach, or reputational risk to the brand
|
||||
— results in **immediate termination**. All termination cases are documented within the
|
||||
|
||||
|
||||
system, with remarks, evidence, and approval trails maintained for audit and
|
||||
compliance verification.
|
||||
|
||||
### 8.1 Create Termination Request
|
||||
|
||||
**8.1.1 Functionality Scope**
|
||||
|
||||
The **Create Termination Request** form enables authorized users such as **DD-Lead** , **DD-Admin** ,
|
||||
or **ASM** to initiate a termination case within the system. The form captures comprehensive
|
||||
dealership details including operational timelines, format type, constitution, performance data,
|
||||
and financial indicators. It also specifies the **Termination Category** (e.g., Working Capital,
|
||||
Performance Issue, or Unethical Practice), supported by descriptive justification and relevant
|
||||
documentation. The request forms the starting point of the digital termination workflow and
|
||||
ensures that all necessary contextual data and artefacts are available for subsequent reviews and
|
||||
escalations.
|
||||
|
||||
**8.1.2 Width**
|
||||
|
||||
- Allows creation of new termination requests by entering **Dealer Code** , operational details, and
|
||||
financial data.
|
||||
- Captures **Termination Category** and **Description** for clarity on grounds of termination.
|
||||
|
||||
|
||||
- Supports upload of supporting artefacts such as MOMs, dealer commitments, or financial
|
||||
statements.
|
||||
- Automatically records creator and timestamp for traceability.
|
||||
|
||||
**8.1.3 Depth**
|
||||
|
||||
- Integrates directly with the **Progress Timeline** , displaying real-time status updates across levels.
|
||||
- Each submission auto-generates an internal case ID linked to the dealer code for tracking.
|
||||
- Supports structured escalation logic based on the **Termination Category** — standard route for
|
||||
working capital/performance cases, immediate escalation for unethical practices.
|
||||
- Maintains versioned records for every document uploaded at creation stage.
|
||||
|
||||
**8.1.4 Personas-wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona / Role Access Level Visibility & Permissions
|
||||
ASM / DD-AM Area Level Can initiate termination requests, upload MOMs and
|
||||
dealer commitments.
|
||||
RBM + DD-ZM Regional / Zonal
|
||||
Level
|
||||
```
|
||||
```
|
||||
Can view request details and validate information before
|
||||
escalation.
|
||||
ZBH Zonal Head Reviews initial request data, comments on justification,
|
||||
and forwards to DD-Lead.
|
||||
DD-Lead / DD-
|
||||
Admin
|
||||
```
|
||||
```
|
||||
National
|
||||
Coordination
|
||||
```
|
||||
```
|
||||
Can initiate, review, and forward requests; validates
|
||||
completeness and assigns to Legal if required.
|
||||
Legal Review Level Can view dealer details and supporting documents for
|
||||
legal evaluation.
|
||||
NBH National Business
|
||||
Head
|
||||
```
|
||||
```
|
||||
Can view the entire request summary before decision
|
||||
and closure approval.
|
||||
```
|
||||
|
||||
### 8.2 Termination Ticket overview
|
||||
|
||||
**8.2.1 Functionality Scope**
|
||||
|
||||
The **Details View** provides a consolidated summary of all key information related to the dealer
|
||||
under review. It includes dealership codes, operational history, financial performance, and
|
||||
termination-specific parameters. This enables reviewers at every level—whether ASM, ZBH, or
|
||||
Legal—to quickly assess background context and validate evidence before taking action. The
|
||||
interface also displays the current workflow stage and offers in-screen options
|
||||
to **Approve** , **Withdraw** , or **Send Back** the request with remarks, ensuring traceable and reason-
|
||||
based decisions.
|
||||
|
||||
**8.2.2 Width**
|
||||
|
||||
- Displays complete dealer profile: code, name, location, and GST details.
|
||||
- Shows operational data: inauguration date, LOA, LOI, format, constitution, and last six-month
|
||||
sales.
|
||||
- Captures termination-specific data: **Termination Category** , reason, and case severity (e.g.,
|
||||
“High”).
|
||||
- Provides workflow action buttons— **Approve** , **Withdraw** , **Send Back** —with mandatory remarks
|
||||
input.
|
||||
- Integration with Work Notes for contextual communication and escalation traceability.
|
||||
|
||||
|
||||
**8.2.3 Depth**
|
||||
|
||||
The **Detail Tab** serves as the **central operational dashboard** for viewing all dealer, operational,
|
||||
and termination-related data within a single, structured interface. It merges static dealer master
|
||||
information with dynamic workflow inputs and uploaded artefacts, ensuring contextual visibility
|
||||
for all stakeholders.
|
||||
|
||||
```
|
||||
8.2.3.1 Components & Functional Behavior
|
||||
```
|
||||
- **Dealer Information (Owner: DD-Admin / System Integration Layer)**
|
||||
Displays master data pulled from the Dealer Master table — including **Dealer Code,**
|
||||
**Name, Address, GST, Domain Name, City Category, Sales Code, Service Code, and GMA**
|
||||
**Code**.
|
||||
o Synced automatically from RE’s **Dealer Database (Master Registry)**.
|
||||
o Read-only for all personas except system admin for data correction requests.
|
||||
o Enables search and cross-referencing across termination, resignation, and
|
||||
onboarding records.
|
||||
- **Operational Details (Owner: DD-Lead / Workflow Engine)**
|
||||
Highlights the dealership’s business health indicators and structural data, including **LOA,**
|
||||
**LOI, Inauguration Date, Constitution Type, Dealership Type, Format Category, Dealer**
|
||||
**Score Card Band, and Last Six-Month Sales**.
|
||||
o Pulled dynamically from the Sales & Performance Module.
|
||||
o Reflects the most recent sales cycle, ensuring leadership sees live performance
|
||||
metrics during termination decision-making.
|
||||
o Editable only by DD-Lead or authorized DD-Admin prior to case lock.
|
||||
- **Termination Details (Owner: DD-Lead / DD-ZM / Legal)**
|
||||
Captures case-specific details such as **Termination Category, Reason Description, and**
|
||||
**Attachments**.
|
||||
o Termination Category includes options like _Working Capital Issues, Performance_
|
||||
_Shortfall, Breach of Agreement, or Unethical Practices_.
|
||||
o Documents uploaded here are visible to all reviewers across the approval chain,
|
||||
maintaining transparency.
|
||||
o Legal team references this section while framing the **Show Cause Notice (SCN)** or
|
||||
final termination letter.
|
||||
- **Workflow Actions (Owner: Workflow Engine / DD-Lead)**
|
||||
Displays **Approve, Withdraw, and Send Back** controls based on role permissions.
|
||||
o Triggers automated workflow transitions and real-time updates in **Progress**
|
||||
**Timeline** and **Audit Trail**.
|
||||
o Any action logs mandatory remarks under “Communication & Notes” with
|
||||
timestamp and user identity.
|
||||
o Permissions vary per role:
|
||||
|
||||
|
||||
```
|
||||
▪ ASM, RBM: Can only comment or escalate.
|
||||
▪ ZBH, DD-Lead, NBH: Can approve or send back.
|
||||
▪ Legal: Can finalize after NBH approval.
|
||||
```
|
||||
- **Document Management Section (Owner: DD-Admin / Legal)**
|
||||
Repository displaying all uploaded evidence or reports associated with the termination.
|
||||
o Documents listed by **name, type, uploader, and date**.
|
||||
o Supports inline viewing (no download needed) for internal confidentiality.
|
||||
o File retention policy aligns with RE’s compliance standards (minimum 7 years).
|
||||
- **Audit Trail (Owner: Workflow Engine / System Log)**
|
||||
Chronologically records every action taken within the termination case — including
|
||||
user, timestamp, and nature of change.
|
||||
|
||||
**8.2.4 Personas-wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona / Role Access Level Visibility & Permissions
|
||||
ASM / DD-AM Area Level Can initiate and upload dealer MOMs and commitment
|
||||
records.
|
||||
RBM + DD-ZM Regional / Zonal
|
||||
Level
|
||||
```
|
||||
```
|
||||
Review dealer details, validate termination rationale,
|
||||
and escalate with remarks.
|
||||
ZBH Zonal Business
|
||||
Head
|
||||
```
|
||||
```
|
||||
Approves or returns the case with comments; can
|
||||
forward to DD-Lead.
|
||||
DD-Lead / DD-
|
||||
Admin
|
||||
```
|
||||
```
|
||||
National
|
||||
Coordination
|
||||
```
|
||||
```
|
||||
Validate details, review documents, assign to Legal, or
|
||||
push for F&F after NBH approval.
|
||||
Legal Legal Level Review dealer information, validate grounds, and issue
|
||||
termination letter.
|
||||
NBH National Head Provides final decision and authorization before case
|
||||
closure.
|
||||
```
|
||||
### 8.3 Termination Approval & Review Process
|
||||
|
||||
**8.3.1 Functionality Scope**
|
||||
|
||||
The **Termination Approval module** enables Royal Enfield’s internal stakeholders to manage
|
||||
dealership termination cases in a structured, transparent, and traceable workflow. It ensures that
|
||||
|
||||
|
||||
every dealership performance concern — whether due to **working capital shortfall** , **sustained
|
||||
underperformance** , or **unethical practices** — is systematically reviewed, documented, and acted
|
||||
upon through the defined escalation hierarchy.
|
||||
|
||||
This module supports structured documentation of **dealer meetings** , **uploaded
|
||||
artefacts** , **reviewer remarks** , and **legal correspondence** , ensuring no manual communication
|
||||
dependency.
|
||||
All approvals, send-backs, or withdrawals are centrally logged, supported by **Work Notes** ,
|
||||
ensuring collaborative clarity and institutional memory across teams.
|
||||
|
||||
The **CEO is the final approving authority** for dealer termination cases. The **Legal team prepares
|
||||
and issues the termination letter only after CEO approval** , and **not upon NBH approval**.
|
||||
**CCO and CEO** are included as approval authorities with **Approve, Hold, and Reject options**.
|
||||
The **dealer does not have portal access** for termination workflows.
|
||||
|
||||
**8.3.2 Width**
|
||||
|
||||
The process spans across the complete DD and Legal hierarchy, ensuring clear role-based
|
||||
accountability:
|
||||
|
||||
- **ASM:** Conducts monthly visits, logs Meeting of Minutes (MOM), uploads dealer
|
||||
commitment letter and personal observations. Logging MOM is not the part of this system
|
||||
but when he feel to trigger Termination, he will log as description & associate documents
|
||||
while initiating the flow.
|
||||
- **RBM + DD-ZM:** Escalate after repeated concerns, conduct joint meetings, and document
|
||||
dealer responses on portal.
|
||||
- **ZBH:** Reviews zonal-level non-compliance, escalates unresolved cases to DD-Lead and
|
||||
NBH.
|
||||
- **DD-Lead:** Reviews consolidated reports, validates escalation records, prepares case
|
||||
presentation, and assigns to Legal.
|
||||
- **Legal:** Reviews chronology, evaluates policy or contractual breaches, issues SCN, and
|
||||
prepares final Termination Letter.
|
||||
- **DD-Head:** Reviews with DD-Lead and Legal; presents case to NBH for decision.
|
||||
- **NBH:** Provides final decision – approve, query, or hold.
|
||||
- **DD-Admin:** Uploads dealer’s SCN response and handles F&F coordination post Legal
|
||||
issuance.
|
||||
|
||||
|
||||
**8.3.3 Depth**
|
||||
|
||||
- **Structured Case Creation (Owner: DD-Lead / DD-Admin / ASM)**
|
||||
A Termination case is initiated through the “Create Termination Request” form by DD-
|
||||
Lead, DD-Admin, or ASM.
|
||||
o Each request is tagged with a unique **Termination ID** (e.g., TERM-001).
|
||||
o Dealer and operational data are automatically fetched from the **Dealer**
|
||||
**Master** and **Sales System** for accuracy.
|
||||
- **Case Workflow Management (Owner: Workflow Engine)**
|
||||
Each stage of the termination journey — from ASM initiation to Legal closure — is
|
||||
mapped to approval levels.
|
||||
o **ASM → RBM/DD-ZM → ZBH → DD-Lead → Legal → DD-Head → NBH**.
|
||||
o Actions at every level (Approve, Withdraw, Send Back) are recorded with
|
||||
mandatory remarks.
|
||||
o Each remark auto-updates in **Work Notes** and **Progress Timeline** , triggering
|
||||
instant notifications to the next role.
|
||||
- **Work Note Integration (Owner: All Reviewers)**
|
||||
The **Work Note** acts as the **central communication thread** within each termination case.
|
||||
o Each reviewer (ASM, RBM, ZBH, DD-Lead, Legal, etc.) can post contextual remarks,
|
||||
share discussions, or tag specific users.
|
||||
o Tagged users (e.g., @DD-Lead, @Legal) receive instant notifications via **system**
|
||||
**alerts** and **email**.
|
||||
o Work Notes serve as a real-time collaboration and escalation record — every
|
||||
comment, clarification, or update remains **time-stamped and user-tagged**.
|
||||
o Legal and DD-Head may also use Work Notes to request clarification from lower
|
||||
hierarchies (ASM, RBM, ZBH).
|
||||
o Once a note is submitted, it becomes immutable and part of the **permanent**
|
||||
**record** under **Audit Trail**.
|
||||
- **Meeting & Artefact Uploads (Owner: ASM, RBM, ZBH)**
|
||||
Each level of escalation includes upload of MOMs, dealer commitment letters, and
|
||||
observations while Approving at his level.
|
||||
o Artefacts are uploaded as PDFs (e.g., _Meeting_MOM_June2025.pdf_ ).
|
||||
o Dealer commitments are scanned and attached for cross-reference during Legal
|
||||
and NBH reviews.
|
||||
- **Approval Actions (Owner: Workflow Engine)**
|
||||
Reviewers can take the following actions:
|
||||
o **Approve:** Confirms escalation readiness for next level.
|
||||
o **Send Back:** Pushes case back for clarification with remarks visible in Work Notes.
|
||||
o **Withdraw:** Used when the concern is resolved or no termination action is required.
|
||||
Each action is recorded in both **Audit Trail** and **Work Notes** , ensuring clarity on
|
||||
decision paths.
|
||||
|
||||
|
||||
- **Legal Review and Issuance (Owner: Legal Team)**
|
||||
Legal reviews the case chronology and uploaded artefacts.
|
||||
o If clarification is needed, they “Send Back” via Work Notes.
|
||||
o Once validated, Legal create the **Show Cause Notice (SCN)** to the portal and later
|
||||
create the **Termination Letter** post NBH approval.
|
||||
o These Show cause Notice and Termination Letter will be created within the system
|
||||
o All uploaded legal artefacts remain accessible to DD-Lead, DD-Admin, and NBH.
|
||||
- **Dealer Interaction & Closure (Owner: DD-Admin / DD-Lead)**
|
||||
Dealer replies to the SCN via DD-Admin, who uploads the response to the portal.
|
||||
o DD-Lead reviews dealer’s response with inputs from RBM and ZBH, updates
|
||||
closure remarks, and forwards to NBH.
|
||||
o Post-approval, Legal uploads the Termination Letter, visible to DD-Admin and
|
||||
dealer.
|
||||
o DD-Admin initiates **F&F** coordination, ensuring all records are finalized within SLA.
|
||||
- **Immediate Termination (Owner: DD-Lead + Legal)**
|
||||
Cases categorized under “Unethical Practice” trigger direct routing to Legal + DD-
|
||||
Lead, skipping intermediate reviews.
|
||||
o Immediate Legal action and issuance of termination communication occur within
|
||||
the system, ensuring swift compliance.
|
||||
- **Audit Trail (Owner: System Engine)**
|
||||
Each user action — approval, send back, upload, comment — is timestamped and
|
||||
permanently logged.
|
||||
o The trail captures: _User Name, Action Type, Timestamp, Remarks Summary, and_
|
||||
_Linked Artefact_.
|
||||
o Accessible by DD-Lead, Legal, DD-Head, and NBH for compliance review.
|
||||
|
||||
**8.3.4 Personas-wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona Responsibilities & Key Actions Access Rights
|
||||
ASM Creates termination request, uploads MOM & dealer
|
||||
commitments, adds initial remarks and observations.
|
||||
```
|
||||
```
|
||||
Create, View,
|
||||
Comment
|
||||
RBM / DD-
|
||||
ZM
|
||||
```
|
||||
```
|
||||
Reviews ASM input, conducts escalation meetings,
|
||||
uploads MOM, provides joint recommendations.
|
||||
```
|
||||
```
|
||||
View, Approve,
|
||||
Send Back
|
||||
ZBH Reviews regional non-compliance, uploads MOM,
|
||||
forwards unresolved cases to DD-Lead.
|
||||
```
|
||||
```
|
||||
Approve, Send
|
||||
Back
|
||||
DD-Lead Reviews full chronology, validates artefacts, triggers Legal
|
||||
for input, issues SCN, consolidates for final closure.
|
||||
```
|
||||
```
|
||||
Full Access,
|
||||
Approve,
|
||||
Withdraw
|
||||
Legal Reviews chronology, uploads SCN, issues Termination
|
||||
Letter, queries if required through Work Notes.
|
||||
```
|
||||
```
|
||||
Approve, Send
|
||||
Back, Upload
|
||||
DD-Head Reviews consolidated cases, presents them to NBH for
|
||||
final decision.
|
||||
```
|
||||
```
|
||||
Review, Comment
|
||||
```
|
||||
|
||||
```
|
||||
NBH Approves or holds termination case; final authority on go-
|
||||
ahead decisions.
|
||||
```
|
||||
```
|
||||
Approve / Hold
|
||||
```
|
||||
```
|
||||
DD-Admin Uploads dealer’s SCN reply, final Termination Letter, and
|
||||
initiates F&F.
|
||||
```
|
||||
```
|
||||
Upload, Close
|
||||
```
|
||||
```
|
||||
Dealer
|
||||
(Read-only)
|
||||
```
|
||||
```
|
||||
Views SCN and final Termination Letter. View Only
|
||||
```
|
||||
### 8.4 Termination Progress Timeline
|
||||
|
||||
**8.4.1 Functionality Scope**
|
||||
|
||||
The **Termination Progress Timeline** provides a stage-wise visualization of the entire termination
|
||||
journey — from case initiation to final closure. It ensures that every escalation, document, review,
|
||||
and approval is tracked transparently with timestamped accountability.
|
||||
|
||||
Each level in the workflow — from **ASM initiation** to **CEO authorization** — is dynamically
|
||||
reflected with role names, document counts, feedback notes, and status indicators.
|
||||
The module promotes structured collaboration by integrating **Work Notes** and **Audit
|
||||
Trail** updates at each milestone, enabling leadership to monitor the decision flow in real time.
|
||||
|
||||
|
||||
**8.4.2 Width**
|
||||
|
||||
The timeline consolidates inputs from multiple roles, creating an end-to-end view of operational,
|
||||
business, and legal evaluations:
|
||||
|
||||
- **ASM** initiates the request and uploads meeting artefacts.
|
||||
- **RBM / DD-ZM** review and escalate based on repeated violations.
|
||||
- **ZBH** performs zonal validation and comments.
|
||||
- **DD-Lead** consolidates data, reviews chronology, and assigns to Legal.
|
||||
- **Legal** verifies contract breaches and provides legal opinion or Show Cause Notice (SCN).
|
||||
- **NBH** performs business-level evaluation and grants or holds final approval.
|
||||
- **CEO / CCO** complete the executive authorization.
|
||||
- **DD-Admin** coordinates issuance of the final Termination Letter and forwards it to F&F.
|
||||
|
||||
Each transition (approve, send-back, withdraw) automatically updates the timeline with the
|
||||
reviewer’s remarks and uploaded artefacts.
|
||||
|
||||
**8.4.3 Depth**
|
||||
|
||||
The Termination Progress Timeline follows a clearly defined 14-stage lifecycle. Each stage is
|
||||
associated with specific ownership, document uploads, and Work Note actions.
|
||||
|
||||
```
|
||||
8.4.3.1 Stage-wise Breakdown
|
||||
```
|
||||
1. **Request Initiated** – _ASM / Initiator_
|
||||
o Case created with details, termination reason, and dealer code.
|
||||
o Supporting documents like MOM and commitment letters attached.
|
||||
o Remarks and feedback logged in Work Notes.
|
||||
2. **RBM Review** – _RBM + DD-ZM_
|
||||
o Joint meeting notes uploaded; recommendations shared.
|
||||
o Approve or Send-Back with clarification via Work Note.
|
||||
3. **ZBH Review** – _Zonal Business Head_
|
||||
o Evaluates pattern of violations, reviews MOM chain, and adds escalation remarks.
|
||||
4. **DD Lead Review** – _DD-Lead_
|
||||
o Consolidates documentation from ASM, RBM, and ZBH.
|
||||
o Prepares case synopsis and assigns to Legal for compliance validation.
|
||||
5. **Legal Verification** – _Legal Department_
|
||||
o Reviews breach type (Working Capital, Performance, Unethical Practice).
|
||||
o Queries or approves via Work Notes.
|
||||
o Uploads draft SCN if verified.
|
||||
6. **NBH Evaluation** – _National Business Head_
|
||||
|
||||
|
||||
```
|
||||
o Reviews termination recommendation; may approve, hold, or query.
|
||||
```
|
||||
7. **Show Cause Notice (SCN)** – _Legal + DD-Lead_
|
||||
o Official SCN issued to dealer.
|
||||
o Dealer reply awaited; all correspondence uploaded.
|
||||
8. **DD Lead & Legal Review** – _Joint Review_
|
||||
o Evaluates dealer’s SCN reply.
|
||||
o Records internal discussion outcome in Work Notes.
|
||||
9. **DD-Head Review** – _Dealer Development Head_
|
||||
o Prepares presentation and recommendation for NBH.
|
||||
10. **CCO Approval** – _Chief Commercial Officer_
|
||||
o Reviews and endorses NBH’s decision.
|
||||
11. **CEO Final Approval** – _Chief Executive Officer_
|
||||
o Authorizes final termination execution.
|
||||
12. **Legal – Termination Letter** – _Legal Team_
|
||||
o Uploads signed Termination Letter to portal.
|
||||
o Triggers auto-notifications to DD-Lead and DD-Admin.
|
||||
13. **DD-Admin – Share with Dealer** – _DD-Admin_
|
||||
o Forwards Termination Letter to dealer.
|
||||
o Initiates F&F process and records completion date.
|
||||
14. **Dealer Terminated** – _System Generated_
|
||||
o Marks dealership status as “Terminated.”
|
||||
o Case locked for further edits; all data archived under Audit Trail.
|
||||
|
||||
```
|
||||
8.4.3.2 Work Note Integration
|
||||
```
|
||||
- Each stage allows the reviewer to post contextual **Work Notes** for coordination,
|
||||
clarification, or escalation.
|
||||
- Notes automatically capture **author, timestamp, and linked stage**.
|
||||
- Tagged users receive both **email** and **in-app alerts**.
|
||||
- Work Notes act as the **single source of truth** , capturing every internal discussion and
|
||||
external clarification.
|
||||
- Once the case reaches “Dealer Terminated,” Work Notes are archived as part of the
|
||||
official record visible under **Audit Trail**.
|
||||
|
||||
**8.4.4 Personas-wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona Visibility in Timeline Actions Allowed
|
||||
ASM Initiate request, view complete history, comment
|
||||
in Work Notes.
|
||||
```
|
||||
```
|
||||
Create, Upload Docs,
|
||||
Comment
|
||||
RBM / DD-ZM See all lower-level stages, add remarks, approve or
|
||||
send-back.
|
||||
```
|
||||
```
|
||||
Approve, Send-Back,
|
||||
Comment
|
||||
ZBH Access RBM & ASM artefacts, escalate to DD-Lead. Approve, Send-Back
|
||||
```
|
||||
|
||||
```
|
||||
DD-Lead Full timeline visibility, assign to Legal, manage SCN,
|
||||
approve final closure.
|
||||
```
|
||||
```
|
||||
Full Access
|
||||
```
|
||||
```
|
||||
Legal Review termination grounds, issue SCN, upload
|
||||
Termination Letter.
|
||||
```
|
||||
```
|
||||
Approve, Send-Back,
|
||||
Upload Docs
|
||||
NBH View all previous stages, make go/no-go decision. Approve / Hold
|
||||
CCO / CEO Executive-level read access, approve final
|
||||
termination.
|
||||
```
|
||||
```
|
||||
Approve Only
|
||||
```
|
||||
```
|
||||
DD-Admin View complete timeline, upload dealer response &
|
||||
Legal letter, initiate F&F.
|
||||
```
|
||||
```
|
||||
Upload, Close
|
||||
```
|
||||
```
|
||||
Dealer (Read-
|
||||
only)
|
||||
```
|
||||
```
|
||||
View SCN and Termination Letter post-issuance. View Only
|
||||
```
|
||||
1023
docs/modular_wise/04_FF_Settlement.md
Normal file
1023
docs/modular_wise/04_FF_Settlement.md
Normal file
File diff suppressed because it is too large
Load Diff
252
docs/modular_wise/05_Constitutional_Change.md
Normal file
252
docs/modular_wise/05_Constitutional_Change.md
Normal file
@ -0,0 +1,252 @@
|
||||
|
||||
**1.2.4 Dealer Constitutional Change Enablement**
|
||||
|
||||
- Enabled dealers to **initiate constitutional change requests** post onboarding.
|
||||
- Supported all approved constitution change scenarios:
|
||||
o Proprietorship, Partnership, LLP, and Private Limited permutations
|
||||
- Implemented **dynamic document requirement determination** based on target
|
||||
constitution.
|
||||
- Explicitly confirmed **no OCR-based document validation** ; all validations are manual and
|
||||
role-driven.
|
||||
- Ensured statutory compliance via Legal review before master data updates.
|
||||
|
||||
**1.2.5 Post-Exit Access Control**
|
||||
|
||||
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
|
||||
completed.
|
||||
|
||||
|
||||
|
||||
- **Change in Constitution Request**
|
||||
|
||||
```
|
||||
The dealer can initiate a Change in Constitution request to seek approval from RE
|
||||
management for ownership or structural changes within the dealership. Upon approval,
|
||||
the dealer may proceed with the legally compliant transition.
|
||||
```
|
||||
```
|
||||
Supported Change in Constitution scenarios include:
|
||||
```
|
||||
```
|
||||
o Proprietorship (Single Owner) → Partnership
|
||||
o Proprietorship → LLP (Limited Liability Partnership)
|
||||
o Proprietorship → Private Limited
|
||||
o Partnership → LLP
|
||||
o Partnership → Private Limited
|
||||
o Private Limited → LLP
|
||||
o Private Limited → Partnership
|
||||
```
|
||||
All dealer-initiated requests are subject to **defined validations, mandatory document
|
||||
submissions, role-based reviews, and approvals**. The dealer’s access is **restricted to initiation,
|
||||
document upload, and status visibility** , with **final decision-making authority retained by
|
||||
authorized internal stakeholders of RE**
|
||||
|
||||
|
||||
## 3 Definitions and Acronyms
|
||||
|
||||
```
|
||||
Acronym Full Form / Description
|
||||
RE Royal Enfield
|
||||
DD Dealer Development
|
||||
DD-AM Dealer Development – Area Manager
|
||||
DD-ZM Dealer Development – Zonal Manager
|
||||
DD-Lead Dealer Development – Lead
|
||||
DD-Head Dealer Development – Head
|
||||
RBM Regional Business Manager
|
||||
ZBH Zonal Business Head
|
||||
NBH National Business Head
|
||||
ASM Area Sales Manager
|
||||
FDD Financial Due Diligence (External Partner/Agency)
|
||||
LOI Letter of Intent
|
||||
EOR Essential Operating Requirements
|
||||
LOA Letter of Appointment
|
||||
F&F Full and Final (Dealer Settlement)
|
||||
KT Matrix Evaluation Matrix used for scoring applicants
|
||||
```
|
||||
|
||||
### 12.2 Dealer Constitutional Change Management
|
||||
|
||||
|
||||
**12.2.1 Functionality Scope**
|
||||
|
||||
This functionality enables a **dealer to initiate, track, and manage requests for change in business
|
||||
constitution** through the portal after successful onboarding. The system provides a **structured
|
||||
self-service mechanism** to propose constitution changes, capture legally required information,
|
||||
and submit **constitution-specific mandatory documents** , while routing the request through
|
||||
a **defined internal review and approval workflow**.
|
||||
|
||||
**12.2.2 Functional Width**
|
||||
|
||||
- Displays a **dealer-facing constitutional change dashboard** with summary indicators:
|
||||
o Total Requests
|
||||
o Pending Requests
|
||||
|
||||
|
||||
```
|
||||
o Completed Requests
|
||||
```
|
||||
- Lists all **constitution change requests** with:
|
||||
o Request ID
|
||||
o Current constitution
|
||||
o Proposed constitution
|
||||
o Submission date
|
||||
o Current status
|
||||
o Progress percentage
|
||||
- Enables **initiation of a new constitutional change request**
|
||||
- Supports the following **constitution change cases** :
|
||||
o Proprietorship (Single Owner) → Partnership
|
||||
o Proprietorship → LLP (Limited Liability Partnership)
|
||||
o Proprietorship → Private Limited
|
||||
o Partnership → LLP
|
||||
o Partnership → Private Limited
|
||||
o Private Limited → LLP
|
||||
o Private Limited → Partnership
|
||||
- Dynamically determines **mandatory document requirements** based on the **target**
|
||||
**constitution**
|
||||
- Allows **document upload only as per applicable case**
|
||||
- Provides **role-based visibility** into request details, documents, and progress
|
||||
- Prevents duplicate or parallel requests as per policy
|
||||
|
||||
**12.2.3 Functional Depth**
|
||||
|
||||
- Constitutional change requests can be initiated **only for active and eligible dealers**.
|
||||
- On selecting **“New Constitutional Change”** , the dealer is presented with a structured
|
||||
submission form capturing:
|
||||
o Dealer Code and Dealer Name (auto-populated, non-editable)
|
||||
o Current constitution (auto-populated)
|
||||
o Proposed constitution (selectable from allowed options)
|
||||
o Reason for change
|
||||
o Details of new partners / members (where applicable)
|
||||
o Proposed shareholding pattern
|
||||
- Based on the **proposed constitution** , the system determines the **mandatory document**
|
||||
**checklist** as follows:
|
||||
|
||||
**12.2.4 Document Applicability Rules**
|
||||
|
||||
**A. Any change resulting in Partnership requires:**
|
||||
|
||||
|
||||
- GST Registration Certificate
|
||||
- Firm PAN Copy
|
||||
- Self-attested KYC documents
|
||||
- Partnership Agreement (Notarised)
|
||||
- Business Purchase Agreement (BPA)
|
||||
- Firm Registration Certificate (Partnership)
|
||||
- Cancelled Cheque
|
||||
- Declaration / Authorization Letter
|
||||
|
||||
**B. Any change resulting in LLP requires:**
|
||||
|
||||
- GST Registration Certificate
|
||||
- Firm PAN Copy
|
||||
- Self-attested KYC documents
|
||||
- Certificate of Incorporation (COI)
|
||||
- Business Purchase Agreement (BPA)
|
||||
- LLP Agreement (Notarised)
|
||||
- Cancelled Cheque
|
||||
- Declaration / Authorization Letter
|
||||
|
||||
**C. Any change resulting in Private Limited requires:**
|
||||
|
||||
- GST Registration Certificate
|
||||
- Firm PAN Copy
|
||||
- Self-attested KYC documents
|
||||
- MOA (Memorandum of Association)
|
||||
- AOA (Articles of Association)
|
||||
- Certificate of Incorporation (COI)
|
||||
- Business Purchase Agreement (BPA)
|
||||
- Cancelled Cheque
|
||||
- Declaration / Authorization Letter
|
||||
|
||||
**D. Any change resulting in Proprietorship requires:**
|
||||
|
||||
- GST Registration Certificate
|
||||
- Firm PAN Copy
|
||||
- Self-attested KYC documents
|
||||
- Cancelled Cheque
|
||||
- Declaration / Authorization Letter
|
||||
- The system enforces **document completeness validation** before allowing submission or
|
||||
progression.
|
||||
|
||||
|
||||
- **No OCR or automated document content extraction** is performed; all validations
|
||||
are **manual and role-driven**.
|
||||
- Upon submission:
|
||||
o The request is routed through a **multi-level internal review workflow** (DD ASM →
|
||||
DD ZM / RBM → ZBH → DD Lead → DD Head → NBH → Legal, as applicable).
|
||||
- Authorized internal roles may **Approve, Send Back, or Revoke** the request.
|
||||
- **Send Back and Revoke actions are communicated through Work Notes** , with mandatory
|
||||
remarks.
|
||||
- The **Legal team validates statutory compliance** and facilitates updates to dealer master
|
||||
records post-approval.
|
||||
- Upon final approval:
|
||||
o Dealer constitution details are updated in the system of record.
|
||||
o All actions, documents, and decisions are **logged for audit and compliance**.
|
||||
|
||||
**12.2.5 11.2.4 Personas-wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona Responsibilities Access Rights
|
||||
Dealer Initiates and tracks constitutional
|
||||
change requests.
|
||||
```
|
||||
- Initiate new constitutional change
|
||||
request
|
||||
- Provide change details and reasons
|
||||
- Upload mandatory documents as
|
||||
per applicable case
|
||||
- View request status, progress, and
|
||||
Work Notes
|
||||
DD ASM Coordinates document collection and
|
||||
supports validation.
|
||||
- View requests
|
||||
- Upload supporting documents
|
||||
- Assist in coordination
|
||||
DD ZM Performs zonal-level review and
|
||||
validation.
|
||||
- View requests
|
||||
- Review and provide inputs
|
||||
RBM Conducts regional business evaluation. • View requests
|
||||
- Review and recommend
|
||||
ZBH Ensures zonal governance compliance. • Review requests
|
||||
- Send Back or Revoke with
|
||||
mandatory Work Notes
|
||||
- Approve as per hierarchy
|
||||
DD Lead Ensures adherence to dealer
|
||||
development policies.
|
||||
- Review requests
|
||||
- Send Back or Revoke with
|
||||
mandatory Work Notes
|
||||
- Approval visibility
|
||||
|
||||
|
||||
```
|
||||
DD Head Oversees dealer development
|
||||
governance.
|
||||
```
|
||||
- Review requests
|
||||
- Send Back or Revoke with
|
||||
mandatory Work Notes
|
||||
- Approve as per hierarchy
|
||||
NBH Provides senior management approval. • Review requests
|
||||
- Send Back or Revoke with
|
||||
mandatory Work Notes
|
||||
- Final approval authority
|
||||
Legal
|
||||
Team
|
||||
|
||||
```
|
||||
Validates statutory compliance and legal
|
||||
documentation.
|
||||
```
|
||||
- Review documents
|
||||
- Validate compliance
|
||||
- Facilitate post-approval updates
|
||||
System Enforces rules and audit compliance. • Determine applicable documents
|
||||
dynamically
|
||||
- Validate completeness (no OCR)
|
||||
- Track progress and status
|
||||
- Maintain audit trail
|
||||
|
||||
|
||||
---
|
||||
344
docs/modular_wise/06_Relocation.md
Normal file
344
docs/modular_wise/06_Relocation.md
Normal file
@ -0,0 +1,344 @@
|
||||
# RE Onboarding & Offboarding System
|
||||
|
||||
# Requirements
|
||||
|
||||
```
|
||||
System Requirements Specifications
|
||||
```
|
||||
## 16 - Oct- 2025
|
||||
|
||||
## Version 1. 4
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
|
||||
-
|
||||
|
||||
## Change Log
|
||||
|
||||
### 1.1 Change Log – Version 2.0
|
||||
|
||||
|
||||
**1.1.1 Notification Channel Enhancement**
|
||||
|
||||
- Added **WhatsApp as a supported notification channel** for reminders and workflow
|
||||
communications (e.g., questionnaire completion and status updates), while restricting
|
||||
sensitive document sharing to email only.
|
||||
|
||||
|
||||
|
||||
|
||||
**1.1.8 Access Control & Visibility Refinements**
|
||||
|
||||
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
|
||||
- Granted **approval visibility** to DD Lead where applicable.
|
||||
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
|
||||
with **DD Lead, RBM, and ZBH** having view access.
|
||||
- Limited applicant and dealer portal access to **stage-specific and context-specific**
|
||||
**scenarios only**.
|
||||
- Confirmed that **dealer portal access is revoked after resignation or termination**.
|
||||
|
||||
**1.1.9 Terminology & Documentation Corrections**
|
||||
|
||||
- Clarified **KT Matrix as Kepner Tregoe Matrix** for consistency and correctness.
|
||||
|
||||
**1.1.10 Super Admin Role Introduction**
|
||||
|
||||
- Introduced a **Super Admin (Master Role)** with end-to-end access and workflow control
|
||||
across modules.
|
||||
- Defined segregation of duties by splitting Super Admin into **two DD Admin roles** with
|
||||
clearly scoped responsibilities.
|
||||
|
||||
|
||||
|
||||
|
||||
**1.2.1 Introduction of Dealer Portal**
|
||||
|
||||
- Introduced a **Dealer Portal capability** enabling onboarded dealers to initiate and track
|
||||
post-onboarding lifecycle requests through the portal.
|
||||
- Dealer actions are governed by **role-based access controls** , approval hierarchies, and
|
||||
audit mechanisms.
|
||||
|
||||
|
||||
**1.2.3 Dealer Relocation Request Enablement**
|
||||
|
||||
- Enabled dealers to **initiate and track relocation requests** through a guided workflow.
|
||||
- Added support for:
|
||||
o Manual or map-based location entry
|
||||
o Distance calculation from existing location
|
||||
o Property type selection and expected relocation date
|
||||
|
||||
|
||||
- Introduced **document-driven relocation validation** , including statutory, legal, property,
|
||||
and infrastructure documents.
|
||||
- Implemented **multi-level approval workflow** with Work Notes–based communication
|
||||
and audit trail.
|
||||
- Ensured dealer has **view and upload access only** , with approvals retained by RE
|
||||
stakeholders.
|
||||
|
||||
|
||||
|
||||
|
||||
## 1 System Overview & Problem Statement
|
||||
|
||||
**1.1.1 System Overview**
|
||||
|
||||
The **Dealer Onboarding and Offboarding System** for **Royal Enfield (RE)** is designed to **digitize,
|
||||
standardize, and streamline** the complete dealer lifecycle — from **application and
|
||||
evaluation** to **approval, resignation, termination, and full-and-final (F&F) settlement**.
|
||||
|
||||
At present, the process operates through **manual coordination** , involving **emails, spreadsheets,
|
||||
and physical documentation** , which makes it difficult to maintain visibility, accountability, and
|
||||
consistency across teams.
|
||||
|
||||
The proposed solution introduces a **centralized digital platform** that brings all stakeholders onto
|
||||
a single workflow. It ensures that every stage — **onboarding, operational approvals, financial
|
||||
diligence, legal validation, and final closure** — follows a **structured and traceable process**.
|
||||
|
||||
The system integrates seamlessly with existing RE applications such as **SSO** , **SAP** , and **Finance
|
||||
modules** , providing **role-based access** , **real-time tracking** , and **secure document management**.
|
||||
It also offers **automated workflows** , **configurable approval hierarchies** , and **AI-assisted decision
|
||||
support** to improve efficiency and reduce turnaround time.
|
||||
|
||||
By moving to a digital workflow, Royal Enfield will achieve higher levels of **process
|
||||
efficiency** , **data accuracy** , and **transparency** , ensuring faster decision-making and stronger
|
||||
control over the dealer network lifecycle.
|
||||
|
||||
## 2 Intended Audience
|
||||
|
||||
This document is intended for all stakeholders involved in the **design, implementation, approval,
|
||||
and operational use** of the **Dealer Onboarding and Offboarding System** at **Royal Enfield (RE)**.
|
||||
|
||||
The following user personas and roles are part of the system:
|
||||
|
||||
### 2.1 Business & Functional Users
|
||||
|
||||
|
||||
**2.1.2 Regional Sales & Business Team**
|
||||
|
||||
- **RBM (Regional Business Manager):** Participates in early-stage evaluations, provides
|
||||
ground-level business insights, and recommends suitable candidates.
|
||||
- **ZBH (Zonal Business Head):** Conducts the second-level review along with DD-Lead;
|
||||
provides strategic feedback on market and location viability.
|
||||
- **NBH (National Business Head):** Holds final authority for approval or rejection of dealer
|
||||
onboarding; reviews consolidated feedback from all levels.
|
||||
|
||||
- **Relocation Request Submission**
|
||||
|
||||
```
|
||||
The dealer can submit a relocation request in scenarios where there is an intent to shift
|
||||
the dealership from the current location to a new proposed location. The request is
|
||||
routed for internal feasibility assessment, validation, and management approval before
|
||||
execution.
|
||||
```
|
||||
|
||||
All dealer-initiated requests are subject to **defined validations, mandatory document
|
||||
submissions, role-based reviews, and approvals**. The dealer’s access is **restricted to initiation,
|
||||
document upload, and status visibility** , with **final decision-making authority retained by
|
||||
authorized internal stakeholders of RE**
|
||||
|
||||
|
||||
4.5.1.1 Overview
|
||||
```
|
||||
The **Finance Team Process Flow** governs all financial activities related to dealer lifecycle
|
||||
management — from **security deposit validation at onboarding** to **final settlement at
|
||||
resignation or termination**.
|
||||
It ensures complete financial traceability, proper verification of payments, and compliance with
|
||||
Royal Enfield’s financial governance standards.
|
||||
The process flow integrates with **Admin, Legal, Dealer Development (DD)** , and **Departmental
|
||||
Modules** , ensuring accurate financial updates and timely closure of all financial transactions.
|
||||
|
||||
**4.5.2 Step-by-Step Process Flow**
|
||||
|
||||
|
||||
```
|
||||
4.5.2.3 Internal Clarification & Approval
|
||||
```
|
||||
- **Action:**
|
||||
Finance initiates clarification rounds with departments or DD-Lead for mismatched data.
|
||||
- **System Steps:**
|
||||
o Uses the **Work Notes** section for comments, tagging users like _@DD-_
|
||||
_Lead_ , _@Legal_ , or _@Admin_.
|
||||
o Tracks status as _Pending Clarification_ until resolved.
|
||||
o After reconciliation, Finance locks the summary and updates case status
|
||||
to _Ready for Approval_.
|
||||
|
||||
`
|
||||
|
||||
|
||||
## 5 System Features & Requirements
|
||||
|
||||
Here, we describe the **system features** along with their respective **Width** and **Depth** to provide
|
||||
complete visibility of each requirement.
|
||||
|
||||
The **Width** defines the **functional coverage** of a feature — outlining what the feature does,
|
||||
its **boundaries, use cases, and user interactions**. It answers the question: _“What scenarios and
|
||||
actions are covered by this feature?”_
|
||||
|
||||
The **Depth** captures the **operational and behavioral details** — describing how the feature
|
||||
behaves through its **logic, workflow, system responses, and edge-case handling**. It answers the
|
||||
question: _“How does the system execute and respond in these scenarios?”_
|
||||
|
||||
---
|
||||
|
||||
Dealer Relocation Request
|
||||
|
||||
|
||||
|
||||
**12.2.6 Functionality Scope**
|
||||
|
||||
This functionality enables a **dealer to initiate, track, and manage dealership relocation
|
||||
requests** through the portal after successful onboarding. The system provides a **guided self-
|
||||
service mechanism** to propose a new dealership location, submit **location-specific statutory,
|
||||
property, and infrastructure documents** , and route the request through a **multi-level internal
|
||||
approval workflow**.
|
||||
|
||||
**12.2.7 Functional Width**
|
||||
|
||||
- Displays a **dealer-facing relocation dashboard** with summary indicators:
|
||||
o Total Requests
|
||||
o Pending Requests
|
||||
o Completed Requests
|
||||
- Lists all **relocation requests** with:
|
||||
o Request ID
|
||||
o Current location
|
||||
o Proposed location
|
||||
o Distance from current location
|
||||
o Submission date
|
||||
o Current status
|
||||
o Progress percentage
|
||||
- Enables **initiation of a new relocation request**
|
||||
- Allows **manual address entry** or **map-based location selection** for the proposed site
|
||||
- Captures **distance from the existing location**
|
||||
- Provides **request-level detailed view** including:
|
||||
o Relocation overview
|
||||
o Submitted information
|
||||
o Workflow progress
|
||||
|
||||
|
||||
```
|
||||
o Required and uploaded documents
|
||||
o History & audit trail
|
||||
```
|
||||
- Supports **document upload, verification, and status tracking**
|
||||
- Provides **role-based visibility and action controls**
|
||||
- Prevents parallel or duplicate relocation requests for the same outlet
|
||||
|
||||
**12.2.8 11.3.3 Functional Depth**
|
||||
|
||||
- Relocation requests can be initiated **only for active and eligible dealerships**.
|
||||
- On selecting **“New Relocation Request”** , the dealer is presented with a structured
|
||||
submission form capturing:
|
||||
o Dealer Code and Dealer Name (auto-populated, non-editable)
|
||||
o Current dealership address (auto-populated)
|
||||
o Proposed new location (manual entry or map selection)
|
||||
o Complete address details (city, state, pincode)
|
||||
o Distance from the current location
|
||||
o Property type
|
||||
o Expected relocation date
|
||||
o Reason for relocation
|
||||
- Upon submission, the request enters a **multi-level approval workflow** , typically
|
||||
progressing through:
|
||||
o DD ASM Review
|
||||
o RBM Review
|
||||
o DD ZM Review
|
||||
o ZBH Review
|
||||
o DD Lead Review
|
||||
o NBH Review
|
||||
o Legal (as applicable)
|
||||
- Each stage is reflected through a **visual workflow progress timeline** , showing:
|
||||
o Responsible role
|
||||
o Stage status (Completed / In Progress / Pending)
|
||||
o Overall progress percentage
|
||||
- The system enforces **mandatory document submission and verification** , categorized as:
|
||||
o Property
|
||||
o Legal
|
||||
o Statutory
|
||||
o Infrastructure
|
||||
- Required documents include, but are not limited to:
|
||||
o Property documents for new location
|
||||
o Lease / Rental agreement for new location
|
||||
o NOC from current landlord
|
||||
o Municipal approvals
|
||||
|
||||
|
||||
```
|
||||
o Fire safety certificate
|
||||
o Pollution clearance
|
||||
o Layout / Floor plan of new location
|
||||
o Photos of new location
|
||||
o Locality map
|
||||
o Building plan approval
|
||||
o Electricity connection documents
|
||||
o Water supply documents
|
||||
```
|
||||
- Document status is tracked as **Pending Verification** , **Verified** , or **Rejected**.
|
||||
- Authorized internal users may **Approve, Send Back, or Revoke** the relocation request.
|
||||
- **Send Back and Revoke actions are communicated through Work Notes** , with mandatory
|
||||
remarks captured by the system.
|
||||
- All uploads, verifications, remarks, and approvals are **logged in the audit trail**.
|
||||
- Upon final approval:
|
||||
o The relocation request is marked as completed.
|
||||
o Dealer master records are updated as per the approved new location.
|
||||
- The system ensures **full traceability and compliance** across all stages of the relocation
|
||||
process.
|
||||
|
||||
**12.2.9 11.3.4 Personas-wise Accessibility & Visibility**
|
||||
|
||||
```
|
||||
Persona Responsibilities Access Rights
|
||||
Dealer Initiates and tracks dealership
|
||||
relocation requests.
|
||||
```
|
||||
- Initiate relocation request
|
||||
- Provide proposed location details
|
||||
- Upload required documents
|
||||
- View request status, workflow
|
||||
progress, and Work Notes
|
||||
DD ASM Coordinates initial review and
|
||||
document readiness.
|
||||
- View relocation requests
|
||||
- Upload and review documents
|
||||
- Support coordination
|
||||
RBM Performs regional feasibility and
|
||||
business review.
|
||||
- View requests
|
||||
- Review and recommend
|
||||
DD ZM Conducts zonal-level evaluation. • View requests
|
||||
- Review and provide inputs
|
||||
ZBH Ensures zonal governance and
|
||||
compliance.
|
||||
- Review requests
|
||||
- Send Back or Revoke with mandatory
|
||||
Work Notes
|
||||
- Approve as per hierarchy
|
||||
DD Lead Ensures policy adherence and cross-
|
||||
functional alignment.
|
||||
- Review requests
|
||||
- Send Back or Revoke with mandatory
|
||||
Work Notes
|
||||
- Approval visibility
|
||||
|
||||
|
||||
```
|
||||
NBH Provides senior management approval. • Review requests
|
||||
```
|
||||
- Send Back or Revoke with mandatory
|
||||
Work Notes
|
||||
- Final approval authority
|
||||
Legal
|
||||
Team
|
||||
|
||||
```
|
||||
Validates statutory and legal
|
||||
compliance.
|
||||
```
|
||||
- Review legal documents
|
||||
- Validate approvals and clearances
|
||||
System Enforces workflow and compliance
|
||||
rules.
|
||||
- Control action availability
|
||||
- Track document status and progress
|
||||
- Maintain history and audit trail
|
||||
|
||||
50
docs/sla/IMPLEMENTATION_STATUS.md
Normal file
50
docs/sla/IMPLEMENTATION_STATUS.md
Normal file
@ -0,0 +1,50 @@
|
||||
# SLA — SRS §9.4 vs implementation
|
||||
|
||||
**Last reviewed:** 2026-05-18
|
||||
|
||||
## Summary
|
||||
|
||||
| Area | Config | Runtime | UI |
|
||||
|------|--------|---------|-----|
|
||||
| Onboarding | Yes | Yes | Badges + ops monitor |
|
||||
| Termination | Yes | Yes | Badges + ops monitor |
|
||||
| Resignation | Yes | Yes | Badges + ops monitor |
|
||||
| Relocation | Yes | Yes | Badges + ops monitor |
|
||||
| Constitutional | Yes | Yes | Badges + ops monitor |
|
||||
| F&F | Yes | Yes (per dept) | Ops monitor (FNF filter) |
|
||||
|
||||
**Overall:** Core engine **~95%** · UX/reporting **~85%** · SRS calendar/pause rules **~85%**
|
||||
|
||||
---
|
||||
|
||||
## Capability checklist
|
||||
|
||||
| Requirement | Status |
|
||||
|-------------|--------|
|
||||
| Templates, reminders, L1–L3 escalation, work notes | **Done** |
|
||||
| Operations monitor + aging buckets | **Done** |
|
||||
| My queue (`mineOnly`) + CSV export | **Done** |
|
||||
| Batch status API + badges all modules | **Done** |
|
||||
| F&F clearance timers | **Done** |
|
||||
| Business hours Mon–Fri 9–18 | **Done** (`slaBusinessTime.ts`) |
|
||||
| Repeat overdue reminders | **Done** |
|
||||
| Pause on termination hold | **Done** |
|
||||
| Geography-aware escalation | **Done** (`slaGeographyResolver.ts`) |
|
||||
| 30-day analytics (breach rate, top stages) | **Done** |
|
||||
| Questionnaire reminder admin | **Done** |
|
||||
| E2E tests | **Added** (`sla-lifecycle.test.ts`) |
|
||||
|
||||
---
|
||||
|
||||
## APIs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /api/sla/operations/dashboard` | Queue + analytics (`?module=&mineOnly=`) |
|
||||
| `GET /api/sla/operations/export` | CSV export |
|
||||
| `POST /api/sla/status/batch` | `{ items: [{ entityType, entityId }] }` |
|
||||
| `GET/PUT /api/sla/settings/questionnaire-reminder` | Prospect reminder cadence |
|
||||
|
||||
---
|
||||
|
||||
See [PENDING_WORK.md](./PENDING_WORK.md) for remaining optional items.
|
||||
54
docs/sla/ONBOARDING_SLA_RULES.md
Normal file
54
docs/sla/ONBOARDING_SLA_RULES.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Onboarding — what uses SLA vs applicant reminders
|
||||
|
||||
SRS §9.4 SLA applies to **internal role turnaround** (ASM, RBM, FDD, Finance, etc.).
|
||||
It does **not** apply to steps the **prospect/dealer** performs on the public portal.
|
||||
|
||||
## Do not configure internal SLA
|
||||
|
||||
| Pipeline step | Why | What to use instead |
|
||||
|---------------|-----|---------------------|
|
||||
| **Submitted** | Application is already submitted; no internal approver waiting. | None (audit only). |
|
||||
| **Questionnaire** | **Prospect** fills the questionnaire, not ASM/RBM. | **Scheduled BullMQ job** + manual bulk: `QUESTIONNAIRE_REMINDER` to applicant (`QuestionnaireReminderService`, requires `ENABLE_REDIS=true`). |
|
||||
|
||||
Runtime: `shouldTrackOnboardingSla()` skips these in `WorkflowService`. Re-seed sets `isActive: false` on old config rows.
|
||||
|
||||
## Internal SLA (configured)
|
||||
|
||||
| Step | Owner (internal) | Real scenario |
|
||||
|------|------------------|---------------|
|
||||
| Shortlist | DD Admin | Admin reviews leads and shortlists (§6.6). |
|
||||
| 1st / 2nd / 3rd Level Interview | RBM+DD-ZM, DD Lead+ZBH, NBH+DD Head | Panel feedback TAT after interviews are scheduled. |
|
||||
| FDD | FDD | External agency report upload/review. |
|
||||
| LOI Approval | NBH | Internal LOI approval gate. |
|
||||
| Security Deposit | Finance | Finance verifies payment proof uploaded by applicant. |
|
||||
| LOI Issue | DD Admin | LOI issuance processing. |
|
||||
| Dealer Code Generation | DD Admin | Code generation task. |
|
||||
| Architecture / Statutory Work | Architecture Team / DD Admin+Legal | Parallel compliance tracks. |
|
||||
| LOA | NBH | LOA approval. |
|
||||
| EOR Complete | DD Admin | EOR milestone. |
|
||||
| Inauguration | ASM | Post-EOR inauguration coordination. |
|
||||
|
||||
## Applicant-facing communications (not SLA matrix)
|
||||
|
||||
| Template | Audience | Trigger |
|
||||
|----------|----------|---------|
|
||||
| `OPPORTUNITY` / portal link | Applicant | Opportunity conversion |
|
||||
| `QUESTIONNAIRE_REMINDER` | Applicant | DD Admin bulk reminder (pending questionnaire) |
|
||||
| `QUESTIONNAIRE_SUBMITTED` | Applicant | On questionnaire submit |
|
||||
| `ONBOARDING_STATUS_UPDATE` | Applicant | Status changes |
|
||||
|
||||
## Scheduled prospect reminders (not SLA Configuration UI)
|
||||
|
||||
| Env variable | Default | Meaning |
|
||||
|--------------|---------|---------|
|
||||
| `ENABLE_REDIS` | `false` | Must be `true` for scheduler |
|
||||
| `QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED` | `true` | Master switch |
|
||||
| `QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS` | `1` | Wait after entering Questionnaire Pending |
|
||||
| `QUESTIONNAIRE_REMINDER_INTERVAL_DAYS` | `2` | Min days between repeat emails |
|
||||
| `QUESTIONNAIRE_REMINDER_MAX_COUNT` | `5` | Max auto reminders per application |
|
||||
| `DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE` | off | Sweep every 15 min (dev) |
|
||||
|
||||
**Cron:** daily 09:00 (same pattern as LWD admin sweep).
|
||||
**Code:** `questionnaire-reminder.queue.ts` → `QuestionnaireReminderService.processScheduledReminders()`.
|
||||
|
||||
Internal **SLA worker** (`sla.worker.ts`) only processes `sla_tracking` rows for **internal roles** — it does not email prospects.
|
||||
50
docs/sla/PENDING_WORK.md
Normal file
50
docs/sla/PENDING_WORK.md
Normal file
@ -0,0 +1,50 @@
|
||||
# SLA — pending implementation
|
||||
|
||||
Ordered by impact. Update this file when items ship.
|
||||
|
||||
## P0 — High impact
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 1 | F&F runtime hooks | **Done** |
|
||||
| 2 | SLA badges (all modules list + detail) | **Done** |
|
||||
| 3 | My SLA Queue (`mineOnly` + export) | **Done** |
|
||||
| 4 | Deactivate legacy SLA rows via seed | **Done** (run `seed-sla-configs.ts` per env) |
|
||||
|
||||
## P1 — SRS completeness
|
||||
|
||||
| # | Item | SRS ref | Status |
|
||||
|---|------|---------|--------|
|
||||
| 5 | Pause/resume SLA on **On Hold** | §9.4.3 | **Partial** — termination hold wired; resignation/relocation hold not implemented in workflow |
|
||||
| 6 | Working-day calendar + business hours | §9.4.5 | **Done** |
|
||||
| 7 | Repeat overdue reminder cadence | §9.4.2 | **Done** |
|
||||
| 8 | Geography-aware escalation (zone → RBM/ZBH) | §9.4.3 | **Done** — `slaGeographyResolver.ts` + `SLAService` notify/escalation |
|
||||
| 9 | Resignation clearance substages (optional per-dept TAT) | 02_Dealer_Resignation | Not started (F&F dept SLAs done separately) |
|
||||
|
||||
## P2 — Reporting & ops
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 10 | Breach rate / avg resolution / top delayed analytics | **Done** — 30-day panel on Operations monitor |
|
||||
| 11 | Aging buckets | **Done** |
|
||||
| 12 | Export compliance CSV | **Done** |
|
||||
| 13 | E2E automated tests | **Done** — `src/__tests__/sla-lifecycle.test.ts` |
|
||||
|
||||
## P3 — Optional
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 14 | Per statutory sub-status SLA | Not started |
|
||||
| 15 | Admin UI for questionnaire reminder cadence | **Done** — Schedulers tab + `PUT /api/sla/settings/questionnaire-reminder` |
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
1. `npx tsx scripts/migrate-sla-tracking-schema.ts` (once per DB if needed)
|
||||
2. `npx tsx scripts/seed-sla-configs.ts`
|
||||
3. `ENABLE_REDIS=true` + restart API
|
||||
4. Operations monitor → analytics cards, **My queue**, **Export CSV**, **Schedulers** → questionnaire settings
|
||||
5. Termination **On Hold** → SLA **Paused**; resume on next transition
|
||||
6. Escalation to RBM/ZBH routes to district/region/zone mapped users (not global first match)
|
||||
7. `npm test -- sla-lifecycle`
|
||||
62
docs/sla/README.md
Normal file
62
docs/sla/README.md
Normal file
@ -0,0 +1,62 @@
|
||||
# SLA implementation tracking
|
||||
|
||||
This folder tracks **SRS §9.4 SLA Configuration & Escalation Management** against the codebase.
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [IMPLEMENTATION_STATUS.md](./IMPLEMENTATION_STATUS.md) | What is built vs SRS (by capability) |
|
||||
| [ONBOARDING_SLA_RULES.md](./ONBOARDING_SLA_RULES.md) | Applicant steps vs internal SLA (Submitted, Questionnaire, etc.) |
|
||||
| [STAGE_CONFIGURATION_MATRIX.md](./STAGE_CONFIGURATION_MATRIX.md) | Every approval stage vs SLA config row |
|
||||
| [PENDING_WORK.md](./PENDING_WORK.md) | Ordered backlog to reach full compliance |
|
||||
|
||||
## Source of truth (code)
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| Activity catalog (all modules) | `backend/src/common/config/slaStageCatalog.ts` |
|
||||
| Seed script | `backend/scripts/seed-sla-configs.ts` |
|
||||
| Admin “Initialize defaults” API | `POST /api/master/sla-configs/initialize` |
|
||||
| Runtime engine | `backend/src/services/SLAService.ts` |
|
||||
| BullMQ worker | `backend/src/common/queues/sla.worker.ts` (`ENABLE_REDIS=true`) |
|
||||
| Stage sync on transition | `backend/src/common/utils/slaWorkflowSync.ts` |
|
||||
|
||||
## Apply / refresh configuration
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx scripts/seed-sla-configs.ts
|
||||
```
|
||||
|
||||
Or use **Master → SLA Configuration → Initialize defaults** in the UI.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `ENABLE_REDIS=true` | Runs SLA breach checker on schedule |
|
||||
| `DEBUG_SLA_FAST_MODE=true` | 1 hour TAT ≈ 1 minute (dev only) |
|
||||
|
||||
## DB note
|
||||
|
||||
If `sla_tracking.metadata` (or `entityType` / `entityId`) is missing on an older database, run:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/migrate-sla-tracking-schema.ts
|
||||
```
|
||||
|
||||
### SLA notification dispatch log (idempotency + audit)
|
||||
|
||||
Table `sla_notification_dispatches` records **one row per threshold per active track** (pre-breach reminder, breach, escalation L1–L3, repeat overdue window). Unique on `(trackingId, thresholdKey)` prevents duplicate emails even if the worker runs every minute.
|
||||
|
||||
```bash
|
||||
npx tsx scripts/migrate-sla-notification-dispatches.ts
|
||||
```
|
||||
|
||||
| `dispatchType` | `thresholdKey` example | Sends |
|
||||
|----------------|------------------------|--------|
|
||||
| `pre_breach_reminder` | `reminder:<uuid>` | Once per T−1d / T−4h config |
|
||||
| `breach` | `breach` | Once when TAT exceeded |
|
||||
| `escalation` | `escalation:L1` | Once per level |
|
||||
| `repeat_overdue` | `repeat_overdue:<bucket>` | Daily (or fast-mode window) |
|
||||
|
||||
`sla_tracking.metadata` is still used for **pause/resume** timers only; legacy `reminder_sent_*` flags are honored until migrated.
|
||||
117
docs/sla/STAGE_CONFIGURATION_MATRIX.md
Normal file
117
docs/sla/STAGE_CONFIGURATION_MATRIX.md
Normal file
@ -0,0 +1,117 @@
|
||||
# SLA stage configuration matrix
|
||||
|
||||
`activityName` in `sla_configurations` **must equal** the workflow stage string when the case enters that step.
|
||||
|
||||
Legend: **Configured** = row in `SLA_STAGE_CATALOG` · **Runtime** = timer starts on transition today
|
||||
|
||||
---
|
||||
|
||||
## Onboarding (14 internal SLA rows — pipeline label)
|
||||
|
||||
See [ONBOARDING_SLA_RULES.md](./ONBOARDING_SLA_RULES.md) for applicant vs internal steps.
|
||||
|
||||
| Activity (config) | Owner role(s) | TAT | Runtime |
|
||||
|-------------------|---------------|-----|---------|
|
||||
| ~~Submitted~~ | — | — | **Excluded** (applicant action done) |
|
||||
| ~~Questionnaire~~ | — | — | **Excluded** — use `QUESTIONNAIRE_REMINDER` to **prospect** |
|
||||
| Shortlist | DD Admin | 3 days | Yes |
|
||||
| 1st Level Interview | RBM, DD-ZM | 2 days | Yes |
|
||||
| 2nd Level Interview | DD Lead, ZBH | 3 days | Yes |
|
||||
| 3rd Level Interview | NBH, DD Head | 5 days | Yes |
|
||||
| FDD | FDD | 10 days | Yes |
|
||||
| LOI Approval | NBH | 5 days | Yes |
|
||||
| Security Deposit | Finance | 7 days | Yes |
|
||||
| LOI Issue | DD Admin | 3 days | Yes |
|
||||
| Dealer Code Generation | DD Admin | 2 days | Yes |
|
||||
| Architecture Work | Architecture Team | 14 days | Yes |
|
||||
| Statutory Work | DD Admin, Legal | 14 days | Yes |
|
||||
| LOA | NBH | 5 days | Yes |
|
||||
| EOR Complete | DD Admin | 7 days | Yes |
|
||||
| Inauguration | ASM | 3 days | Yes |
|
||||
|
||||
**Not separately configured (optional future):** per-statutory sub-status (`Statutory GST`, …), interview scheduling pending states.
|
||||
|
||||
---
|
||||
|
||||
## Termination (12 configured — matches `TERMINATION_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| RBM + DD-ZM Review | RBM, DD-ZM | 3 days | Yes |
|
||||
| ZBH Review | ZBH | 3 days | Yes |
|
||||
| DD Lead Review | DD Lead | 5 days | Yes |
|
||||
| Legal Verification | Legal Admin | 7 days | Yes |
|
||||
| DD Head Review | DD Head | 5 days | Yes |
|
||||
| NBH Evaluation | NBH | 5 days | Yes |
|
||||
| Show Cause Notice (SCN) | Legal Admin, DD Admin | 5 days | Yes |
|
||||
| Evaluation of Dealer SCN Response | DD Lead, ZBH, RBM, DD Head | 5 days | Yes |
|
||||
| NBH Final Approval | NBH | 3 days | Yes |
|
||||
| CCO Approval | CCO | 3 days | Yes |
|
||||
| CEO Final Approval | CEO | 5 days | Yes |
|
||||
| Legal - Termination Letter | Legal Admin | 5 days | Yes |
|
||||
|
||||
**Skipped (terminal / no approval TAT):** Submitted, Terminated, Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Resignation (8 configured — matches `RESIGNATION_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| ASM | ASM | 2 days | Yes |
|
||||
| RBM + DD-ZM Review | RBM, DD-ZM | 3 days | Yes |
|
||||
| ZBH | ZBH | 3 days | Yes |
|
||||
| DD Lead | DD Lead | 5 days | Yes |
|
||||
| NBH | NBH | 5 days | Yes |
|
||||
| Legal | Legal Admin | 7 days | Yes |
|
||||
| DD Admin | DD Admin | 3 days | Yes |
|
||||
| Awaiting F&F | DD Lead, DD Admin | 7 days | Yes |
|
||||
|
||||
**Not configured:** post–F&F department clearance rows (`Spares Clearance`, …) — use F&F module rows if enabled later.
|
||||
|
||||
---
|
||||
|
||||
## Relocation (7 configured — matches `RELOCATION_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| ASM Review | ASM | 2 days | Yes |
|
||||
| RBM Review | RBM | 3 days | Yes |
|
||||
| DD ZM Review | DD-ZM | 3 days | Yes |
|
||||
| ZBH Review | ZBH | 3 days | Yes |
|
||||
| DD Lead Review | DD Lead | 5 days | Yes |
|
||||
| NBH Approval | NBH | 5 days | Yes |
|
||||
| Legal Clearance | Legal Admin | 7 days | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Constitutional change (7 configured — matches `CONSTITUTIONAL_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| ASM Review | ASM | 2 days | Yes |
|
||||
| ZM/RBM Review | RBM, DD-ZM | 3 days | Yes |
|
||||
| ZBH Review | ZBH | 3 days | Yes |
|
||||
| DD Lead Review | DD Lead | 5 days | Yes |
|
||||
| DD Head Review | DD Head | 5 days | Yes |
|
||||
| NBH Approval | NBH | 5 days | Yes |
|
||||
| Legal Review | Legal Admin | 7 days | Yes |
|
||||
|
||||
---
|
||||
|
||||
## F&F settlement (16 configured — **config only**)
|
||||
|
||||
| Activity pattern | Owner | TAT | Runtime |
|
||||
|------------------|-------|-----|---------|
|
||||
| F&F Clearance: {Department} × 16 | Finance / DD Admin | 5 days each | **Not wired** |
|
||||
|
||||
**Pending:** call `syncSlaOnStageTransition({ entityType: 'fnf', … })` when department clearance status → Pending and stop when Cleared.
|
||||
|
||||
---
|
||||
|
||||
## Legacy config rows (safe to deactivate)
|
||||
|
||||
After re-seed, old names no longer match workflows. Deactivate or delete in Master UI if still present:
|
||||
|
||||
- `ASM Review`, `ZM Review`, `Level 1 Interview` (old onboarding names)
|
||||
- `Resignation ASM Review`, `Termination Evaluation`, `Relocation ASM Review`, `Constitution Legal Review`, etc.
|
||||
683
package-lock.json
generated
683
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,12 +10,14 @@
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migrate": "tsx scripts/migrate.ts",
|
||||
"migrate:sla-dispatches": "tsx scripts/migrate-sla-notification-dispatches.ts",
|
||||
"reset:stable": "tsx scripts/reset_db_stable.ts",
|
||||
"seed": "tsx scripts/seed_normalized_data.ts",
|
||||
"seed:roles": "tsx scripts/seed-roles.ts",
|
||||
"seed:permissions": "tsx scripts/seed-permissions.ts",
|
||||
"seed:approval-policies": "tsx scripts/seed-approval-policies.ts",
|
||||
"seed:email-templates": "tsx src/scripts/seed-master-emails.ts",
|
||||
"seed:interview-templates": "tsx src/scripts/seed-interview-templates.ts",
|
||||
"seed:configs": "tsx scripts/seed-system-configs.ts",
|
||||
"seed:document-configs": "tsx scripts/seed-document-configs.ts",
|
||||
"seed:interview-configs": "tsx scripts/seed-interview-configs.ts",
|
||||
@ -41,6 +43,7 @@
|
||||
"author": "Royal Enfield",
|
||||
"license": "PROPRIETARY",
|
||||
"dependencies": {
|
||||
"@google-cloud/secret-manager": "^5.6.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.73.4",
|
||||
"compression": "^1.8.1",
|
||||
|
||||
@ -43,7 +43,7 @@ async function reset() {
|
||||
await db.ApplicationProgress.destroy({
|
||||
where: {
|
||||
applicationId: app.id,
|
||||
stageName: ['LOI Approval', 'Security Details', 'LOI Issue', 'Dealer Code Generation', 'Architecture Team Assigned', 'Statutory GST']
|
||||
stageName: ['LOI Approval', 'Security Deposit', 'LOI Issue', 'Dealer Code Generation', 'Architecture Team Assigned', 'Statutory GST']
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
24
scratch/update_resignation_enum.js
Normal file
24
scratch/update_resignation_enum.js
Normal file
@ -0,0 +1,24 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function updateEnum() {
|
||||
try {
|
||||
console.log('Attempting to update PostgreSQL ENUM: enum_resignations_currentStage...');
|
||||
|
||||
// Note: ALTER TYPE ... ADD VALUE cannot be executed in a transaction block in some Postgres versions.
|
||||
// Sequelize's queryInterface.sequelize.query uses a transaction if not specified otherwise.
|
||||
|
||||
await db.sequelize.query('ALTER TYPE "enum_resignations_currentStage" ADD VALUE IF NOT EXISTS \'RBM + DD-ZM Review\'');
|
||||
|
||||
console.log('SUCCESS: ENUM updated successfully.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('FAILED to update ENUM:', error.message);
|
||||
if (error.message.includes('already exists')) {
|
||||
console.log('INFO: Value already exists, proceeding.');
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
updateEnum();
|
||||
28
scripts/check-sla-dispatches.ts
Normal file
28
scripts/check-sla-dispatches.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function main() {
|
||||
const { sequelize, SLANotificationDispatch, SLATracking } = db;
|
||||
await sequelize.authenticate();
|
||||
const count = await SLANotificationDispatch.count();
|
||||
const activeTracks = await SLATracking.count({ where: { isActive: true, endTime: null } });
|
||||
const recent = await SLANotificationDispatch.findAll({
|
||||
limit: 15,
|
||||
order: [['sentAt', 'DESC']]
|
||||
});
|
||||
console.log('dispatches:', count, 'active tracks:', activeTracks);
|
||||
for (const r of recent) {
|
||||
console.log(
|
||||
r.dispatchType,
|
||||
r.thresholdKey,
|
||||
String(r.trackingId).slice(0, 8),
|
||||
r.sentAt
|
||||
);
|
||||
}
|
||||
await sequelize.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
18
scripts/check-smtp-config.ts
Normal file
18
scripts/check-smtp-config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
import { initializeSmtpConfig } from '../src/services/smtpConfig.service.js';
|
||||
|
||||
async function main() {
|
||||
const cfg = await initializeSmtpConfig();
|
||||
console.log('enabled:', cfg.enabled);
|
||||
console.log('source:', cfg.source);
|
||||
console.log('host:', cfg.host);
|
||||
console.log('port:', cfg.port);
|
||||
console.log('user:', cfg.auth.user || '(empty)');
|
||||
console.log('pass:', cfg.auth.pass ? `*** (${cfg.auth.pass.length})` : '(empty)');
|
||||
console.log('from:', cfg.from);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
40
scripts/create-system-audit-log-table.ts
Normal file
40
scripts/create-system-audit-log-table.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Create System Audit Log Table
|
||||
*
|
||||
* Bootstraps the new `system_audit_logs` table on environments where the
|
||||
* full `migrate.ts` (sequelize.sync({ force: true })) cannot be run because
|
||||
* the database already holds production / shared data.
|
||||
*
|
||||
* Safe to re-run: uses `SystemAuditLog.sync()` (no `force`, no `alter`),
|
||||
* which is a no-op once the table exists.
|
||||
*
|
||||
* Run: npx tsx scripts/create-system-audit-log-table.ts
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function run() {
|
||||
console.log('🔄 Ensuring system_audit_logs table exists...');
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
console.log('📡 Database connection OK');
|
||||
|
||||
await db.SystemAuditLog.sync();
|
||||
|
||||
const [rows] = await db.sequelize.query(
|
||||
`SELECT COUNT(*)::int AS total FROM system_audit_logs`
|
||||
);
|
||||
const total = (rows as any[])[0]?.total ?? 0;
|
||||
|
||||
console.log('✅ system_audit_logs is ready');
|
||||
console.log(` Existing rows: ${total}`);
|
||||
process.exit(0);
|
||||
} catch (err: any) {
|
||||
console.error('❌ Failed to ensure system_audit_logs table:', err.message || err);
|
||||
if (err.stack) console.error(err.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@ -1,30 +0,0 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function fixEnum() {
|
||||
const enumName = 'enum_constitutional_changes_changeType';
|
||||
const newValues = ['Proprietorship', 'Partnership', 'LLP', 'Private Limited'];
|
||||
|
||||
console.log(`--- Patching DB ENUM: ${enumName} ---`);
|
||||
|
||||
for (const val of newValues) {
|
||||
try {
|
||||
// Sequelize does not have a direct method for ADD VALUE to ENUM in all dialects, using raw query
|
||||
// Using check to avoid "already exists" error
|
||||
await db.sequelize.query(`ALTER TYPE "${enumName}" ADD VALUE IF NOT EXISTS '${val}'`);
|
||||
console.log(`✅ Added '${val}' to ${enumName}`);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes('already exists')) {
|
||||
console.log(`ℹ️ '${val}' already exists in ${enumName}`);
|
||||
} else {
|
||||
console.log(`❌ Failed to add '${val}':`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('--- ENUM Patching Complete ---');
|
||||
}
|
||||
|
||||
fixEnum().catch(err => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
}).then(() => process.exit(0));
|
||||
83
scripts/migrate-onboarding-documents-cleanup.ts
Normal file
83
scripts/migrate-onboarding-documents-cleanup.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Migration Script: Clean up onboarding_documents table.
|
||||
*
|
||||
* What it does (idempotent — safe to re-run):
|
||||
* 1. Drops legacy columns `requestId` and `requestType` (and their indexes).
|
||||
* These were generic catch-alls from when a single documents table routed
|
||||
* across modules. Each module now has its own dedicated documents table
|
||||
* (resignation_documents, termination_documents, constitutional_documents,
|
||||
* relocation_documents), so these columns are dead weight on
|
||||
* onboarding_documents and are not read or written anywhere in code.
|
||||
* 2. Adds two indexes the UI actually queries:
|
||||
* - (applicationId, stage) -> Progress / Documents tab grouping
|
||||
* - documentType -> EOR auto-link in onboarding.controller.ts
|
||||
*
|
||||
* What it does NOT do:
|
||||
* - No new "documentName" column. The user-entered document name is sent as
|
||||
* the FormData filename and stored in the existing `fileName` column.
|
||||
* - Does not touch `dealerId` (the Dealer <-> OnboardingDocument association
|
||||
* references it; it stays for future use).
|
||||
*
|
||||
* Run: npx tsx scripts/migrate-onboarding-documents-cleanup.ts
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
const TABLE = 'onboarding_documents';
|
||||
|
||||
async function migrate() {
|
||||
const queryInterface = db.sequelize.getQueryInterface();
|
||||
|
||||
try {
|
||||
console.log(`🔄 Cleaning up ${TABLE} ...\n`);
|
||||
await db.sequelize.authenticate();
|
||||
|
||||
const tableInfo = await queryInterface.describeTable(TABLE);
|
||||
|
||||
// 1) Drop the index on requestId first (if it exists). Index name depends on
|
||||
// how Sequelize/Postgres generated it — try the common variants.
|
||||
for (const idxName of [
|
||||
`${TABLE}_requestId`,
|
||||
`${TABLE}_request_id`,
|
||||
]) {
|
||||
try {
|
||||
await db.sequelize.query(`DROP INDEX IF EXISTS "${idxName}"`);
|
||||
console.log(`✓ Dropped index ${idxName} (if existed)`);
|
||||
} catch (err: any) {
|
||||
console.log(`- Skipped index ${idxName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Drop the unused columns (idempotent via describeTable check).
|
||||
for (const col of ['requestId', 'requestType']) {
|
||||
if (tableInfo[col]) {
|
||||
console.log(`Dropping column ${col} ...`);
|
||||
await queryInterface.removeColumn(TABLE, col);
|
||||
console.log(`✓ Dropped column ${col}`);
|
||||
} else {
|
||||
console.log(`- Column ${col} not present (already cleaned)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Add useful indexes (idempotent — Postgres IF NOT EXISTS).
|
||||
await db.sequelize.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${TABLE}_applicationId_stage" ON ${TABLE} ("applicationId", "stage")`
|
||||
);
|
||||
console.log(`✓ Ensured index ${TABLE}_applicationId_stage`);
|
||||
|
||||
await db.sequelize.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${TABLE}_documentType" ON ${TABLE} ("documentType")`
|
||||
);
|
||||
console.log(`✓ Ensured index ${TABLE}_documentType`);
|
||||
|
||||
console.log('\n✅ Migration completed successfully!');
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ Migration failed:', error.message);
|
||||
if (error.stack) console.error('\nStack Trace:\n', error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
49
scripts/migrate-sla-notification-dispatches.ts
Normal file
49
scripts/migrate-sla-notification-dispatches.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
/**
|
||||
* Creates sla_notification_dispatches — idempotent audit log for SLA emails/alerts.
|
||||
* Safe to run multiple times.
|
||||
*/
|
||||
async function migrate() {
|
||||
const { sequelize } = db as { sequelize: { authenticate: () => Promise<void>; query: (sql: string) => Promise<unknown>; close: () => Promise<void> } };
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
|
||||
const statements = [
|
||||
`CREATE TABLE IF NOT EXISTS sla_notification_dispatches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"trackingId" UUID NOT NULL REFERENCES sla_tracking(id) ON DELETE CASCADE,
|
||||
"thresholdKey" VARCHAR(128) NOT NULL,
|
||||
"dispatchType" VARCHAR(32) NOT NULL,
|
||||
"templateCode" VARCHAR(64),
|
||||
"stageName" VARCHAR(255),
|
||||
"reminderId" UUID,
|
||||
"escalationLevel" INTEGER,
|
||||
"recipientCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"sentAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'sent',
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS sla_notification_dispatches_tracking_threshold_uq
|
||||
ON sla_notification_dispatches ("trackingId", "thresholdKey")`,
|
||||
`CREATE INDEX IF NOT EXISTS sla_notification_dispatches_tracking_sent_idx
|
||||
ON sla_notification_dispatches ("trackingId", "sentAt")`,
|
||||
`CREATE INDEX IF NOT EXISTS sla_notification_dispatches_type_idx
|
||||
ON sla_notification_dispatches ("dispatchType")`
|
||||
];
|
||||
|
||||
for (const sql of statements) {
|
||||
console.log('Running:', sql.split('\n')[0].slice(0, 72) + '...');
|
||||
await sequelize.query(sql);
|
||||
}
|
||||
|
||||
console.log('sla_notification_dispatches migration complete.');
|
||||
await sequelize.close();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
34
scripts/migrate-sla-tracking-schema.ts
Normal file
34
scripts/migrate-sla-tracking-schema.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
/**
|
||||
* Aligns sla_tracking with SLATracking model (entity columns + metadata for reminder state).
|
||||
* Safe to run multiple times (IF NOT EXISTS).
|
||||
*/
|
||||
async function migrate() {
|
||||
const { sequelize } = db as any;
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
|
||||
const statements = [
|
||||
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS "entityType" VARCHAR(255)`,
|
||||
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS "entityId" UUID`,
|
||||
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb`,
|
||||
// Backfill entity columns for legacy rows that only had applicationId
|
||||
`UPDATE sla_tracking SET "entityType" = 'application' WHERE "entityType" IS NULL AND "applicationId" IS NOT NULL`,
|
||||
`UPDATE sla_tracking SET "entityId" = "applicationId" WHERE "entityId" IS NULL AND "applicationId" IS NOT NULL`
|
||||
];
|
||||
|
||||
for (const sql of statements) {
|
||||
console.log('Running:', sql.slice(0, 80) + '...');
|
||||
await sequelize.query(sql);
|
||||
}
|
||||
|
||||
console.log('sla_tracking schema migration complete.');
|
||||
await sequelize.close();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,8 +1,12 @@
|
||||
/**
|
||||
* Database Migration Script
|
||||
* Synchronizes all Sequelize models with the database
|
||||
* Synchronizes all Sequelize models with the database (PostgreSQL).
|
||||
* This script will DROP all existing tables and recreate them.
|
||||
*
|
||||
*
|
||||
* Schema for modules such as constitutional change (ENUM values, partial unique indexes,
|
||||
* columns) is defined only on Sequelize models — no separate "table alteration" scripts are
|
||||
* required after a fresh `migrate` + `seed:all` (see package.json `setup:fresh`).
|
||||
*
|
||||
* Run: npx tsx scripts/migrate.ts
|
||||
*/
|
||||
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function migrate() {
|
||||
const queryInterface = db.sequelize.getQueryInterface();
|
||||
|
||||
// Using describeTable to check existence
|
||||
const tableDefinition = await queryInterface.describeTable('constitutional_changes');
|
||||
|
||||
console.log('--- Migrating constitutional_changes table ---');
|
||||
|
||||
if (!tableDefinition.currentConstitution) {
|
||||
console.log('Adding currentConstitution column...');
|
||||
await queryInterface.addColumn('constitutional_changes', 'currentConstitution', {
|
||||
type: db.Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDefinition.metadata) {
|
||||
console.log('Adding metadata column...');
|
||||
await queryInterface.addColumn('constitutional_changes', 'metadata', {
|
||||
type: db.Sequelize.DataTypes.JSON,
|
||||
defaultValue: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Update outletId to be nullable
|
||||
console.log('Updating outletId to be nullable...');
|
||||
await queryInterface.changeColumn('constitutional_changes', 'outletId', {
|
||||
type: db.Sequelize.DataTypes.UUID,
|
||||
allowNull: true
|
||||
});
|
||||
|
||||
console.log('✅ Migration complete!');
|
||||
}
|
||||
|
||||
migrate();
|
||||
@ -45,6 +45,24 @@ const configs = [
|
||||
{ documentType: 'First Fill Receipt', stageCode: 'LOA Approval', allowedRoles: [ROLES.DEALER, ROLES.FINANCE, ROLES.DD_HEAD, ROLES.NBH, ROLES.SUPER_ADMIN], isMandatory: true },
|
||||
{ documentType: 'LOI Acknowledgement Copy', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES },
|
||||
{ documentType: 'Nodal Agreement', stageCode: 'LOI Approval', allowedRoles: [ROLES.LEGAL_ADMIN, ROLES.DEALER, ROLES.SUPER_ADMIN] },
|
||||
{ documentType: 'DIP Booklet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Profile Sheet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Dealership Application Form', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Interview Feedback Forms', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Land Selection Criteria Sheet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Logic Note and Comparative Logic Note', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Zonal Evaluation Form', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Authorization Letter', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'City Map (PPT)', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Proposed Location Photos (minimum 20, PPT)', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Layout Drawings (PPT)', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Viability Sheet', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Project Plan', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Self-signed PAN/Aadhaar of all partners', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'CIBIL Reports of all partners', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Dealership Name & Address Email from RBM', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Rental / Lease Agreement or Consent Letter from Landlord', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
{ documentType: 'Security Deposit Proof', stageCode: 'LOI Issue', allowedRoles: ALL_ROLES, isMandatory: true, module: 'ONBOARDING' },
|
||||
|
||||
// Architecture Team Documents
|
||||
{ documentType: 'Architecture Assignment Document', stageCode: 'Architecture Team Assigned', allowedRoles: [ROLES.ARCHITECTURE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN] },
|
||||
|
||||
@ -1,120 +1,30 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
type SlaDefault = {
|
||||
stage: string;
|
||||
role: string;
|
||||
tat: number;
|
||||
unit: 'hours' | 'days';
|
||||
};
|
||||
|
||||
const defaults: SlaDefault[] = [
|
||||
// ONBOARDING
|
||||
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
|
||||
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
|
||||
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
|
||||
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
|
||||
// RESIGNATION
|
||||
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
|
||||
|
||||
// TERMINATION
|
||||
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
|
||||
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
|
||||
|
||||
// RELOCATION
|
||||
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
|
||||
// CONSTITUTIONAL CHANGE
|
||||
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
|
||||
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
];
|
||||
import { SLA_STAGE_CATALOG } from '../src/common/config/slaStageCatalog.js';
|
||||
import { seedSlaCatalogEntries } from '../src/common/utils/slaSeedUtils.js';
|
||||
|
||||
async function seedSlaConfigs() {
|
||||
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any;
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
const { sequelize } = db as any;
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
for (const item of defaults) {
|
||||
const [config, created] = await SLAConfiguration.findOrCreate({
|
||||
where: { activityName: item.stage },
|
||||
defaults: {
|
||||
activityName: item.stage,
|
||||
ownerRole: item.role,
|
||||
tatHours: item.tat,
|
||||
tatUnit: item.unit,
|
||||
isActive: true,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await config.update(
|
||||
{
|
||||
ownerRole: item.role,
|
||||
tatHours: item.tat,
|
||||
tatUnit: item.unit,
|
||||
},
|
||||
{ transaction }
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
|
||||
await transaction.commit();
|
||||
console.log(
|
||||
`SLA configurations seeded successfully. Total activities: ${SLA_STAGE_CATALOG.length}`
|
||||
);
|
||||
}
|
||||
|
||||
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
|
||||
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
|
||||
|
||||
await SLAReminder.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true },
|
||||
{ slaConfigId: config.id, timeValue: 4, timeUnit: 'hours', isEnabled: true },
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await SLAEscalationConfig.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: config.id, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
|
||||
{ slaConfigId: config.id, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
|
||||
{ slaConfigId: config.id, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' },
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('SLA seed failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
console.log(`SLA configurations seeded successfully. Total stages: ${defaults.length}`);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('SLA seed failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
seedSlaConfigs().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
18
scripts/sql/application_security_deposit_status_rename.sql
Normal file
18
scripts/sql/application_security_deposit_status_rename.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- Rename overallStatus value Security Details -> Security Deposit (canonical label from APPLICATION_STATUS.SECURITY_DETAILS).
|
||||
-- Run once per environment AFTER deploying code that uses 'Security Deposit'.
|
||||
--
|
||||
-- 1) Discover enum type name for applications.overallStatus:
|
||||
-- SELECT c.column_name, c.udt_name
|
||||
-- FROM information_schema.columns c
|
||||
-- WHERE c.table_schema = 'public' AND c.table_name = 'applications' AND c.column_name = 'overallStatus';
|
||||
--
|
||||
-- 2) Add new enum value (PostgreSQL 9.1+). Replace type name if yours differs.
|
||||
-- ALTER TYPE "enum_applications_overallStatus" ADD VALUE IF NOT EXISTS 'Security Deposit';
|
||||
--
|
||||
-- 3) Backfill rows (camelCase column is typical for this Sequelize project).
|
||||
-- UPDATE applications SET "overallStatus" = 'Security Deposit' WHERE "overallStatus" = 'Security Details';
|
||||
--
|
||||
-- 4) Backfill ApplicationProgress pipeline stage label (see ONBOARDING_STAGES in progress.ts).
|
||||
-- UPDATE application_progress SET "stageName" = 'Security Deposit' WHERE "stageName" = 'Security Details';
|
||||
|
||||
-- Minimal safe block: only step 3+4 if you use VARCHAR-like enums; for native ENUM you must run step 2 first (cannot be in same transaction as use of new value on older PG — run ADD VALUE, COMMIT, then UPDATE).
|
||||
@ -1,5 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getResignationStatusForStage, getTerminationStatusForStage, normalizeClearanceStatus, normalizeFnFStatus } from '../src/common/utils/offboardingStatus.js';
|
||||
import {
|
||||
getResignationStatusForStage,
|
||||
getTerminationStatusForStage,
|
||||
normalizeClearanceStatus,
|
||||
normalizeFnFStatus,
|
||||
normalizeTerminationCurrentStage,
|
||||
getLegacyTerminationRowFixes
|
||||
} from '../src/common/utils/offboardingStatus.js';
|
||||
import { getJointRoundCutoffMsFromTimeline } from '../src/common/utils/terminationJointReviewRound.util.js';
|
||||
|
||||
assert.equal(normalizeFnFStatus('settled'), 'Completed');
|
||||
assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval');
|
||||
@ -10,6 +18,28 @@ assert.equal(getResignationStatusForStage('F&F Initiated'), 'F&F Initiated');
|
||||
assert.equal(getTerminationStatusForStage('Submitted'), 'Submitted');
|
||||
assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated');
|
||||
|
||||
assert.equal(
|
||||
normalizeTerminationCurrentStage('Personal Hearing'),
|
||||
'Evaluation of Dealer SCN Response'
|
||||
);
|
||||
assert.deepEqual(getLegacyTerminationRowFixes({ currentStage: 'Personal Hearing', status: 'Personal Hearing Pending' }), {
|
||||
currentStage: 'Evaluation of Dealer SCN Response',
|
||||
status: 'SCN Response Evaluation Pending'
|
||||
});
|
||||
|
||||
const reconsiderTimeline = [
|
||||
{ action: 'Approved', targetStage: 'NBH Final Approval', timestamp: new Date('2024-01-01').toISOString() },
|
||||
{
|
||||
action: 'Sent for Reconsideration',
|
||||
targetStage: 'Evaluation of Dealer SCN Response',
|
||||
timestamp: new Date('2025-06-15T12:00:00.000Z').toISOString()
|
||||
}
|
||||
];
|
||||
assert.equal(
|
||||
getJointRoundCutoffMsFromTimeline(reconsiderTimeline, 'scn_response_eval'),
|
||||
new Date('2025-06-15T12:00:00.000Z').getTime()
|
||||
);
|
||||
|
||||
assert.equal(normalizeClearanceStatus('Cleared', 0), 'NOC Submitted');
|
||||
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
|
||||
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');
|
||||
|
||||
@ -35,6 +35,8 @@ console.log('✓ Termination stage resolution passed.');
|
||||
console.log('Testing getPreviousStage (Resignation)...');
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.RBM), RESIGNATION_STAGES.ASM);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.ZBH), RESIGNATION_STAGES.RBM);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.FNF_INITIATED), RESIGNATION_STAGES.AWAITING_FNF);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.DD_ADMIN), RESIGNATION_STAGES.LEGAL);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.COMPLETED), RESIGNATION_STAGES.FNF_INITIATED);
|
||||
console.log('✓ Resignation stage resolution passed.');
|
||||
|
||||
|
||||
35
src/__tests__/constitutional-alignment.test.ts
Normal file
35
src/__tests__/constitutional-alignment.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { normalizeToConstitutionalChangeType, mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||
import { ConstitutionalWorkflowService } from '../services/ConstitutionalWorkflowService.js';
|
||||
|
||||
describe('Constitutional alignment', () => {
|
||||
it('rejects legacy non-structure change types after scope tightening', () => {
|
||||
expect(normalizeToConstitutionalChangeType('Director Change')).toBeNull();
|
||||
expect(normalizeToConstitutionalChangeType('Ownership Transfer')).toBeNull();
|
||||
expect(normalizeToConstitutionalChangeType('Company Formation')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps supported structure change types to dealer profile', () => {
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('Proprietorship')).toBe('Proprietorship');
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('Partnership')).toBe('Partnership');
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('LLP')).toBe('LLP');
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('Private Limited')).toBe('Private Limited');
|
||||
});
|
||||
|
||||
it('computes missing mandatory documents from uploaded checklist payload', () => {
|
||||
const completeDocs = [
|
||||
{ documentType: 'GST Certificate' },
|
||||
{ documentType: 'PAN Card' },
|
||||
{ documentType: 'Aadhaar' },
|
||||
{ documentType: 'Certificate of Incorporation' },
|
||||
{ documentType: 'Business Purchase Agreement (BPA)' },
|
||||
{ documentType: 'LLP Agreement' },
|
||||
{ documentType: 'Cancelled Check' },
|
||||
{ documentType: 'Declaration / Authorization Letter' }
|
||||
];
|
||||
expect(ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', completeDocs)).toEqual([]);
|
||||
|
||||
const missingOne = completeDocs.filter((d) => d.documentType !== 'Business Purchase Agreement (BPA)');
|
||||
const missing = ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', missingOne);
|
||||
expect(missing).toContain('Business Purchase Agreement (BPA)');
|
||||
});
|
||||
});
|
||||
271
src/__tests__/external-integrations.test.ts
Normal file
271
src/__tests__/external-integrations.test.ts
Normal file
@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file external-integrations.test.ts
|
||||
* @description Contract/mock tests for all external integrations.
|
||||
* These tests validate the SHAPE and BEHAVIOUR of each mock so that
|
||||
* when real APIs are wired, only the mock needs to be swapped.
|
||||
*
|
||||
* Integrations covered:
|
||||
* 1. SAP OData — Dealer Code generation (mockGenerateSapCodes, mockSyncDealerStatusToSap)
|
||||
* 2. Google Calendar — Interview invite scheduling (mockScheduleMeeting)
|
||||
* 3. WhatsApp — Notification delivery (mockSendWhatsApp)
|
||||
* 4. Gemini AI — Panel evaluation summary (mockGenerateAiSummary)
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.17.3.1 — SAP OData API for Sales/Service/GMA/Gear codes
|
||||
* §6.9.2 — Google Calendar invites for all participants
|
||||
* §1.1.1 — WhatsApp as supported notification channel
|
||||
* §6.10.4 — AI-assisted recommendation via Gemini API
|
||||
*/
|
||||
|
||||
import { ExternalMocksService } from '../common/utils/externalMocks.service.js';
|
||||
|
||||
// ─── 1. SAP Dealer Code Generation ───────────────────────────────────────────
|
||||
|
||||
describe('SAP Mock — Dealer Code Generation (SRS §6.17.3.1)', () => {
|
||||
it('TC-SAP-01: returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-SAP-02: returns salesCode in SLS-XXXX format', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.salesCode).toMatch(/^SLS-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-03: returns serviceCode in SRV-XXXX format', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.serviceCode).toMatch(/^SRV-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-04: returns gmaCode in GMA-XXXX format (Genuine Motorcycle Accessories)', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.gmaCode).toMatch(/^GMA-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-05: returns gearCode in GER-XXXX format', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.gearCode).toMatch(/^GER-\d{4}$/);
|
||||
});
|
||||
|
||||
it('TC-SAP-06: returns a non-empty sapMasterId', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data.sapMasterId).toBeTruthy();
|
||||
expect(typeof data.sapMasterId).toBe('string');
|
||||
});
|
||||
|
||||
it('TC-SAP-07: each call generates unique codes (no collisions)', async () => {
|
||||
const r1 = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
const r2 = await ExternalMocksService.mockGenerateSapCodes('app-002');
|
||||
// Codes are random; while collision is statistically possible, sapMasterIds must differ
|
||||
expect(r1.data.sapMasterId).not.toBe(r2.data.sapMasterId);
|
||||
});
|
||||
|
||||
it('TC-SAP-08: returns all 4 code types in a single call (no missing fields)', async () => {
|
||||
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||
expect(data).toHaveProperty('salesCode');
|
||||
expect(data).toHaveProperty('serviceCode');
|
||||
expect(data).toHaveProperty('gmaCode');
|
||||
expect(data).toHaveProperty('gearCode');
|
||||
expect(data).toHaveProperty('sapMasterId');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SAP Status Sync ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('SAP Mock — Dealer Status Synchronization', () => {
|
||||
it('TC-SAP-09: mockSyncDealerStatusToSap returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockSyncDealerStatusToSap('SLS-1234', 'Active');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-SAP-10: sync result includes a sapTransactionId and ISO timestamp', async () => {
|
||||
const result = await ExternalMocksService.mockSyncDealerStatusToSap('SLS-1234', 'Terminated');
|
||||
expect(result.sapTransactionId).toMatch(/^SAP-TX-/);
|
||||
expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── SAP Financial Dues ───────────────────────────────────────────────────────
|
||||
|
||||
describe('SAP Mock — Financial Dues (F&F Context)', () => {
|
||||
it('TC-SAP-11: mockGetFinancialDuesFromSap returns financial data for a dealer', async () => {
|
||||
const result = await ExternalMocksService.mockGetFinancialDuesFromSap('SLS-5678');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toHaveProperty('outstandingInvoices');
|
||||
expect(result.data).toHaveProperty('securityDeposit');
|
||||
expect(result.data).toHaveProperty('creditLimit');
|
||||
expect(result.data).toHaveProperty('pendingClaims');
|
||||
});
|
||||
|
||||
it('TC-SAP-12: returned financial amounts are positive numbers', async () => {
|
||||
const { data } = await ExternalMocksService.mockGetFinancialDuesFromSap('SLS-5678');
|
||||
expect(data.securityDeposit).toBeGreaterThan(0);
|
||||
expect(data.creditLimit).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. Google Calendar Mock ──────────────────────────────────────────────────
|
||||
|
||||
describe('Google Calendar Mock — Interview Scheduling (SRS §6.9.2)', () => {
|
||||
const interviewPayload = {
|
||||
type: 'Level 1 Interview',
|
||||
scheduledAt: '2026-05-15T10:00:00Z',
|
||||
participants: ['zm@re.com', 'rbm@re.com', 'applicant@gmail.com'],
|
||||
mode: 'Virtual',
|
||||
applicationId: 'app-uuid-001',
|
||||
};
|
||||
|
||||
it('TC-CAL-01: mockScheduleMeeting returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-CAL-02: returns a Google Meet link URL', async () => {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
expect(result.meetLink).toContain('meet.google.com');
|
||||
});
|
||||
|
||||
it('TC-CAL-03: returns a non-empty calendarEventId (UUID format)', async () => {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
expect(result.calendarEventId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-CAL-04: each scheduling call returns a unique meetLink (no duplicate links)', async () => {
|
||||
const r1 = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||
const r2 = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, type: 'Level 2 Interview' });
|
||||
expect(r1.meetLink).not.toBe(r2.meetLink);
|
||||
});
|
||||
|
||||
it('TC-CAL-05: mock works for Level 1, Level 2, and Level 3 interview types', async () => {
|
||||
for (const type of ['Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview']) {
|
||||
const result = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, type });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('TC-CAL-06: mock works for both Virtual and Physical interview modes', async () => {
|
||||
const virtualResult = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, mode: 'Virtual' });
|
||||
const physicalResult = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, mode: 'Physical' });
|
||||
expect(virtualResult.success).toBe(true);
|
||||
expect(physicalResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. WhatsApp Mock ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('WhatsApp Mock — Notification Delivery (SRS §1.1.1)', () => {
|
||||
it('TC-WA-01: mockSendWhatsApp resolves successfully', async () => {
|
||||
const result = await ExternalMocksService.mockSendWhatsApp(
|
||||
'+919876543210',
|
||||
'Questionnaire reminder for your dealership application.'
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-WA-02: returned messageId starts with WA- prefix', async () => {
|
||||
const result = await ExternalMocksService.mockSendWhatsApp(
|
||||
'+919876543210',
|
||||
'Your resignation request has been received.'
|
||||
);
|
||||
expect(result.messageId).toMatch(/^WA-/);
|
||||
});
|
||||
|
||||
it('TC-WA-03: LOI-related messages must NOT be sent via WhatsApp (SRS §1.1.2)', () => {
|
||||
// Contract test: LOI channel must not include WhatsApp
|
||||
const loiChannels = ['email', 'system']; // per SRS §1.1.2
|
||||
expect(loiChannels).not.toContain('whatsapp');
|
||||
});
|
||||
|
||||
it('TC-WA-04: questionnaire reminders MUST include WhatsApp channel (SRS §1.1.1)', () => {
|
||||
const questionnaireChannels = ['email', 'whatsapp']; // per SRS §1.1.1
|
||||
expect(questionnaireChannels).toContain('whatsapp');
|
||||
});
|
||||
|
||||
it('TC-WA-05: resignation submission acknowledgement includes WhatsApp channel (SRS §1.1.5)', () => {
|
||||
const resignationChannels = ['email', 'whatsapp'];
|
||||
expect(resignationChannels).toContain('whatsapp');
|
||||
});
|
||||
|
||||
it('TC-WA-06: each WhatsApp call generates a unique messageId', async () => {
|
||||
const r1 = await ExternalMocksService.mockSendWhatsApp('+91111', 'Msg 1');
|
||||
const r2 = await ExternalMocksService.mockSendWhatsApp('+91222', 'Msg 2');
|
||||
expect(r1.messageId).not.toBe(r2.messageId);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Gemini AI Mock ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Gemini AI Mock — Panel Evaluation Summary (SRS §6.10.4)', () => {
|
||||
const allApprovedFeedback = [
|
||||
{ recommendation: 'Approve', score: 85 },
|
||||
{ recommendation: 'Approve', score: 90 },
|
||||
{ recommendation: 'Approve', score: 88 },
|
||||
];
|
||||
|
||||
const mixedFeedback = [
|
||||
{ recommendation: 'Approve', score: 80 },
|
||||
{ recommendation: 'Approve', score: 75 },
|
||||
{ recommendation: 'Reject', score: 40 },
|
||||
];
|
||||
|
||||
const allRejectFeedback = [
|
||||
{ recommendation: 'Reject', score: 30 },
|
||||
{ recommendation: 'Reject', score: 25 },
|
||||
];
|
||||
|
||||
it('TC-AI-01: mockGenerateAiSummary returns success=true', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-AI-02: returns a non-empty summary string', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
expect(typeof result.summary).toBe('string');
|
||||
expect(result.summary.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('TC-AI-03: unanimous approval panel produces positive consensus summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
// Unanimous approval → strong consensus message
|
||||
expect(result.summary).toMatch(/strong consensus|strong candidate|exceptional/i);
|
||||
});
|
||||
|
||||
it('TC-AI-04: majority approval produces cautiously positive summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', mixedFeedback);
|
||||
// Majority but not all → cautious message
|
||||
expect(result.summary).toMatch(/majority|recommend approval|monitored/i);
|
||||
});
|
||||
|
||||
it('TC-AI-05: rejection-leaning panel produces concern-focused summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allRejectFeedback);
|
||||
expect(result.summary).toMatch(/divided|rejection|concern/i);
|
||||
});
|
||||
|
||||
it('TC-AI-06: empty feedback list produces a valid (non-crashing) summary', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', []);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summary).toBeDefined();
|
||||
});
|
||||
|
||||
it('TC-AI-07: summary is presentable to NBH — 2 to 3 sentences', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||
// SRS §6.10.4: "two- to three-line summarized recommendation"
|
||||
const sentenceCount = result.summary.split(/[.!?]/).filter(Boolean).length;
|
||||
expect(sentenceCount).toBeGreaterThanOrEqual(1);
|
||||
expect(sentenceCount).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('TC-AI-08: mixed feedback with exactly half approvals falls into majority branch', async () => {
|
||||
const halfHalf = [
|
||||
{ recommendation: 'Approve', score: 75 },
|
||||
{ recommendation: 'Reject', score: 40 },
|
||||
];
|
||||
// 1 approve out of 2 = not > total/2, so should fall into rejection branch
|
||||
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', halfHalf);
|
||||
expect(result.success).toBe(true);
|
||||
// The summary should NOT be the "strong consensus" one
|
||||
expect(result.summary).not.toMatch(/strong consensus|exceptional/i);
|
||||
});
|
||||
});
|
||||
213
src/__tests__/notification-service.test.ts
Normal file
213
src/__tests__/notification-service.test.ts
Normal file
@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file notification-service.test.ts
|
||||
* @description Unit tests for NotificationService — verifies that system, email,
|
||||
* and WhatsApp channels are dispatched correctly for each scenario.
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.14.3 — Delivery Channels: in-system, email, WhatsApp
|
||||
* §1.1.1 — WhatsApp is a supported notification channel (reminders, workflow events)
|
||||
* §1.1.2 — LOI documents shared via email ONLY (not WhatsApp)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { sendEmail } from '../common/utils/email.service.js';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
Notification: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'notif-1', createdAt: new Date() }),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
PushSubscription: {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Disable Redis so async channels fall through to console (no BullMQ needed in tests)
|
||||
process.env.ENABLE_REDIS = 'false';
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
jest.mock('../common/utils/socket.js', () => ({
|
||||
getIO: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const sendEmailMock = sendEmail as jest.Mock;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const basePayload = {
|
||||
title: 'Test Notification',
|
||||
message: 'Test message body',
|
||||
channels: ['email', 'system'] as Array<'email' | 'whatsapp' | 'system' | 'push'>,
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NotificationService — channel dispatch', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ── System channel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('system channel', () => {
|
||||
it('TC-NS-01: creates an in-app Notification record when system channel is included', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
|
||||
await NotificationService.notify('user-123', 'test@re.com', {
|
||||
...basePayload,
|
||||
channels: ['system'],
|
||||
});
|
||||
|
||||
expect(db.Notification.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-123', isRead: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NS-02: skips Notification.create when userId is null (applicant not yet a system user)', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
|
||||
await NotificationService.notify(null, 'applicant@gmail.com', {
|
||||
...basePayload,
|
||||
channels: ['system'],
|
||||
});
|
||||
|
||||
expect(db.Notification.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Email channel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('email channel', () => {
|
||||
it('TC-NS-03: does NOT call sendEmail synchronously (Redis disabled — logs and skips)', async () => {
|
||||
await NotificationService.notify('user-123', 'test@re.com', {
|
||||
...basePayload,
|
||||
channels: ['email'],
|
||||
});
|
||||
// With ENABLE_REDIS=false, the async channel is skipped; no direct sendEmail call
|
||||
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TC-NS-04: processJob triggers sendEmail for email channel', async () => {
|
||||
await NotificationService.processJob({
|
||||
userId: 'user-abc',
|
||||
email: 'reviewer@re.com',
|
||||
title: 'Action Required',
|
||||
message: 'Please review the application.',
|
||||
channels: ['email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { link: 'http://localhost:5173/apps/1' },
|
||||
});
|
||||
|
||||
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||
'reviewer@re.com',
|
||||
'Action Required',
|
||||
'WORKFLOW_ACTION_REQUIRED',
|
||||
expect.objectContaining({ link: 'http://localhost:5173/apps/1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NS-05: processJob does NOT call sendEmail when email is null/undefined', async () => {
|
||||
await NotificationService.processJob({
|
||||
userId: 'user-abc',
|
||||
email: null,
|
||||
title: 'Test',
|
||||
message: 'No email',
|
||||
channels: ['email'],
|
||||
templateCode: 'GENERIC_NOTIFICATION',
|
||||
placeholders: {},
|
||||
});
|
||||
|
||||
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WhatsApp channel ─────────────────────────────────────────────────────
|
||||
|
||||
describe('whatsapp channel', () => {
|
||||
it('TC-NS-06: processJob calls sendWhatsApp with phone from placeholders', async () => {
|
||||
const spy = jest
|
||||
.spyOn(NotificationService, 'sendWhatsApp')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
await NotificationService.processJob({
|
||||
userId: 'user-wa',
|
||||
email: null,
|
||||
title: 'WA Test',
|
||||
message: 'WhatsApp message',
|
||||
channels: ['whatsapp'],
|
||||
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||
placeholders: { phone: '+919876543210', applicantName: 'Ravi' },
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'+919876543210',
|
||||
'QUESTIONNAIRE_REMINDER',
|
||||
expect.objectContaining({ applicantName: 'Ravi' })
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('TC-NS-07: sendWhatsApp resolves without throwing (mock contract)', async () => {
|
||||
await expect(
|
||||
NotificationService.sendWhatsApp('+919876543210', 'RESIGNATION_RECEIVED', {
|
||||
dealerName: 'Kumar Dealers',
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Questionnaire reminder (SRS §1.1.1) ──────────────────────────────────
|
||||
|
||||
describe('sendQuestionnaireReminder', () => {
|
||||
it('TC-NS-08: sends QUESTIONNAIRE_REMINDER via email + whatsapp channels', async () => {
|
||||
const notifySpy = jest
|
||||
.spyOn(NotificationService, 'notify')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+919876543210',
|
||||
'Rahul Sharma',
|
||||
{ location: 'Chennai' }
|
||||
);
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({
|
||||
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||
channels: expect.arrayContaining(['email', 'whatsapp']),
|
||||
placeholders: expect.objectContaining({
|
||||
applicantName: 'Rahul Sharma',
|
||||
location: 'Chennai',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
notifySpy.mockRestore();
|
||||
});
|
||||
|
||||
it('TC-NS-09: questionnaire reminder includes a CTA link to the applicant portal', async () => {
|
||||
const notifySpy = jest
|
||||
.spyOn(NotificationService, 'notify')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+91000',
|
||||
'Test User'
|
||||
);
|
||||
|
||||
const call = notifySpy.mock.calls[0][2];
|
||||
expect(call.placeholders?.link).toContain('localhost:5173');
|
||||
notifySpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
345
src/__tests__/onboarding-stage-notifications.test.ts
Normal file
345
src/__tests__/onboarding-stage-notifications.test.ts
Normal file
@ -0,0 +1,345 @@
|
||||
/**
|
||||
* @file onboarding-stage-notifications.test.ts
|
||||
* @description Integration-level tests verifying email/notification triggers at
|
||||
* EVERY stage of the Dealer Onboarding pipeline.
|
||||
*
|
||||
* Stages covered (SRS §4.1.1 + §6.x):
|
||||
* 1. Application Submitted → Opportunity/Non-Opportunity Email
|
||||
* 2. Questionnaire Link Sent → Email + WhatsApp reminder
|
||||
* 3. Questionnaire Completed → Admin notified (system)
|
||||
* 4. Shortlisted → DD-ZM + RBM notified (email + WhatsApp)
|
||||
* 5. Level 1 Interview Scheduled → DD-ZM + RBM + Applicant (Calendar mock)
|
||||
* 6. Level 1 Approved → DD-Lead + ZBH notified (email + WhatsApp)
|
||||
* 7. Level 2 Interview Scheduled → DD-Lead + ZBH + Applicant
|
||||
* 8. Level 2 Approved → NBH + DD-Head notified (email + WhatsApp)
|
||||
* 9. Level 3 Interview Scheduled → NBH + DD-Head + Applicant
|
||||
* 10. Level 3 Approved → FDD team notified (email + system)
|
||||
* 11. FDD Submitted → Finance notified (email + system)
|
||||
* 12. Finance Approved (LOI Stage) → DD-Head + NBH notified (email + WhatsApp)
|
||||
* 13. LOI Issued → Applicant via EMAIL only — NOT WhatsApp (SRS §1.1.2)
|
||||
* 14. Dealer Code Generated → Finance + Legal + DD-Admin notified (system)
|
||||
* 15. LOA Issued → Applicant + DD-Head + NBH (email)
|
||||
* 16. EOR Completed → DD-Head + NBH (system alert)
|
||||
* 17. Inauguration Logged → Applicant marked Live (system)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { ExternalMocksService } from '../common/utils/externalMocks.service.js';
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
const mockSendWhatsApp = jest.spyOn(NotificationService, 'sendWhatsApp').mockResolvedValue(true);
|
||||
const mockScheduleMeeting = jest.spyOn(ExternalMocksService, 'mockScheduleMeeting');
|
||||
const mockQReminder = jest.spyOn(NotificationService, 'sendQuestionnaireReminder').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type NotifyCall = Parameters<typeof NotificationService.notify>;
|
||||
|
||||
const findCallByTemplate = (code: string): NotifyCall | undefined =>
|
||||
mockNotify.mock.calls.find((c: any[]) => c[2]?.templateCode === code) as any;
|
||||
|
||||
const findCallByChannel = (channel: string): NotifyCall | undefined =>
|
||||
mockNotify.mock.calls.find((c: any[]) => c[2]?.channels?.includes(channel)) as any;
|
||||
|
||||
// ─── Stage 1-2: Application + Questionnaire ──────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 1-2: Application Submission & Questionnaire', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-01: Questionnaire reminder is sent via email + WhatsApp (SRS §1.1.1)', async () => {
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+919876543210',
|
||||
'Amit Sharma',
|
||||
{ location: 'Bangalore' }
|
||||
);
|
||||
|
||||
expect(mockQReminder).toHaveBeenCalledWith(
|
||||
'applicant@gmail.com',
|
||||
'+919876543210',
|
||||
'Amit Sharma',
|
||||
expect.objectContaining({ location: 'Bangalore' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-ONB-02: Questionnaire reminder uses QUESTIONNAIRE_REMINDER template code', async () => {
|
||||
// Restore real implementation to verify template code
|
||||
mockQReminder.mockRestore();
|
||||
const realNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
'applicant@gmail.com',
|
||||
'+91999',
|
||||
'Test User'
|
||||
);
|
||||
|
||||
expect(realNotify).toHaveBeenCalledWith(
|
||||
null,
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'QUESTIONNAIRE_REMINDER' })
|
||||
);
|
||||
|
||||
realNotify.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 4: Shortlisting ────────────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 4: Shortlisting Notification', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-03: DD-ZM receives email + WhatsApp + system after shortlisting (SRS §6.6.3)', async () => {
|
||||
await NotificationService.notify('zm-user-1', 'zm@re.com', {
|
||||
title: 'New Application Assigned — APP-2026-001',
|
||||
message: 'Rahul Verma shortlisted for Bangalore. Please evaluate.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91999', link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'zm-user-1',
|
||||
'zm@re.com',
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-ONB-04: RBM receives same channels as DD-ZM on shortlisting', async () => {
|
||||
await NotificationService.notify('rbm-user-1', 'rbm@re.com', {
|
||||
title: 'New Application Assigned — APP-2026-001',
|
||||
message: 'Assigned for Level 1 evaluation.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91888', link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('rbm-user-1', 'rbm@re.com', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 5: Level 1 Interview Scheduling ───────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 5: Level 1 Interview — Google Calendar Mock', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-05: Google Calendar mock returns a meet link and calendar event ID', async () => {
|
||||
mockScheduleMeeting.mockResolvedValueOnce({
|
||||
success: true,
|
||||
meetLink: 'https://meet.google.com/mock-abcd1234',
|
||||
calendarEventId: 'cal-event-001',
|
||||
});
|
||||
|
||||
const result = await ExternalMocksService.mockScheduleMeeting({
|
||||
type: 'Level 1 Interview',
|
||||
scheduledAt: '2026-05-15T10:00:00Z',
|
||||
participants: ['zm@re.com', 'rbm@re.com', 'applicant@gmail.com'],
|
||||
mode: 'Virtual',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.meetLink).toContain('meet.google.com');
|
||||
expect(result.calendarEventId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('TC-ONB-06: After scheduling, DD-ZM and RBM are notified via email + system', async () => {
|
||||
for (const [userId, email] of [['zm-1', 'zm@re.com'], ['rbm-1', 'rbm@re.com']]) {
|
||||
await NotificationService.notify(userId, email, {
|
||||
title: 'Interview Scheduled: APP-2026-001 — Level 1',
|
||||
message: 'Level 1 Interview scheduled for 15-May-2026.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'INTERVIEW_SCHEDULED',
|
||||
placeholders: { link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
}
|
||||
|
||||
const calls = mockNotify.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
calls.forEach((c: any[]) =>
|
||||
expect(c[2].channels).toEqual(expect.arrayContaining(['system', 'email']))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 6: Level 1 Approval ───────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 6: Level 1 Approved → DD-Lead + ZBH notified', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-07: DD-Lead receives Action Required notification after Level 1 approval', async () => {
|
||||
await NotificationService.notify('lead-1', 'ddlead@re.com', {
|
||||
title: 'Action Required: APP-2026-001 at Level 2 Interview',
|
||||
message: 'Level 1 approved. Please conduct Level 2 evaluation.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91777', link: '/applications/app-1', requestId: 'APP-2026-001', targetStage: 'Level 2 Interview' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toContain('whatsapp');
|
||||
expect(call[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('TC-ONB-08: ZBH receives same channels as DD-Lead after Level 1 approval', async () => {
|
||||
await NotificationService.notify('zbh-1', 'zbh@re.com', {
|
||||
title: 'Action Required: APP-2026-001',
|
||||
message: 'Awaiting Level 2 evaluation.',
|
||||
channels: ['system', 'email', 'whatsapp'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { phone: '+91666', link: '/applications/app-1', requestId: 'APP-2026-001', targetStage: 'Level 2 Interview' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('zbh-1', 'zbh@re.com', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 10-11: FDD → Finance ──────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 10-11: FDD Verification → Finance Review', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-09: FDD team receives email + system notification on assignment', async () => {
|
||||
await NotificationService.notify('fdd-1', 'fddagency@external.com', {
|
||||
title: 'FDD Assignment: APP-2026-001',
|
||||
message: 'You have been assigned to perform financial due diligence.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { link: '/fdd/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
// FDD is external — no WhatsApp per SRS §6.15
|
||||
expect(call[2].channels).not.toContain('whatsapp');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-ONB-10: Finance team receives email + system after FDD report submission', async () => {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'FDD Report Submitted: APP-2026-001',
|
||||
message: 'FDD agency has submitted the financial due diligence report.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { link: '/finance/fdd/app-1', requestId: 'APP-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 13: LOI Issued ────────────────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 13: LOI Issued — Email ONLY (SRS §1.1.2)', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-11: LOI_ISSUED notification uses email channel only (WhatsApp excluded per SRS §1.1.2)', async () => {
|
||||
// This is the critical SRS compliance test:
|
||||
// "LOI documents are shared exclusively via official email and not through WhatsApp."
|
||||
await NotificationService.notify('sys-user-1', 'applicant@gmail.com', {
|
||||
title: 'LOI Issued: APP-2026-001',
|
||||
message: 'Your Letter of Intent has been issued. Please check your email.',
|
||||
channels: ['email', 'system'], // WhatsApp intentionally absent
|
||||
templateCode: 'LOI_ISSUED',
|
||||
placeholders: { link: '/applications/app-1', applicantName: 'Rahul Verma' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).not.toContain('whatsapp');
|
||||
expect(call[2].templateCode).toBe('LOI_ISSUED');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-ONB-12: LOI Issued notification includes link to applicant portal', async () => {
|
||||
await NotificationService.notify('sys-user-1', 'applicant@gmail.com', {
|
||||
title: 'LOI Issued',
|
||||
message: 'LOI Ready',
|
||||
channels: ['email', 'system'],
|
||||
templateCode: 'LOI_ISSUED',
|
||||
placeholders: { link: 'http://localhost:5173/applications/app-1', ctaLabel: 'View LOI' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].placeholders?.link).toContain('/applications/');
|
||||
expect(call[2].placeholders?.ctaLabel).toBe('View LOI');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 14: Dealer Code Generated ─────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 14: Dealer Code Generated — SAP Mock + Notification', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-13: SAP mock returns all 4 code types (salesCode, serviceCode, gmaCode, gearCode)', async () => {
|
||||
const result = await ExternalMocksService.mockGenerateSapCodes('app-uuid-001');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.salesCode).toMatch(/^SLS-\d{4}$/);
|
||||
expect(result.data.serviceCode).toMatch(/^SRV-\d{4}$/);
|
||||
expect(result.data.gmaCode).toMatch(/^GMA-\d{4}$/);
|
||||
expect(result.data.gearCode).toMatch(/^GER-\d{4}$/);
|
||||
expect(result.data.sapMasterId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('TC-ONB-14: Finance, Legal, DD-Admin are notified after Dealer Code is generated', async () => {
|
||||
const stakeholders = [
|
||||
{ id: 'finance-1', email: 'finance@re.com', label: 'Finance' },
|
||||
{ id: 'legal-1', email: 'legal@re.com', label: 'Legal' },
|
||||
{ id: 'admin-1', email: 'ddadmin@re.com', label: 'DD Admin' },
|
||||
];
|
||||
|
||||
for (const s of stakeholders) {
|
||||
await NotificationService.notify(s.id, s.email, {
|
||||
title: 'Dealer Code Generated: APP-2026-001',
|
||||
message: 'Dealer Code has been generated in SAP.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'DEALER_CODE_READY',
|
||||
placeholders: { requestId: 'APP-2026-001' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledTimes(3);
|
||||
mockNotify.mock.calls.forEach((c: any[]) =>
|
||||
expect(c[2].templateCode).toBe('DEALER_CODE_READY')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 16-17: EOR + Inauguration ─────────────────────────────────────────
|
||||
|
||||
describe('Onboarding Stage 16-17: EOR Completion + Inauguration', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-ONB-15: DD-Head + NBH receive system alert when EOR reaches 100% (SRS §6.19.3.4)', async () => {
|
||||
for (const [userId, email] of [['head-1', 'ddhead@re.com'], ['nbh-1', 'nbh@re.com']]) {
|
||||
await NotificationService.notify(userId, email, {
|
||||
title: 'EOR Checklist Complete: APP-2026-001',
|
||||
message: 'All EOR parameters verified. Ready for Inauguration.',
|
||||
channels: ['system'],
|
||||
templateCode: 'EOR_COMPLETED',
|
||||
placeholders: { requestId: 'APP-2026-001' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledTimes(2);
|
||||
mockNotify.mock.calls.forEach((c: any[]) =>
|
||||
expect(c[2].channels).toEqual(['system'])
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-ONB-16: Dealership marked Live — applicant receives system notification', async () => {
|
||||
await NotificationService.notify('dealer-sys-user-1', 'applicant@gmail.com', {
|
||||
title: 'Congratulations! Your Dealership is Now Live.',
|
||||
message: 'APP-2026-001 has been inaugurated and is now Active.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'ONBOARDING_STATUS_UPDATE',
|
||||
placeholders: { status: 'Dealership Live', applicantName: 'Rahul Verma' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toContain('email');
|
||||
expect(call[2].placeholders?.status).toBe('Dealership Live');
|
||||
});
|
||||
});
|
||||
365
src/__tests__/resignation-stage-notifications.test.ts
Normal file
365
src/__tests__/resignation-stage-notifications.test.ts
Normal file
@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @file resignation-stage-notifications.test.ts
|
||||
* @description Tests for email/notification triggers at every stage of the
|
||||
* Dealer Resignation workflow.
|
||||
*
|
||||
* Stages covered (SRS §4.2 + §7.x):
|
||||
* 1. Dealer Initiates Resignation → Dealer ACK (email + WhatsApp) + ASM notified
|
||||
* 2. ASM Review → RBM + DD-ZM notified (email + WhatsApp)
|
||||
* 3. RBM + DD-ZM Joint Evaluation → ZBH notified (email + WhatsApp)
|
||||
* 4. ZBH Review → DD-Lead notified (email + WhatsApp)
|
||||
* 5. DD-Lead Review → NBH notified (email + WhatsApp)
|
||||
* 6. NBH Approval → Legal notified (email + system)
|
||||
* 7. Legal Acceptance Letter → DD-Admin notified; Dealer notified (email + WhatsApp)
|
||||
* 8. DD-Admin Closure + F&F Trigger → Finance notified (email + system)
|
||||
* 9. Send Back (any level) → ASM + Dealer notified (email + WhatsApp)
|
||||
* 10. Revoke → Dealer notified (email + WhatsApp)
|
||||
* 11. Dealer Withdrawal → Internal team notified (system)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import {
|
||||
notifyStakeholdersOnTransition,
|
||||
notifyResignationSubmittedEmails,
|
||||
} from '../common/utils/workflow-email-notifications.js';
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
const BASE_RESIGNATION = {
|
||||
id: 'res-uuid-001',
|
||||
dealerId: 'dealer-99',
|
||||
resignationId: 'RES-2026-001',
|
||||
lastOperationalDateSales: '2026-07-31',
|
||||
lastOperationalDateServices: '2026-07-31',
|
||||
};
|
||||
|
||||
const BASE_META = {
|
||||
code: 'RES-2026-001',
|
||||
dealerName: 'Sunrise Motorcycles Pvt. Ltd.',
|
||||
dealerId: 'dealer-99',
|
||||
actionUserFullName: 'Current Actor',
|
||||
action: 'Forwarded for review',
|
||||
remarks: 'All documents verified.',
|
||||
link: 'http://localhost:5173/resignation/res-uuid-001',
|
||||
};
|
||||
|
||||
const mockParticipants: any[] = [];
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
RequestParticipant: { findAll: jest.fn(async () => mockParticipants) },
|
||||
User: {
|
||||
findByPk: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
email: `${id}@re.com`,
|
||||
fullName: 'Mock User',
|
||||
mobileNumber: '+919800000001',
|
||||
})),
|
||||
},
|
||||
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
District: {},
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const makeParticipant = (id: string, roleCode: string, mobileNumber: string | null = '+91900') => ({
|
||||
user: { id, email: `${id}@re.com`, fullName: `User ${id}`, roleCode, mobileNumber },
|
||||
});
|
||||
|
||||
// ─── Stage 1: Dealer Initiation ───────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 1: Dealer Initiates Request', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-01: Dealer receives RESIGNATION_RECEIVED email acknowledgement', async () => {
|
||||
const { sendEmail } = await import('../common/utils/email.service.js');
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-99',
|
||||
email: 'dealer@sunrise.com',
|
||||
fullName: 'Sunrise Motorcycles',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||
|
||||
expect(sendEmail).toHaveBeenCalledWith(
|
||||
'dealer@sunrise.com',
|
||||
expect.stringContaining('RES-2026-001'),
|
||||
'RESIGNATION_RECEIVED',
|
||||
expect.objectContaining({ dealerName: 'Sunrise Motorcycles' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-RES-02: Dealer receives WhatsApp acknowledgement if mobileNumber exists (SRS §1.1.1)', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-99',
|
||||
email: 'dealer@sunrise.com',
|
||||
fullName: 'Sunrise Motorcycles',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||
|
||||
const waCall = mockNotify.mock.calls.find(
|
||||
(c) => c[0] === 'dealer-99' && c[2].channels?.includes('whatsapp')
|
||||
);
|
||||
expect(waCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('TC-RES-03: ASM receives RESIGNATION_SUBMITTED notification with email + system', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-99',
|
||||
email: 'dealer@sunrise.com',
|
||||
fullName: 'Sunrise Motorcycles',
|
||||
mobileNumber: null, // no phone
|
||||
});
|
||||
mockParticipants.push(makeParticipant('asm-1', 'ASM', '+91000'));
|
||||
|
||||
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||
|
||||
const asmCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(asmCall?.[2].templateCode).toBe('RESIGNATION_SUBMITTED');
|
||||
expect(asmCall?.[2].channels).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 2: ASM → RBM + DD-ZM ─────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 2: ASM Review → RBM + DD-ZM Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-04: RBM receives Action Required (email + WhatsApp + system) after ASM review', async () => {
|
||||
mockParticipants.push(makeParticipant('rbm-1', 'RBM', '+91100'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'RBM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'rbm-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('TC-RES-05: DD-ZM receives same channels as RBM for joint evaluation', async () => {
|
||||
mockParticipants.push(makeParticipant('zm-1', 'DD_ZM', '+91200'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ZM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zm-1');
|
||||
expect(call?.[2].channels).toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 3: RBM/ZM → ZBH ───────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 3: RBM+ZM Evaluation → ZBH Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-06: ZBH receives Action Required notification after RBM+ZM approval', async () => {
|
||||
mockParticipants.push(makeParticipant('zbh-1', 'ZBH', '+91300'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ZBH Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zbh-1');
|
||||
expect(call?.[2].channels).toContain('email');
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 4: ZBH → DD-Lead ──────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 4: ZBH Review → DD-Lead Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-07: DD-Lead receives email + WhatsApp after ZBH forwards case', async () => {
|
||||
mockParticipants.push(makeParticipant('lead-1', 'DD_LEAD', '+91400'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'DD Lead Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['email', 'whatsapp', 'system']));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 5: DD-Lead → NBH ──────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 5: DD-Lead Review → NBH Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-08: NBH receives Action Required (email + WhatsApp) for final approval', async () => {
|
||||
mockParticipants.push(makeParticipant('nbh-1', 'NBH', '+91500'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'NBH Approval', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'nbh-1');
|
||||
expect(call?.[2].channels).toContain('whatsapp');
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 6: NBH → Legal ────────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 6: NBH Approval → Legal Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-09: Legal team receives email + system notification after NBH approval', async () => {
|
||||
mockParticipants.push(makeParticipant('legal-1', 'LEGAL_ADMIN', null)); // no phone
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Legal Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||
expect(call?.[2].channels).not.toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 7: Legal Acceptance Letter ─────────────────────────────────────────
|
||||
|
||||
describe('Resignation Stage 7: Legal Acceptance Letter — Dealer Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-10: Dealer receives email + WhatsApp when Legal Acceptance Letter is uploaded', async () => {
|
||||
mockParticipants.push(makeParticipant('dealer-99', 'Dealer', '+91987654321'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Completed', {
|
||||
...BASE_META,
|
||||
dealerId: 'dealer-99',
|
||||
action: 'Legal Acceptance Letter issued',
|
||||
});
|
||||
|
||||
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||
expect(dealerCall?.[2].channels).toEqual(
|
||||
expect.arrayContaining(['system', 'email', 'whatsapp'])
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-RES-11: DD-Admin receives in-app system notification for case closure', async () => {
|
||||
mockParticipants.push(makeParticipant('admin-1', 'DD_ADMIN', '+91600'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'DD Admin', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'admin-1');
|
||||
expect(call?.[2].channels).toContain('system');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 9: Send Back actions ───────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Send Back — ASM notified with mandatory remarks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-12: Send Back by ZBH notifies ASM via email + WhatsApp + system (SRS §4.2.2.4)', async () => {
|
||||
mockParticipants.push(makeParticipant('asm-1', 'ASM', '+91700'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ASM Review', {
|
||||
...BASE_META,
|
||||
action: 'Sent back to ASM — insufficient documentation',
|
||||
actionUserFullName: 'ZBH Actor', // ZBH acting, so ASM won't be skipped
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(call?.[2].placeholders?.remarks).toBeDefined();
|
||||
});
|
||||
|
||||
it('TC-RES-13: Send Back includes remarks in notification placeholders (SRS §4.2.2.4)', async () => {
|
||||
mockParticipants.push(makeParticipant('asm-2', 'ASM', '+91800'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ASM Review', {
|
||||
...BASE_META,
|
||||
action: 'Sent back to ASM',
|
||||
remarks: 'MOM document missing. Please resubmit.',
|
||||
actionUserFullName: 'DD Lead Actor',
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'asm-2');
|
||||
expect(call?.[2].placeholders?.remarks).toBe('MOM document missing. Please resubmit.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 10: Revoke ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Resignation Revoke — Dealer notified on terminal event', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RES-14: Dealer receives email + WhatsApp when resignation is Revoked (SRS §4.2.2.6)', async () => {
|
||||
mockParticipants.push(makeParticipant('dealer-99', 'Dealer', '+91987'));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Revoked', {
|
||||
...BASE_META,
|
||||
dealerId: 'dealer-99',
|
||||
action: 'Resignation revoked by NBH',
|
||||
});
|
||||
|
||||
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||
expect(dealerCall?.[2].channels).toEqual(
|
||||
expect.arrayContaining(['system', 'email', 'whatsapp'])
|
||||
);
|
||||
expect(dealerCall?.[2].title).toContain('Revoked');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F&F Trigger (LWD) ────────────────────────────────────────────────────────
|
||||
|
||||
describe('F&F Trigger — LWD-based gate (SRS §1.1.5 + §4.2.2.9)', () => {
|
||||
it('TC-RES-15: F&F initiation is allowed when today >= LWD', () => {
|
||||
const lwd = new Date('2026-01-01'); // in the past
|
||||
const today = new Date();
|
||||
expect(today >= lwd).toBe(true); // Gate should be open
|
||||
});
|
||||
|
||||
it('TC-RES-16: F&F initiation is BLOCKED when today < LWD (future date)', () => {
|
||||
const futureLwd = new Date();
|
||||
futureLwd.setFullYear(futureLwd.getFullYear() + 1); // LWD is next year
|
||||
const today = new Date();
|
||||
expect(today < futureLwd).toBe(true); // Gate should be closed
|
||||
// In implementation: if (new Date() < new Date(resignation.lastWorkingDay)) → reject with 403
|
||||
});
|
||||
|
||||
it('TC-RES-17: Finance team is notified via email + system after F&F is initiated on LWD', async () => {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'F&F Settlement Initiated: RES-2026-001',
|
||||
message: 'Full & Final settlement has been triggered on the Last Working Day.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'FNF_INITIATED',
|
||||
placeholders: { requestId: 'RES-2026-001', dealerName: 'Sunrise Motorcycles' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].templateCode).toBe('FNF_INITIATED');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
});
|
||||
147
src/__tests__/sla-lifecycle.test.ts
Normal file
147
src/__tests__/sla-lifecycle.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* SLA lifecycle: start → breach → geography escalation → pause/resume
|
||||
*/
|
||||
|
||||
import { SLAService } from '../services/SLAService.js';
|
||||
import { resolveRecipientsForRoles } from '../services/slaGeographyResolver.js';
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
|
||||
const mockTrackUpdate = jest.fn().mockResolvedValue(true);
|
||||
const mockTrackCreate = jest.fn().mockResolvedValue({ id: 'track-1', stageName: 'ASM Review', metadata: {} });
|
||||
const mockFindAllTracks = jest.fn();
|
||||
const mockBreachCreate = jest.fn().mockResolvedValue({ id: 'breach-1' });
|
||||
|
||||
const mockConfig = {
|
||||
id: 'cfg-1',
|
||||
activityName: 'ASM Review',
|
||||
tatHours: 1,
|
||||
tatUnit: 'hours',
|
||||
ownerRole: 'ASM,RBM',
|
||||
reminders: [],
|
||||
escalationConfigs: [{ level: 1, timeValue: 0, timeUnit: 'hours', notifyRole: 'RBM' }]
|
||||
};
|
||||
|
||||
jest.mock('../database/models/index.js', () => {
|
||||
const mockUpdate = jest.fn().mockResolvedValue(true);
|
||||
return {
|
||||
default: {
|
||||
SLATracking: {
|
||||
findAll: (...args) => mockFindAllTracks(...args),
|
||||
update: mockUpdate,
|
||||
create: mockTrackCreate
|
||||
},
|
||||
SLAConfiguration: {
|
||||
findOne: jest.fn().mockResolvedValue(mockConfig),
|
||||
findByPk: jest.fn().mockResolvedValue({
|
||||
...mockConfig,
|
||||
reminders: [],
|
||||
escalationConfigs: mockConfig.escalationConfigs
|
||||
})
|
||||
},
|
||||
SLABreach: { create: (...args) => mockBreachCreate(...args) },
|
||||
Application: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
User: {
|
||||
findByPk: jest.fn().mockResolvedValue({ id: 'u-rbm', email: 'rbm@test.com', mobileNumber: '99' }),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null)
|
||||
},
|
||||
Worknote: { create: jest.fn().mockResolvedValue(true) },
|
||||
TerminationRequest: { findByPk: jest.fn() },
|
||||
Resignation: { findByPk: jest.fn() },
|
||||
RelocationRequest: { findByPk: jest.fn() },
|
||||
ConstitutionalChange: { findByPk: jest.fn() },
|
||||
FnF: { findByPk: jest.fn() },
|
||||
Dealer: { findByPk: jest.fn() },
|
||||
Outlet: { findOne: jest.fn() },
|
||||
District: {},
|
||||
Region: {},
|
||||
Zone: {}
|
||||
},
|
||||
__mocks__: { mockUpdate, mockTrackUpdate }
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../common/config/slaStageCatalog.js', () => ({
|
||||
slaConfigLookupNames: (name) => [name]
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/slaBusinessTime.js', () => ({
|
||||
effectiveElapsedMs: () => 10 * 60 * 60 * 1000
|
||||
}));
|
||||
|
||||
jest.mock('../services/NotificationService.js', () => ({
|
||||
NotificationService: { notify: jest.fn().mockResolvedValue(undefined) }
|
||||
}));
|
||||
|
||||
jest.mock('../services/slaGeographyResolver.js', () => ({
|
||||
resolveRecipientsForRoles: jest.fn().mockResolvedValue(['geo-rbm-1'])
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/workflowWorknote.js', () => ({
|
||||
writeWorkflowActivityWorknote: jest.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
const mockGeo = resolveRecipientsForRoles;
|
||||
|
||||
describe('SLAService lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.DEBUG_SLA_FAST_MODE = 'true';
|
||||
mockFindAllTracks.mockReset();
|
||||
});
|
||||
|
||||
it('startTrack creates a row when config exists', async () => {
|
||||
await SLAService.startTrack({
|
||||
entityType: 'application',
|
||||
entityId: 'app-1',
|
||||
applicationId: 'app-1',
|
||||
stageName: 'ASM Review'
|
||||
});
|
||||
expect(mockTrackCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkBreaches triggers breach and geography-aware escalation', async () => {
|
||||
const track = {
|
||||
id: 'track-1',
|
||||
entityType: 'termination',
|
||||
entityId: 'term-1',
|
||||
applicationId: null,
|
||||
stageName: 'ASM Review',
|
||||
startTime: new Date(Date.now() - 3 * 60 * 60 * 1000),
|
||||
isBreached: false,
|
||||
isActive: true,
|
||||
endTime: null,
|
||||
metadata: {},
|
||||
update: mockTrackUpdate
|
||||
};
|
||||
|
||||
mockFindAllTracks.mockResolvedValueOnce([track]).mockResolvedValueOnce([{ ...track, isBreached: true }]);
|
||||
|
||||
await SLAService.checkBreaches();
|
||||
expect(mockBreachCreate).toHaveBeenCalled();
|
||||
expect(mockNotify).toHaveBeenCalled();
|
||||
|
||||
await SLAService.checkBreaches();
|
||||
expect(mockGeo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pauseEntityTracks and resumeEntityTracks adjust metadata', async () => {
|
||||
const track = {
|
||||
id: 'track-2',
|
||||
metadata: {},
|
||||
update: jest.fn().mockImplementation(async (payload) => {
|
||||
track.metadata = payload.metadata;
|
||||
})
|
||||
};
|
||||
mockFindAllTracks.mockResolvedValue([track]);
|
||||
|
||||
await SLAService.pauseEntityTracks('termination', 'term-1');
|
||||
expect(track.metadata.pausedAt).toBeDefined();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await SLAService.resumeEntityTracks('termination', 'term-1');
|
||||
expect(track.metadata.pausedAt).toBeUndefined();
|
||||
expect(Number(track.metadata.accumulatedPauseMs || 0)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
354
src/__tests__/termination-stage-notifications.test.ts
Normal file
354
src/__tests__/termination-stage-notifications.test.ts
Normal file
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @file termination-stage-notifications.test.ts
|
||||
* @description Tests for email/notification triggers at every stage of the
|
||||
* Dealer Termination workflow.
|
||||
*
|
||||
* Stages covered (SRS §4.3 + §8.x):
|
||||
* 1. ASM Case Initiation → RBM + DD-ZM notified (email + WhatsApp)
|
||||
* 2. RBM + DD-ZM Review → ZBH notified (email + WhatsApp)
|
||||
* 3. ZBH Review → DD-Lead notified (email + WhatsApp)
|
||||
* 4. DD-Lead Review + Legal Assignment → Legal + DD-Head notified
|
||||
* 5. Legal Verification → DD-Lead notified after legal input
|
||||
* 6. DD-Head → NBH → NBH notified (email + WhatsApp)
|
||||
* 7. NBH → SCN Issuance → Legal triggered; DD-Admin + Dealer notified
|
||||
* 8. SCN Response Evaluation → Joint panel notified (system)
|
||||
* 9. NBH Final Decision → CEO + CCO notified (email + system)
|
||||
* 10. CEO/CCO Authorization → Legal notified for Termination Letter
|
||||
* 11. Termination Letter Issued → DD-Lead + DD-Admin + Finance notified
|
||||
* 12. F&F Trigger on LWD → Finance notified (email + system)
|
||||
*/
|
||||
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { notifyStakeholdersOnTransition } from '../common/utils/workflow-email-notifications.js';
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
const BASE_META = {
|
||||
code: 'TERM-2026-001',
|
||||
dealerName: 'ABC Motors Pvt. Ltd.',
|
||||
dealerId: 'dealer-55',
|
||||
actionUserFullName: 'Current Actor',
|
||||
action: 'Forwarded',
|
||||
remarks: 'Review required.',
|
||||
link: 'http://localhost:5173/termination/term-uuid-001',
|
||||
};
|
||||
|
||||
const mockParticipants: any[] = [];
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
RequestParticipant: { findAll: jest.fn(async () => mockParticipants) },
|
||||
User: {
|
||||
findByPk: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
email: `${id}@re.com`,
|
||||
fullName: 'Mock User',
|
||||
mobileNumber: '+919000000001',
|
||||
})),
|
||||
},
|
||||
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
District: {},
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const makeP = (id: string, roleCode: string, phone: string | null = '+91900') => ({
|
||||
user: { id, email: `${id}@re.com`, fullName: `User ${id}`, roleCode, mobileNumber: phone },
|
||||
});
|
||||
|
||||
// ─── Stage 1: ASM Initiation ──────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 1: ASM Initiates → RBM + DD-ZM Notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-01: RBM receives email + WhatsApp + system on termination case initiation', async () => {
|
||||
mockParticipants.push(makeP('rbm-1', 'RBM', '+91100'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'RBM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'rbm-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
});
|
||||
|
||||
it('TC-TERM-02: DD-ZM receives same channels as RBM for joint evaluation', async () => {
|
||||
mockParticipants.push(makeP('zm-1', 'DD_ZM', '+91200'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ZM Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zm-1');
|
||||
expect(call?.[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-TERM-03: Dealer does NOT have portal access for termination (SRS §1.1.6)', () => {
|
||||
// This is a documentation/contract test. Dealer should NOT appear in participants for termination.
|
||||
const dealerInParticipants = mockParticipants.some(
|
||||
(p) => p.user.roleCode === 'Dealer'
|
||||
);
|
||||
expect(dealerInParticipants).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 2-3: ZBH → DD-Lead ────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 2-3: RBM+ZM → ZBH → DD-Lead', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-04: ZBH receives Action Required notification after RBM+ZM approval', async () => {
|
||||
mockParticipants.push(makeP('zbh-1', 'ZBH', '+91300'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ZBH Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'zbh-1');
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
|
||||
it('TC-TERM-05: DD-Lead receives email + WhatsApp after ZBH forwards case', async () => {
|
||||
mockParticipants.push(makeP('lead-1', 'DD_LEAD', '+91400'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'DD Lead Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||
expect(call?.[2].channels).toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 4: DD-Lead → Legal ────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 4: DD-Lead → Legal Assignment', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-06: Legal team receives email + system on DD-Lead assignment (SRS §4.3.2.4)', async () => {
|
||||
mockParticipants.push(makeP('legal-1', 'LEGAL_ADMIN', null)); // no phone
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Legal Review', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||
expect(call?.[2].channels).not.toContain('whatsapp');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 6: DD-Head → NBH ──────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 6: DD-Head → NBH Evaluation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-07: NBH receives email + WhatsApp for strategic review (SRS §4.3.2.7)', async () => {
|
||||
mockParticipants.push(makeP('nbh-1', 'NBH', '+91500'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'NBH Evaluation', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'nbh-1');
|
||||
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 7: SCN Issuance ───────────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 7: Show Cause Notice Issuance', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-08: Legal receives notification to prepare SCN after NBH Go-Ahead (SRS §4.3.2.8)', async () => {
|
||||
mockParticipants.push(makeP('legal-1', 'LEGAL_ADMIN', null));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Show Cause Notice', BASE_META);
|
||||
|
||||
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||
expect(call?.[2].channels).toContain('system');
|
||||
expect(call?.[2].channels).toContain('email');
|
||||
});
|
||||
|
||||
it('TC-TERM-09: DD-Admin is notified to share SCN with dealer (system + email)', async () => {
|
||||
await NotificationService.notify('admin-1', 'ddadmin@re.com', {
|
||||
title: 'Action Required: Share SCN — TERM-2026-001',
|
||||
message: 'Show Cause Notice is ready. Please share with the dealer.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001', link: '/termination/term-uuid-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'admin-1',
|
||||
'ddadmin@re.com',
|
||||
expect.objectContaining({ templateCode: 'WORKFLOW_ACTION_REQUIRED' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 9: NBH Final Decision → CEO/CCO ───────────────────────────────────
|
||||
|
||||
describe('Termination Stage 9: NBH Final Decision → CEO + CCO Authorization', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-10: CEO receives email + system notification for final authorization (SRS §4.3.2.11)', async () => {
|
||||
await NotificationService.notify('ceo-1', 'ceo@re.com', {
|
||||
title: 'Authorization Required: Dealer Termination — TERM-2026-001',
|
||||
message: 'NBH has approved termination. CEO authorization required.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001', link: '/termination/term-uuid-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toContain('email');
|
||||
expect(call[2].channels).toContain('system');
|
||||
});
|
||||
|
||||
it('TC-TERM-11: CCO receives same notification as CEO for co-authorization', async () => {
|
||||
await NotificationService.notify('cco-1', 'cco@re.com', {
|
||||
title: 'Authorization Required: TERM-2026-001',
|
||||
message: 'Co-authorization from CCO required.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[0]).toBe('cco-1');
|
||||
expect(call[2].channels).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stage 11: Termination Letter ─────────────────────────────────────────────
|
||||
|
||||
describe('Termination Stage 11: Termination Letter — DD-Lead + DD-Admin + Finance Notified', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-TERM-12: DD-Lead is notified via system when Legal uploads Termination Letter (SRS §4.3.2.12)', async () => {
|
||||
await NotificationService.notify('lead-1', 'ddlead@re.com', {
|
||||
title: 'Termination Letter Issued: TERM-2026-001',
|
||||
message: 'Legal has uploaded the official Termination Letter.',
|
||||
channels: ['system'],
|
||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].channels).toEqual(['system']);
|
||||
});
|
||||
|
||||
it('TC-TERM-13: DD-Admin is notified to communicate Termination Letter to dealer (email + system)', async () => {
|
||||
await NotificationService.notify('admin-1', 'ddadmin@re.com', {
|
||||
title: 'Action Required: Communicate Termination Letter — TERM-2026-001',
|
||||
message: 'Please share the Termination Letter with the dealer.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith('admin-1', 'ddadmin@re.com', expect.anything());
|
||||
});
|
||||
|
||||
it('TC-TERM-14: Finance is notified for F&F setup after termination letter (email + system)', async () => {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'F&F Initiation Required: TERM-2026-001',
|
||||
message: 'Termination complete. Please initiate F&F settlement on LWD.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'FNF_INITIATED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
const call = mockNotify.mock.calls[0];
|
||||
expect(call[2].templateCode).toBe('FNF_INITIATED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── F&F Trigger (LWD) — Termination Context ──────────────────────────────────
|
||||
|
||||
describe('Termination: F&F Trigger Must Be on LWD (SRS §1.1.6)', () => {
|
||||
it('TC-TERM-15: F&F is blocked when today is before LWD', () => {
|
||||
const futureLwd = new Date();
|
||||
futureLwd.setDate(futureLwd.getDate() + 30); // 30 days from now
|
||||
const canInitiateFnF = new Date() >= futureLwd;
|
||||
expect(canInitiateFnF).toBe(false);
|
||||
});
|
||||
|
||||
it('TC-TERM-16: F&F is allowed when LWD has passed', () => {
|
||||
const pastLwd = new Date('2025-01-01');
|
||||
const canInitiateFnF = new Date() >= pastLwd;
|
||||
expect(canInitiateFnF).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-TERM-17: Finance receives notification only when F&F is triggered on LWD', async () => {
|
||||
const lwd = new Date('2025-12-01'); // past date — F&F allowed
|
||||
const today = new Date();
|
||||
|
||||
if (today >= lwd) {
|
||||
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||
title: 'F&F Triggered on Last Working Day: TERM-2026-001',
|
||||
message: 'Settlement process initiated.',
|
||||
channels: ['system', 'email'],
|
||||
templateCode: 'FNF_INITIATED',
|
||||
placeholders: { requestId: 'TERM-2026-001' },
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'finance-1',
|
||||
'finance@re.com',
|
||||
expect.objectContaining({ templateCode: 'FNF_INITIATED' })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Send Back in Termination ─────────────────────────────────────────────────
|
||||
|
||||
describe('Termination: Send Back / Revoke → ASM + DD-Lead notified', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-TERM-18: ZBH Send Back notifies ASM with remarks via email + WhatsApp (SRS §4.3.2.3)', async () => {
|
||||
mockParticipants.push(makeP('asm-1', 'ASM', '+91700'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ASM Review', {
|
||||
...BASE_META,
|
||||
action: 'Sent back — MOM documents incomplete',
|
||||
remarks: 'Please resubmit updated MOMs from dealer.',
|
||||
actionUserFullName: 'ZBH Actor',
|
||||
});
|
||||
|
||||
const asmCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(asmCall?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||
expect(asmCall?.[2].placeholders?.remarks).toBe('Please resubmit updated MOMs from dealer.');
|
||||
});
|
||||
|
||||
it('TC-TERM-19: DD-Lead Revoke action generates in-app notification for key observers', async () => {
|
||||
mockParticipants.push(makeP('head-1', 'DD_HEAD', '+91800'));
|
||||
|
||||
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Revoked', {
|
||||
...BASE_META,
|
||||
action: 'Revoked by DD Lead',
|
||||
actionUserFullName: 'Another User',
|
||||
});
|
||||
|
||||
const observerCall = mockNotify.mock.calls.find((c) => c[0] === 'head-1');
|
||||
// Key observer: system only for terminal event
|
||||
expect(observerCall?.[2].channels).toEqual(['system']);
|
||||
});
|
||||
});
|
||||
405
src/__tests__/workflow-email-notifications.test.ts
Normal file
405
src/__tests__/workflow-email-notifications.test.ts
Normal file
@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @file workflow-email-notifications.test.ts
|
||||
* @description Tests for notifyStakeholdersOnTransition + resolveNextActors.
|
||||
* Verifies that the correct personas receive the correct channels at each
|
||||
* onboarding, resignation, and termination stage.
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.14.3 — Next actor gets email + WhatsApp + in-app
|
||||
* §6.14.3 — Send Back notifies ASM via email + WhatsApp + in-app
|
||||
* §6.12.3 — Rejection notifies applicant via email + WhatsApp
|
||||
* §6.13 — Work Notes / observer roles get in-app only on terminal events
|
||||
*/
|
||||
|
||||
import {
|
||||
notifyStakeholdersOnTransition,
|
||||
resolveNextActors,
|
||||
notifyResignationSubmittedEmails,
|
||||
notifyRelocationSubmittedEmails,
|
||||
notifyConstitutionalSubmittedEmails,
|
||||
} from '../common/utils/workflow-email-notifications.js';
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { sendEmail } from '../common/utils/email.service.js';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('../common/utils/email.service.js', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
// Helper: build a participant user object
|
||||
const makeUser = (overrides: Partial<{
|
||||
id: string; email: string; fullName: string; roleCode: string; mobileNumber: string | null;
|
||||
}> = {}) => ({
|
||||
id: overrides.id ?? 'user-1',
|
||||
email: overrides.email ?? 'user@re.com',
|
||||
fullName: overrides.fullName ?? 'Test User',
|
||||
roleCode: overrides.roleCode ?? 'DD_ADMIN',
|
||||
mobileNumber: overrides.mobileNumber ?? '+919800000001',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeParticipant = (user: ReturnType<typeof makeUser>) => ({ user });
|
||||
|
||||
// ─── Mock DB ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockParticipants: ReturnType<typeof makeParticipant>[] = [];
|
||||
|
||||
jest.mock('../database/models/index.js', () => ({
|
||||
default: {
|
||||
RequestParticipant: {
|
||||
findAll: jest.fn(async () => mockParticipants),
|
||||
},
|
||||
User: {
|
||||
findByPk: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
fullName: 'System User',
|
||||
email: 'sysuser@re.com',
|
||||
mobileNumber: '+910000000000',
|
||||
})),
|
||||
},
|
||||
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
District: {},
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
// ─── Shared metadata ──────────────────────────────────────────────────────────
|
||||
|
||||
const baseMeta = {
|
||||
code: 'RES-2026-0001',
|
||||
dealerName: 'Sunrise Motorcycles',
|
||||
dealerId: 'dealer-99',
|
||||
actionUserFullName: 'Ravi Kumar (ASM)',
|
||||
action: 'Forwarded to RBM',
|
||||
remarks: 'Documents verified.',
|
||||
link: 'http://localhost:5173/resignation/abc123',
|
||||
};
|
||||
|
||||
// ─── resolveNextActors ────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveNextActors — stage-to-role mapping', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-RNA-01: Level 1 Interview resolves to DD_ZM and RBM', async () => {
|
||||
const zmUser = makeUser({ id: 'zm-1', roleCode: 'DD_ZM' });
|
||||
const rbmUser = makeUser({ id: 'rbm-1', roleCode: 'RBM' });
|
||||
mockParticipants.push(makeParticipant(zmUser), makeParticipant(rbmUser));
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'Level 1 Interview');
|
||||
expect(actors).toContain('zm-1');
|
||||
expect(actors).toContain('rbm-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-02: Level 2 Interview resolves to ZBH and DD_LEAD', async () => {
|
||||
const zbhUser = makeUser({ id: 'zbh-1', roleCode: 'ZBH' });
|
||||
const leadUser = makeUser({ id: 'lead-1', roleCode: 'DD_LEAD' });
|
||||
mockParticipants.push(makeParticipant(zbhUser), makeParticipant(leadUser));
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'Level 2 Interview');
|
||||
expect(actors).toContain('zbh-1');
|
||||
expect(actors).toContain('lead-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-03: Level 3 Interview resolves to NBH and DD_HEAD', async () => {
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||
mockParticipants.push(makeParticipant(nbhUser), makeParticipant(headUser));
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'Level 3 Interview');
|
||||
expect(actors).toContain('nbh-1');
|
||||
expect(actors).toContain('head-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-04: LOI Approval resolves to DD_HEAD (DD Head has not yet approved)', async () => {
|
||||
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
mockParticipants.push(makeParticipant(headUser), makeParticipant(nbhUser));
|
||||
|
||||
// StageApprovalAction returns empty (no approvals yet)
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([]);
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'LOI Approval');
|
||||
// Sequential: DD Head first
|
||||
expect(actors).toContain('head-1');
|
||||
expect(actors).not.toContain('nbh-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-05: LOI Approval resolves to NBH after DD Head has approved', async () => {
|
||||
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
mockParticipants.push(makeParticipant(headUser), makeParticipant(nbhUser));
|
||||
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorRole: 'DD Head' }, // DD Head already approved
|
||||
]);
|
||||
|
||||
const actors = await resolveNextActors('app-1', 'application', 'LOI Approval');
|
||||
expect(actors).toContain('nbh-1');
|
||||
expect(actors).not.toContain('head-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-06: NBH Approval stage resolves to NBH only', async () => {
|
||||
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||
mockParticipants.push(makeParticipant(nbhUser));
|
||||
|
||||
const actors = await resolveNextActors('res-1', 'resignation', 'NBH Approval');
|
||||
expect(actors).toEqual(['nbh-1']);
|
||||
});
|
||||
|
||||
it('TC-RNA-07: Legal Review stage resolves to LEGAL_ADMIN', async () => {
|
||||
const legalUser = makeUser({ id: 'legal-1', roleCode: 'LEGAL_ADMIN' });
|
||||
mockParticipants.push(makeParticipant(legalUser));
|
||||
|
||||
const actors = await resolveNextActors('res-1', 'resignation', 'Legal Review');
|
||||
expect(actors).toContain('legal-1');
|
||||
});
|
||||
|
||||
it('TC-RNA-08: Unknown stage returns empty array (no crash)', async () => {
|
||||
const actors = await resolveNextActors('app-1', 'application', 'NonExistentStage');
|
||||
expect(actors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── notifyStakeholdersOnTransition ──────────────────────────────────────────
|
||||
|
||||
describe('notifyStakeholdersOnTransition — channel selection per persona', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-NST-01: Next actor receives system + email + whatsapp channels', async () => {
|
||||
const rbmUser = makeUser({ id: 'rbm-1', roleCode: 'RBM', mobileNumber: '+919800000001' });
|
||||
mockParticipants.push(makeParticipant(rbmUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('app-1', 'application', 'RBM Review', baseMeta);
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'rbm-1',
|
||||
'user@re.com',
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-02: Next actor without phone gets system + email only (no WhatsApp)', async () => {
|
||||
const zbhUser = makeUser({ id: 'zbh-1', roleCode: 'ZBH', mobileNumber: null });
|
||||
mockParticipants.push(makeParticipant(zbhUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('app-1', 'application', 'ZBH Review', baseMeta);
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'zbh-1',
|
||||
'user@re.com',
|
||||
expect.objectContaining({
|
||||
channels: expect.not.arrayContaining(['whatsapp']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-03: Send Back action notifies ASM via system + email + whatsapp', async () => {
|
||||
const asmUser = makeUser({ id: 'asm-1', roleCode: 'ASM', mobileNumber: '+91900' });
|
||||
mockParticipants.push(makeParticipant(asmUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'ASM Review', {
|
||||
...baseMeta,
|
||||
action: 'Sent Back to ASM for clarification',
|
||||
actionUserFullName: 'DD Lead User', // different from ASM — won't be skipped
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'asm-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-04: Dealer receives system + email + whatsapp on Rejected terminal event', async () => {
|
||||
const dealerUser = makeUser({
|
||||
id: 'dealer-99',
|
||||
roleCode: 'Dealer',
|
||||
mobileNumber: '+91987654321',
|
||||
});
|
||||
mockParticipants.push(makeParticipant(dealerUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'Rejected', {
|
||||
...baseMeta,
|
||||
dealerId: 'dealer-99',
|
||||
});
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'dealer-99',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NST-05: Dealer receives in-app only on non-terminal interim stage', async () => {
|
||||
const dealerUser = makeUser({
|
||||
id: 'dealer-99',
|
||||
roleCode: 'Dealer',
|
||||
mobileNumber: '+91987654321',
|
||||
});
|
||||
mockParticipants.push(makeParticipant(dealerUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'ZBH Review', {
|
||||
...baseMeta,
|
||||
dealerId: 'dealer-99',
|
||||
});
|
||||
|
||||
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||
expect(dealerCall?.[2].channels).toEqual(['system']);
|
||||
});
|
||||
|
||||
it('TC-NST-06: Key observers (DD_LEAD, DD_HEAD, NBH) receive in-app only on terminal events', async () => {
|
||||
const leadUser = makeUser({ id: 'lead-1', roleCode: 'DD_LEAD', mobileNumber: '+91900' });
|
||||
mockParticipants.push(makeParticipant(leadUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('res-1', 'resignation', 'Completed', {
|
||||
...baseMeta,
|
||||
dealerId: 'dealer-99',
|
||||
actionUserFullName: 'DD Lead User', // acting user – but leadUser will still match key observer
|
||||
});
|
||||
|
||||
const observerCall = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||
// Key observer: only in-app (system)
|
||||
expect(observerCall?.[2].channels).toEqual(['system']);
|
||||
});
|
||||
|
||||
it('TC-NST-07: Acting user is skipped to avoid self-notification', async () => {
|
||||
const actingUser = makeUser({
|
||||
id: 'acting-1',
|
||||
roleCode: 'ZBH',
|
||||
fullName: 'Ravi Kumar (ASM)', // same as baseMeta.actionUserFullName
|
||||
});
|
||||
// actingUser is NOT the next actor (no role match for ZBH Review mapping)
|
||||
mockParticipants.push(makeParticipant(actingUser));
|
||||
|
||||
await notifyStakeholdersOnTransition('app-1', 'application', 'ZBH Review', baseMeta);
|
||||
|
||||
// Should not notify the acting user (they are the one who just acted)
|
||||
const actingCall = mockNotify.mock.calls.find((c) => c[0] === 'acting-1');
|
||||
expect(actingCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── notifyResignationSubmittedEmails ─────────────────────────────────────────
|
||||
|
||||
describe('notifyResignationSubmittedEmails — channels on dealer submission', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockParticipants.length = 0;
|
||||
});
|
||||
|
||||
it('TC-NRSE-01: sends RESIGNATION_RECEIVED email to dealer on submission', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-1',
|
||||
email: 'dealer@example.com',
|
||||
fullName: 'Sunrise Dealers',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
const sendEmailMock = sendEmail as jest.Mock;
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-1',
|
||||
dealerId: 'dealer-1',
|
||||
resignationId: 'RES-2026-0001',
|
||||
lastOperationalDateSales: '2026-06-30',
|
||||
});
|
||||
|
||||
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||
'dealer@example.com',
|
||||
expect.stringContaining('RES-2026-0001'),
|
||||
'RESIGNATION_RECEIVED',
|
||||
expect.objectContaining({ dealerName: 'Sunrise Dealers' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-NRSE-02: sends WhatsApp to dealer when mobileNumber is present', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-1',
|
||||
email: 'dealer@example.com',
|
||||
fullName: 'Sunrise Dealers',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-1',
|
||||
dealerId: 'dealer-1',
|
||||
resignationId: 'RES-2026-0001',
|
||||
lastOperationalDateSales: '2026-06-30',
|
||||
});
|
||||
|
||||
const whatsappCall = mockNotify.mock.calls.find(
|
||||
(c) => c[2].channels?.includes('whatsapp')
|
||||
);
|
||||
expect(whatsappCall).toBeDefined();
|
||||
expect(whatsappCall?.[2].templateCode).toBe('RESIGNATION_RECEIVED');
|
||||
});
|
||||
|
||||
it('TC-NRSE-03: notifies internal participants via email + system + whatsapp', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-1',
|
||||
email: 'dealer@example.com',
|
||||
fullName: 'Sunrise Dealers',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
|
||||
const internalUser = makeUser({ id: 'asm-1', roleCode: 'ASM', mobileNumber: '+91111' });
|
||||
mockParticipants.push(makeParticipant(internalUser));
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-1',
|
||||
dealerId: 'dealer-1',
|
||||
resignationId: 'RES-2026-0001',
|
||||
});
|
||||
|
||||
const internalCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||
expect(internalCall?.[2].channels).toEqual(
|
||||
expect.arrayContaining(['email', 'system', 'whatsapp'])
|
||||
);
|
||||
expect(internalCall?.[2].templateCode).toBe('RESIGNATION_SUBMITTED');
|
||||
});
|
||||
|
||||
it('TC-NRSE-04: skips WhatsApp if dealer has no mobileNumber', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findByPk.mockResolvedValueOnce({
|
||||
id: 'dealer-2',
|
||||
email: 'dealer2@example.com',
|
||||
fullName: 'No Phone Dealer',
|
||||
mobileNumber: null,
|
||||
});
|
||||
|
||||
await notifyResignationSubmittedEmails({
|
||||
id: 'res-uuid-2',
|
||||
dealerId: 'dealer-2',
|
||||
resignationId: 'RES-2026-0002',
|
||||
});
|
||||
|
||||
const whatsappCall = mockNotify.mock.calls.find(
|
||||
(c) => c[0] === 'dealer-2' && c[2].channels?.includes('whatsapp')
|
||||
);
|
||||
expect(whatsappCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
298
src/__tests__/workflow-service.test.ts
Normal file
298
src/__tests__/workflow-service.test.ts
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @file workflow-service.test.ts
|
||||
* @description Tests for WorkflowService.transitionApplication — verifies that
|
||||
* each status transition triggers the correct notifications, audit log entries,
|
||||
* and SLA tracking calls.
|
||||
*
|
||||
* SRS Coverage:
|
||||
* §6.22 — Every workflow event is auto-logged in Audit Trail
|
||||
* §6.14.3 — Status transitions trigger in-system + email + WhatsApp for applicant
|
||||
* §6.16 — LOI Issued triggers LOI_ISSUED email template (not WhatsApp per SRS §1.1.2)
|
||||
* §9.4 — SLA tracking starts/stops on each stage transition
|
||||
*/
|
||||
|
||||
import { WorkflowService } from '../services/WorkflowService.js';
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
import { SLAService } from '../services/SLAService.js';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jest.mock('../database/models/index.js', () => {
|
||||
const mockUpdate = jest.fn().mockResolvedValue(true);
|
||||
const mockCreate = jest.fn().mockResolvedValue({ id: 'hist-1' });
|
||||
const mockFindOne = jest.fn().mockResolvedValue(null);
|
||||
const mockFindByPk = jest.fn().mockResolvedValue({ fullName: 'Test Actor' });
|
||||
|
||||
return {
|
||||
default: {
|
||||
Application: {},
|
||||
ApplicationStatusHistory: { create: mockCreate },
|
||||
User: { findOne: mockFindOne, findByPk: mockFindByPk },
|
||||
Dealer: {},
|
||||
StageApprovalPolicy: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
__mocks__: { mockUpdate, mockCreate, mockFindOne },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../common/utils/progress.js', () => ({
|
||||
syncApplicationProgress: jest.fn().mockResolvedValue(undefined),
|
||||
PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: {},
|
||||
}));
|
||||
|
||||
jest.mock('../common/config/constants.js', () => ({
|
||||
AUDIT_ACTIONS: { UPDATED: 'UPDATED' },
|
||||
APPLICATION_STAGES: { SHORTLISTED: 'Shortlisted', LOI_ISSUED: 'LOI Issued' },
|
||||
OVERALL_STATUS_TO_DB_CURRENT_STAGE: {},
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/workflow-email-notifications.js', () => ({
|
||||
notifyStakeholdersOnTransition: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../services/applicationAuditLog.service.js', () => ({
|
||||
pickApplicationAuditContext: jest.fn().mockReturnValue({}),
|
||||
safeAuditLogCreate: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockSLAStop = jest.spyOn(SLAService, 'stopTrack').mockResolvedValue(undefined as any);
|
||||
const mockSLAStart = jest.spyOn(SLAService, 'startTrack').mockResolvedValue(undefined as any);
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
|
||||
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
// ─── Application fixture ─────────────────────────────────────────────────────
|
||||
|
||||
const makeApp = (overrides: Record<string, any> = {}) => {
|
||||
const app: any = {
|
||||
id: 'app-uuid-001',
|
||||
applicationId: 'APP-2026-001',
|
||||
overallStatus: 'Shortlisted',
|
||||
currentStage: 'Shortlisted',
|
||||
progressPercentage: 20,
|
||||
email: 'applicant@gmail.com',
|
||||
applicantName: 'Rahul Verma',
|
||||
mobileNumber: '+919876543210',
|
||||
update: jest.fn().mockResolvedValue(true),
|
||||
...overrides,
|
||||
};
|
||||
return app;
|
||||
};
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('WorkflowService.transitionApplication — status transitions', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-WS-01: updates application overallStatus to the target status', async () => {
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
||||
expect(app.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ overallStatus: 'Level 1 Interview Pending' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-02: creates an ApplicationStatusHistory record on each transition', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Level 2 Interview Pending', 'user-2');
|
||||
expect(db.ApplicationStatusHistory.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationId: 'app-uuid-001',
|
||||
previousStatus: 'Shortlisted',
|
||||
newStatus: 'Level 2 Interview Pending',
|
||||
changedBy: 'user-2',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-03: skips redundant status history when status is already at target', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
const app = makeApp({ overallStatus: 'Level 1 Interview Pending' });
|
||||
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
||||
expect(db.ApplicationStatusHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TC-WS-04: calls safeAuditLogCreate with oldData and newData on each transition', async () => {
|
||||
const { safeAuditLogCreate } = await import('../services/applicationAuditLog.service.js');
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-fdd');
|
||||
expect(safeAuditLogCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATED',
|
||||
entityType: 'application',
|
||||
entityId: 'app-uuid-001',
|
||||
oldData: expect.objectContaining({ status: 'Shortlisted' }),
|
||||
newData: expect.objectContaining({ status: 'FDD Verification' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-05: stops SLA tracking for the previous stage and starts for the new stage', async () => {
|
||||
const app = makeApp({ currentStage: 'Level 1 Interview Pending' });
|
||||
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-1');
|
||||
expect(mockSLAStop).toHaveBeenCalledWith('app-uuid-001', 'Level 1 Interview Pending');
|
||||
});
|
||||
|
||||
it('TC-WS-06: triggers NotificationService.notify for the applicant on any status transition', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-admin');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining('Onboarding Update'),
|
||||
channels: expect.arrayContaining(['email', 'system']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-07: uses LOI_ISSUED template when target status is "LOI Issued"', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'LOI Issued', 'admin-1');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'LOI_ISSUED' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-08: uses LOA_ISSUED template when target status is "LOA Issued"', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'LOA Issued', 'admin-1');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'LOA_ISSUED' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-09: uses DEALER_CODE_READY template when target status is "Dealer Code Generated"', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: null });
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Dealer Code Generated', 'admin-1');
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'applicant@gmail.com',
|
||||
expect.objectContaining({ templateCode: 'DEALER_CODE_READY' })
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-10: notifyStakeholdersOnTransition is called for every transition', async () => {
|
||||
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'ZBH Review', 'user-zbh');
|
||||
expect(notifyStakeholdersOnTransition).toHaveBeenCalledWith(
|
||||
'app-uuid-001',
|
||||
'application',
|
||||
'ZBH Review',
|
||||
expect.objectContaining({
|
||||
code: 'APP-2026-001',
|
||||
dealerName: 'Rahul Verma',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('TC-WS-11: skips applicant notification when skipNotification metadata is true', async () => {
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'Shortlisted', 'admin-1', {
|
||||
skipNotification: true,
|
||||
forceLog: true,
|
||||
});
|
||||
expect(mockNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TC-WS-12: includes applicant mobileNumber in WhatsApp placeholders', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.User.findOne.mockResolvedValueOnce({
|
||||
id: 'sys-user-1',
|
||||
mobileNumber: '+919876543210',
|
||||
});
|
||||
const app = makeApp();
|
||||
await WorkflowService.transitionApplication(app, 'FDD Verification', 'admin-1');
|
||||
const notifyCall = mockNotify.mock.calls.find(
|
||||
(c) => c[1] === 'applicant@gmail.com'
|
||||
);
|
||||
expect(notifyCall?.[2].placeholders?.phone).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── WorkflowService.evaluateStagePolicy ─────────────────────────────────────
|
||||
|
||||
describe('WorkflowService.evaluateStagePolicy — multi-role gate logic', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('TC-WESP-01: returns policyMet=true when no active policy exists for the stage', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce(null);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'SOME_STAGE');
|
||||
expect(result.policyMet).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-WESP-02: Super Admin bypass — always returns policyMet=true', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'ALL',
|
||||
minApprovals: 2,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'su-1', actorRole: 'Super Admin', decision: 'Approved' },
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(true);
|
||||
expect(result.overriddenBy).toBe('Super Admin');
|
||||
});
|
||||
|
||||
it('TC-WESP-03: MIN_N mode — policyMet=true when minApprovals threshold is reached', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'MIN_N',
|
||||
minApprovals: 1,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(true);
|
||||
});
|
||||
|
||||
it('TC-WESP-04: ALL mode — policyMet=false when only one of two required roles responded', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'ALL',
|
||||
minApprovals: 2,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||
// RBM has NOT responded yet
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(false);
|
||||
});
|
||||
|
||||
it('TC-WESP-05: ALL mode — policyMet=true when both RBM and DD_ZM have responded', async () => {
|
||||
const db = (await import('../database/models/index.js')).default as any;
|
||||
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||
requiredRoles: ['DD_ZM', 'RBM'],
|
||||
approvalMode: 'ALL',
|
||||
minApprovals: 2,
|
||||
});
|
||||
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||
{ actorUserId: 'rbm-1', actorRole: 'RBM', decision: 'Approved' },
|
||||
]);
|
||||
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||
expect(result.policyMet).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -85,7 +85,7 @@ export const APPLICATION_STATUS = {
|
||||
LEVEL_3_PENDING: 'Level 3 Interview Pending',
|
||||
LEVEL_3_APPROVED: 'Level 3 Approved',
|
||||
FDD_VERIFICATION: 'FDD Verification',
|
||||
SECURITY_DETAILS: 'Security Details',
|
||||
SECURITY_DETAILS: 'Security Deposit',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
LOI_IN_PROGRESS: 'LOI In Progress',
|
||||
LOI_ISSUED: 'LOI Issued',
|
||||
@ -142,6 +142,8 @@ export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
|
||||
[APPLICATION_STATUS.LEVEL_3_APPROVED]: APPLICATION_STAGES.LEVEL_3_APPROVED,
|
||||
[APPLICATION_STATUS.FDD_VERIFICATION]: APPLICATION_STAGES.FDD,
|
||||
[APPLICATION_STATUS.SECURITY_DETAILS]: APPLICATION_STAGES.LOI,
|
||||
/** Legacy `overallStatus` before rename; remove after DB migrated */
|
||||
'Security Details': APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.PAYMENT_PENDING]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.LOI_IN_PROGRESS]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.LOI_ISSUED]: APPLICATION_STAGES.LOI,
|
||||
@ -176,14 +178,14 @@ export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
|
||||
// Termination Stages
|
||||
export const TERMINATION_STAGES = {
|
||||
SUBMITTED: 'Submitted',
|
||||
RBM_REVIEW: 'RBM Review',
|
||||
RBM_REVIEW: 'RBM + DD-ZM Review',
|
||||
ZBH_REVIEW: 'ZBH Review',
|
||||
DD_LEAD_REVIEW: 'DD Lead Review',
|
||||
LEGAL_VERIFICATION: 'Legal Verification',
|
||||
DD_HEAD_REVIEW: 'DD Head Review',
|
||||
NBH_EVALUATION: 'NBH Evaluation',
|
||||
SCN_ISSUED: 'Show Cause Notice',
|
||||
PERSONAL_HEARING: 'Personal Hearing',
|
||||
SCN_ISSUED: 'Show Cause Notice (SCN)',
|
||||
PERSONAL_HEARING: 'Evaluation of Dealer SCN Response',
|
||||
NBH_FINAL_APPROVAL: 'NBH Final Approval',
|
||||
CCO_APPROVAL: 'CCO Approval',
|
||||
CEO_APPROVAL: 'CEO Final Approval',
|
||||
@ -195,11 +197,14 @@ export const TERMINATION_STAGES = {
|
||||
// Resignation Stages
|
||||
export const RESIGNATION_STAGES = {
|
||||
ASM: 'ASM',
|
||||
RBM: 'RBM',
|
||||
RBM: 'RBM + DD-ZM Review',
|
||||
ZBH: 'ZBH',
|
||||
DD_LEAD: 'DD Lead',
|
||||
DD_HEAD: 'DD Head',
|
||||
NBH: 'NBH',
|
||||
DD_ADMIN: 'DD Admin',
|
||||
/** Post DD Admin — workflow paused until an authorized user runs Push to F&F (no automatic F&F). */
|
||||
AWAITING_FNF: 'Awaiting F&F',
|
||||
LEGAL: 'Legal',
|
||||
SPARES_CLEARANCE: 'Spares Clearance',
|
||||
SERVICE_CLEARANCE: 'Service Clearance',
|
||||
@ -223,13 +228,8 @@ export const RESIGNATION_TYPES = {
|
||||
export const CONSTITUTIONAL_CHANGE_TYPES = {
|
||||
PROPRIETORSHIP: 'Proprietorship',
|
||||
PARTNERSHIP: 'Partnership',
|
||||
LLP_CONVERSION: 'LLP Conversion',
|
||||
LLP: 'LLP',
|
||||
PRIVATE_LIMITED: 'Private Limited',
|
||||
COMPANY_FORMATION: 'Company Formation',
|
||||
OWNERSHIP_TRANSFER: 'Ownership Transfer',
|
||||
PARTNERSHIP_CHANGE: 'Partnership Change',
|
||||
DIRECTOR_CHANGE: 'Director Change'
|
||||
PRIVATE_LIMITED: 'Private Limited'
|
||||
} as const;
|
||||
|
||||
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
|
||||
@ -270,10 +270,8 @@ export const RELOCATION_STAGES = {
|
||||
DD_ZM_REVIEW: 'DD ZM Review',
|
||||
ZBH_REVIEW: 'ZBH Review',
|
||||
DD_LEAD_REVIEW: 'DD Lead Review',
|
||||
DD_HEAD_APPROVAL: 'DD Head Approval',
|
||||
NBH_APPROVAL: 'NBH Approval',
|
||||
LEGAL_CLEARANCE: 'Legal Clearance',
|
||||
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
|
||||
COMPLETED: 'Completed',
|
||||
REJECTED: 'Rejected'
|
||||
} as const;
|
||||
@ -423,6 +421,7 @@ export const AUDIT_ACTIONS = {
|
||||
RESIGNATION_REJECTED: 'RESIGNATION_REJECTED',
|
||||
RESIGNATION_REVOKED: 'RESIGNATION_REVOKED',
|
||||
RESIGNATION_SENT_BACK: 'RESIGNATION_SENT_BACK',
|
||||
RESIGNATION_LETTER_DISPATCHED: 'RESIGNATION_LETTER_DISPATCHED',
|
||||
TERMINATION_REVOKED: 'TERMINATION_REVOKED',
|
||||
TERMINATION_SENT_BACK: 'TERMINATION_SENT_BACK',
|
||||
RELOCATION_SENT_BACK: 'RELOCATION_SENT_BACK',
|
||||
@ -433,6 +432,34 @@ export const AUDIT_ACTIONS = {
|
||||
REMINDER_SENT: 'REMINDER_SENT'
|
||||
} as const;
|
||||
|
||||
// System / Configuration audit modules — written to `system_audit_logs`,
|
||||
// kept separate from per-application audit so config changes can be filtered
|
||||
// without scanning the much larger application audit volume.
|
||||
export const SYSTEM_AUDIT_MODULES = {
|
||||
QUESTIONNAIRE: 'QUESTIONNAIRE',
|
||||
INTERVIEW_CONFIG: 'INTERVIEW_CONFIG',
|
||||
SYSTEM_CONFIG: 'SYSTEM_CONFIG',
|
||||
SLA_CONFIG: 'SLA_CONFIG',
|
||||
EMAIL_TEMPLATE: 'EMAIL_TEMPLATE',
|
||||
MASTER_HIERARCHY: 'MASTER_HIERARCHY',
|
||||
ROLE_ASSIGNMENT: 'ROLE_ASSIGNMENT',
|
||||
USER_ADMIN: 'USER_ADMIN',
|
||||
DEALER_MAPPING: 'DEALER_MAPPING'
|
||||
} as const;
|
||||
|
||||
export const SYSTEM_AUDIT_ACTIONS = {
|
||||
CREATED: 'CREATED',
|
||||
UPDATED: 'UPDATED',
|
||||
DELETED: 'DELETED',
|
||||
ACTIVATED: 'ACTIVATED',
|
||||
DEACTIVATED: 'DEACTIVATED',
|
||||
INITIALIZED: 'INITIALIZED',
|
||||
SUBMITTED: 'SUBMITTED',
|
||||
ASSIGNED: 'ASSIGNED',
|
||||
UNASSIGNED: 'UNASSIGNED',
|
||||
REORDERED: 'REORDERED'
|
||||
} as const;
|
||||
|
||||
// Document Types
|
||||
export const DOCUMENT_TYPES = {
|
||||
GST_CERTIFICATE: 'GST Certificate',
|
||||
@ -493,13 +520,15 @@ export const RESIGNATION_DOCUMENT_TYPES = [
|
||||
'Resignation Letter',
|
||||
'Dealer Undertaking',
|
||||
'Approval Note',
|
||||
'Legal Communication',
|
||||
'Resignation Acceptance Letter',
|
||||
'Handover Document',
|
||||
'Settlement Supporting Document',
|
||||
'PPT Presentation',
|
||||
'Other'
|
||||
] as const;
|
||||
|
||||
export const RESIGNATION_DOCUMENT_STAGES = [
|
||||
'Initiation',
|
||||
'ASM',
|
||||
'RBM',
|
||||
'ZBH',
|
||||
@ -511,6 +540,7 @@ export const RESIGNATION_DOCUMENT_STAGES = [
|
||||
] as const;
|
||||
|
||||
export const TERMINATION_DOCUMENT_TYPES = [
|
||||
'Presentation',
|
||||
'Termination Recommendation',
|
||||
'Show Cause Notice',
|
||||
'SCN Response',
|
||||
@ -523,7 +553,7 @@ export const TERMINATION_DOCUMENT_TYPES = [
|
||||
|
||||
export const TERMINATION_DOCUMENT_STAGES = [
|
||||
'Submitted',
|
||||
'RBM Review',
|
||||
'RBM + DD-ZM Review',
|
||||
'ZBH Review',
|
||||
'DD Lead Review',
|
||||
'Legal Verification',
|
||||
@ -557,7 +587,8 @@ export const OFFBOARDING_ACTIONS = {
|
||||
PUSH_FNF: 'pushfnf',
|
||||
RECONSIDER: 'reconsider',
|
||||
ISSUE_SCN: 'issueSCN',
|
||||
SCN_RESPONSE: 'scnResponse'
|
||||
SCN_RESPONSE: 'scnResponse',
|
||||
HOLD: 'hold'
|
||||
} as const;
|
||||
|
||||
// Module List for Document Management
|
||||
@ -566,8 +597,8 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU
|
||||
// Process Stages per Module (Source of Truth for Checklists)
|
||||
export const STAGES_MAP = {
|
||||
'ONBOARDING': ['General', 'KYC', 'Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview', 'FDD', 'LOI Approval', 'LOA Approval', 'LOI Issue', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion', 'EOR', 'Inauguration'],
|
||||
'RESIGNATION': ['Submission', 'Regional Review', 'ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
|
||||
'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'],
|
||||
'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
|
||||
'RELOCATION': ['Initiated', 'ASM Review', 'RBM Review', 'DD ZM Review', 'ZBH Review', 'DD Lead Review', 'NBH Approval', 'Legal Clearance', 'Completed'],
|
||||
'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'],
|
||||
'TERMINATION': ['Hearing', 'Review', 'Closed']
|
||||
'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed']
|
||||
} as const;
|
||||
|
||||
@ -1,25 +1,49 @@
|
||||
import 'dotenv/config';
|
||||
import {
|
||||
getSmtpConfig,
|
||||
isSmtpEnabled,
|
||||
type SmtpConfig,
|
||||
} from '../../services/smtpConfig.service.js';
|
||||
|
||||
export interface EmailConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: {
|
||||
user: string | undefined;
|
||||
pass: string | undefined;
|
||||
};
|
||||
from: string;
|
||||
}
|
||||
|
||||
const config: EmailConfig = {
|
||||
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.EMAIL_PORT || '587'),
|
||||
secure: process.env.EMAIL_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASSWORD
|
||||
},
|
||||
from: process.env.EMAIL_FROM || 'Royal Enfield <noreply@royalenfield.com>'
|
||||
export type EmailConfig = Pick<SmtpConfig, 'host' | 'port' | 'secure' | 'auth' | 'from'> & {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default config;
|
||||
/** Resolved after `initializeSmtpConfig()` in server startup */
|
||||
export function getEmailConfig(): EmailConfig {
|
||||
const smtp = getSmtpConfig();
|
||||
return {
|
||||
enabled: smtp.enabled,
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.auth,
|
||||
from: smtp.from,
|
||||
};
|
||||
}
|
||||
|
||||
export { isSmtpEnabled };
|
||||
|
||||
/** @deprecated Prefer getEmailConfig() after startup initialization */
|
||||
const legacyConfig: EmailConfig = {
|
||||
get enabled() {
|
||||
return isSmtpEnabled();
|
||||
},
|
||||
get host() {
|
||||
return getEmailConfig().host;
|
||||
},
|
||||
get port() {
|
||||
return getEmailConfig().port;
|
||||
},
|
||||
get secure() {
|
||||
return getEmailConfig().secure;
|
||||
},
|
||||
get auth() {
|
||||
return getEmailConfig().auth;
|
||||
},
|
||||
get from() {
|
||||
return getEmailConfig().from;
|
||||
},
|
||||
};
|
||||
|
||||
export default legacyConfig;
|
||||
|
||||
159
src/common/config/slaStageCatalog.ts
Normal file
159
src/common/config/slaStageCatalog.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Canonical SLA activity catalog — `activityName` MUST match workflow `currentStage`
|
||||
* (or onboarding pipeline label) for timers to start/stop correctly.
|
||||
*
|
||||
* SRS: Re_New_Dealer_Onboard_TWO.md §9.4
|
||||
*/
|
||||
import {
|
||||
TERMINATION_STAGES,
|
||||
RESIGNATION_STAGES,
|
||||
RELOCATION_STAGES,
|
||||
CONSTITUTIONAL_STAGES,
|
||||
FNF_DEPARTMENTS
|
||||
} from './constants.js';
|
||||
|
||||
export type SlaCatalogEntry = {
|
||||
module: 'ONBOARDING' | 'RESIGNATION' | 'TERMINATION' | 'RELOCATION' | 'CONSTITUTIONAL' | 'FNF';
|
||||
activityName: string;
|
||||
ownerRole: string;
|
||||
tatHours: number;
|
||||
tatUnit: 'hours' | 'days';
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding pipeline steps that must NOT use internal SLA (§9.4).
|
||||
* - Submitted: applicant action already complete — no internal approver TAT.
|
||||
* - Questionnaire: prospect/dealer fills form — use QUESTIONNAIRE_REMINDER (email/WhatsApp), not SLA to ASM/RBM.
|
||||
*/
|
||||
export const ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES = ['Submitted', 'Questionnaire'] as const;
|
||||
|
||||
/** Legacy rows to deactivate when re-seeding after catalog corrections */
|
||||
export const ONBOARDING_SLA_DEPRECATED_ACTIVITIES = [
|
||||
...ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES,
|
||||
'ASM Review',
|
||||
'ZM Review',
|
||||
'Level 1 Interview',
|
||||
'Level 2 Interview',
|
||||
'Level 3 Interview',
|
||||
'FDD Verification',
|
||||
'Finance Verification',
|
||||
'LOA Approval'
|
||||
] as const;
|
||||
|
||||
export function shouldTrackOnboardingSla(pipelineStage: string | null | undefined): boolean {
|
||||
if (!pipelineStage) return false;
|
||||
return !ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES.includes(
|
||||
pipelineStage as (typeof ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES)[number]
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-rename `sla_configurations.activityName` values still present until re-seed */
|
||||
export const SLA_ACTIVITY_LEGACY_ALIASES: Record<string, string[]> = {
|
||||
'1st Level Interview': ['Level 1 Interview'],
|
||||
'2nd Level Interview': ['Level 2 Interview'],
|
||||
'3rd Level Interview': ['Level 3 Interview']
|
||||
};
|
||||
|
||||
/** Names to match when resolving SLA config / stopping a timer for a pipeline stage */
|
||||
export function slaConfigLookupNames(pipelineStage: string): string[] {
|
||||
return [pipelineStage, ...(SLA_ACTIVITY_LEGACY_ALIASES[pipelineStage] || [])];
|
||||
}
|
||||
|
||||
const d = (module: SlaCatalogEntry['module'], activityName: string, ownerRole: string, tatHours: number, tatUnit: 'hours' | 'days' = 'days'): SlaCatalogEntry => ({
|
||||
module,
|
||||
activityName,
|
||||
ownerRole,
|
||||
tatHours,
|
||||
tatUnit
|
||||
});
|
||||
|
||||
/**
|
||||
* Onboarding — internal approver TAT only (`PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS`).
|
||||
* Excluded: Submitted, Questionnaire (see ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES).
|
||||
*/
|
||||
const ONBOARDING_SLA: SlaCatalogEntry[] = [
|
||||
d('ONBOARDING', 'Shortlist', 'DD Admin', 3),
|
||||
d('ONBOARDING', '1st Level Interview', 'RBM, DD-ZM', 2),
|
||||
d('ONBOARDING', '2nd Level Interview', 'DD Lead, ZBH', 3),
|
||||
d('ONBOARDING', '3rd Level Interview', 'NBH, DD Head', 5),
|
||||
d('ONBOARDING', 'FDD', 'FDD', 10),
|
||||
d('ONBOARDING', 'LOI Approval', 'NBH', 5),
|
||||
d('ONBOARDING', 'Security Deposit', 'Finance', 7),
|
||||
d('ONBOARDING', 'LOI Issue', 'DD Admin', 3),
|
||||
d('ONBOARDING', 'Dealer Code Generation', 'DD Admin', 2),
|
||||
d('ONBOARDING', 'Architecture Work', 'Architecture Team', 14),
|
||||
d('ONBOARDING', 'Statutory Work', 'DD Admin, Legal', 14),
|
||||
d('ONBOARDING', 'LOA', 'NBH', 5),
|
||||
d('ONBOARDING', 'EOR Complete', 'DD Admin', 7),
|
||||
d('ONBOARDING', 'Inauguration', 'ASM', 3)
|
||||
];
|
||||
|
||||
const TERMINATION_SLA: SlaCatalogEntry[] = [
|
||||
d('TERMINATION', TERMINATION_STAGES.RBM_REVIEW, 'RBM, DD-ZM', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.ZBH_REVIEW, 'ZBH', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.DD_LEAD_REVIEW, 'DD Lead', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.LEGAL_VERIFICATION, 'Legal Admin', 7),
|
||||
d('TERMINATION', TERMINATION_STAGES.DD_HEAD_REVIEW, 'DD Head', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.NBH_EVALUATION, 'NBH', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.SCN_ISSUED, 'Legal Admin, DD Admin', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.PERSONAL_HEARING, 'DD Lead, ZBH, RBM, DD Head', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.NBH_FINAL_APPROVAL, 'NBH', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.CCO_APPROVAL, 'CCO', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.CEO_APPROVAL, 'CEO', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.LEGAL_LETTER, 'Legal Admin', 5)
|
||||
];
|
||||
|
||||
const RESIGNATION_SLA: SlaCatalogEntry[] = [
|
||||
d('RESIGNATION', RESIGNATION_STAGES.ASM, 'ASM', 2),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.RBM, 'RBM, DD-ZM', 3),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.ZBH, 'ZBH', 3),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.DD_LEAD, 'DD Lead', 5),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.DD_HEAD, 'DD Head', 5),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.NBH, 'NBH', 5),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.LEGAL, 'Legal Admin', 7),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.DD_ADMIN, 'DD Admin', 3),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.AWAITING_FNF, 'DD Lead, DD Admin', 7)
|
||||
];
|
||||
|
||||
const RELOCATION_SLA: SlaCatalogEntry[] = [
|
||||
d('RELOCATION', RELOCATION_STAGES.ASM_REVIEW, 'ASM', 2),
|
||||
d('RELOCATION', RELOCATION_STAGES.RBM_REVIEW, 'RBM', 3),
|
||||
d('RELOCATION', RELOCATION_STAGES.DD_ZM_REVIEW, 'DD-ZM', 3),
|
||||
d('RELOCATION', RELOCATION_STAGES.ZBH_REVIEW, 'ZBH', 3),
|
||||
d('RELOCATION', RELOCATION_STAGES.DD_LEAD_REVIEW, 'DD Lead', 5),
|
||||
d('RELOCATION', RELOCATION_STAGES.NBH_APPROVAL, 'NBH', 5),
|
||||
d('RELOCATION', RELOCATION_STAGES.LEGAL_CLEARANCE, 'Legal Admin', 7)
|
||||
];
|
||||
|
||||
const CONSTITUTIONAL_SLA: SlaCatalogEntry[] = [
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ASM_REVIEW, 'ASM', 2),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, 'RBM, DD-ZM', 3),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ZBH_REVIEW, 'ZBH', 3),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.LEAD_REVIEW, 'DD Lead', 5),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.HEAD_REVIEW, 'DD Head', 5),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.NBH_APPROVAL, 'NBH', 5),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.LEGAL_REVIEW, 'Legal Admin', 7)
|
||||
];
|
||||
|
||||
/** F&F departmental NOC — SRS §4 / modular_wise/04_FF_Settlement.md */
|
||||
const FNF_SLA: SlaCatalogEntry[] = FNF_DEPARTMENTS.map((dept) =>
|
||||
d('FNF', `F&F Clearance: ${dept}`, dept.includes('Finance') ? 'Finance' : 'DD Admin', 5)
|
||||
);
|
||||
|
||||
export const SLA_STAGE_CATALOG: SlaCatalogEntry[] = [
|
||||
...ONBOARDING_SLA,
|
||||
...TERMINATION_SLA,
|
||||
...RESIGNATION_SLA,
|
||||
...RELOCATION_SLA,
|
||||
...CONSTITUTIONAL_SLA,
|
||||
...FNF_SLA
|
||||
];
|
||||
|
||||
export const SLA_CATALOG_BY_MODULE = {
|
||||
ONBOARDING: ONBOARDING_SLA,
|
||||
RESIGNATION: RESIGNATION_SLA,
|
||||
TERMINATION: TERMINATION_SLA,
|
||||
RELOCATION: RELOCATION_SLA,
|
||||
CONSTITUTIONAL: CONSTITUTIONAL_SLA,
|
||||
FNF: FNF_SLA
|
||||
} as const;
|
||||
@ -48,7 +48,9 @@ const fileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallb
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
];
|
||||
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
@ -86,6 +88,18 @@ export const uploadSingleIfMultipart = (req: Request, res: Response, next: NextF
|
||||
// Multiple files upload
|
||||
export const uploadMultiple = upload.array('files', 10); // Max 10 files
|
||||
|
||||
/**
|
||||
* Only parse multipart with multiple files when the client sends multipart/form-data.
|
||||
* Otherwise JSON bodies from express.json() are preserved.
|
||||
*/
|
||||
export const uploadMultipleIfMultipart = (req: Request, res: Response, next: NextFunction) => {
|
||||
const ct = String(req.headers['content-type'] || '').toLowerCase();
|
||||
if (ct.includes('multipart/form-data')) {
|
||||
return uploadMultiple(req, res, next);
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Error handler for multer
|
||||
export const handleUploadError = (err: any, req: Request, res: Response, next: NextFunction) => {
|
||||
if (err instanceof multer.MulterError) {
|
||||
|
||||
@ -5,8 +5,21 @@ export const notificationQueue = new Queue('notificationQueue', {
|
||||
connection: redisConfig
|
||||
});
|
||||
|
||||
function buildNotificationJobId(data: {
|
||||
email?: string | null;
|
||||
templateCode?: string;
|
||||
metadata?: { entityType?: string; entityId?: string; slaDispatchKey?: string };
|
||||
}): string | undefined {
|
||||
const email = data.email?.trim().toLowerCase();
|
||||
const dispatchKey = data.metadata?.slaDispatchKey;
|
||||
if (!email || !dispatchKey) return undefined;
|
||||
return `notify-${dispatchKey}-${email}`.replace(/[^a-zA-Z0-9:_-]/g, '_').slice(0, 200);
|
||||
}
|
||||
|
||||
export const addNotificationJob = async (data: any) => {
|
||||
const jobId = buildNotificationJobId(data);
|
||||
await notificationQueue.add('sendNotification', data, {
|
||||
jobId,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
|
||||
28
src/common/queues/offboarding-lwd.queue.ts
Normal file
28
src/common/queues/offboarding-lwd.queue.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
|
||||
export const offboardingLwdQueue = new Queue('offboardingLwdQueue', {
|
||||
connection: redisConfig
|
||||
});
|
||||
|
||||
/**
|
||||
* Daily sweep (08:00) + optional per-case delayed jobs when a case enters Awaiting F&F.
|
||||
*/
|
||||
export const scheduleOffboardingLwdReminders = async () => {
|
||||
const isFastMode = process.env.DEBUG_OFFBOARDING_LWD_FAST_MODE === 'true';
|
||||
const pattern = isFastMode ? '*/15 * * * *' : '0 8 * * *';
|
||||
|
||||
const jobs = await offboardingLwdQueue.getRepeatableJobs();
|
||||
for (const job of jobs) {
|
||||
await offboardingLwdQueue.removeRepeatableByKey(job.key);
|
||||
}
|
||||
|
||||
await offboardingLwdQueue.add('checkLwdFnfReminders', {}, {
|
||||
repeat: { pattern },
|
||||
jobId: 'offboarding-lwd-fnf-reminder'
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Offboarding LWD Queue] Repeatable job scheduled: ${isFastMode ? 'Every 15 minutes (FAST MODE)' : 'Daily at 08:00'}`
|
||||
);
|
||||
};
|
||||
35
src/common/queues/offboarding-lwd.worker.ts
Normal file
35
src/common/queues/offboarding-lwd.worker.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
import { OffboardingLwdReminderService } from '../../services/OffboardingLwdReminderService.js';
|
||||
|
||||
export const offboardingLwdWorker = new Worker(
|
||||
'offboardingLwdQueue',
|
||||
async (job) => {
|
||||
console.log(`[Offboarding LWD Worker] Processing job ${job.id} (${job.name})`);
|
||||
|
||||
if (job.name === 'checkLwdFnfReminders') {
|
||||
await OffboardingLwdReminderService.processAllPendingReminders();
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name === 'sendLwdFnfReminder') {
|
||||
const payload = job.data as { requestType: 'resignation' | 'termination'; requestId: string };
|
||||
await OffboardingLwdReminderService.processOne(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[Offboarding LWD Worker] Unknown job name: ${job.name}`);
|
||||
},
|
||||
{
|
||||
connection: redisConfig,
|
||||
concurrency: 2
|
||||
}
|
||||
);
|
||||
|
||||
offboardingLwdWorker.on('completed', (job) => {
|
||||
console.log(`[Offboarding LWD Worker] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
offboardingLwdWorker.on('failed', (job, err) => {
|
||||
console.error(`[Offboarding LWD Worker] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
29
src/common/queues/questionnaire-reminder.queue.ts
Normal file
29
src/common/queues/questionnaire-reminder.queue.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
|
||||
export const questionnaireReminderQueue = new Queue('questionnaireReminderQueue', {
|
||||
connection: redisConfig
|
||||
});
|
||||
|
||||
/**
|
||||
* Scheduled prospect reminders for pending questionnaires (not internal SLA).
|
||||
* Default: daily 09:00 — or every 15 min when DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE=true.
|
||||
*/
|
||||
export const scheduleQuestionnaireReminders = async () => {
|
||||
const isFastMode = process.env.DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE === 'true';
|
||||
const pattern = isFastMode ? '*/15 * * * *' : '0 9 * * *';
|
||||
|
||||
const jobs = await questionnaireReminderQueue.getRepeatableJobs();
|
||||
for (const job of jobs) {
|
||||
await questionnaireReminderQueue.removeRepeatableByKey(job.key);
|
||||
}
|
||||
|
||||
await questionnaireReminderQueue.add('checkQuestionnaireReminders', {}, {
|
||||
repeat: { pattern },
|
||||
jobId: 'questionnaire-pending-reminder-sweep'
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Questionnaire Reminder Queue] Scheduled: ${isFastMode ? 'every 15 min (FAST MODE)' : 'daily at 09:00'}`
|
||||
);
|
||||
};
|
||||
29
src/common/queues/questionnaire-reminder.worker.ts
Normal file
29
src/common/queues/questionnaire-reminder.worker.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
import { QuestionnaireReminderService } from '../../services/QuestionnaireReminderService.js';
|
||||
|
||||
export const questionnaireReminderWorker = new Worker(
|
||||
'questionnaireReminderQueue',
|
||||
async (job) => {
|
||||
console.log(`[Questionnaire Reminder Worker] Processing job ${job.id} (${job.name})`);
|
||||
|
||||
if (job.name === 'checkQuestionnaireReminders') {
|
||||
await QuestionnaireReminderService.processScheduledReminders();
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[Questionnaire Reminder Worker] Unknown job name: ${job.name}`);
|
||||
},
|
||||
{
|
||||
connection: redisConfig,
|
||||
concurrency: 1
|
||||
}
|
||||
);
|
||||
|
||||
questionnaireReminderWorker.on('completed', (job) => {
|
||||
console.log(`[Questionnaire Reminder Worker] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
questionnaireReminderWorker.on('failed', (job, err) => {
|
||||
console.error(`[Questionnaire Reminder Worker] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
|
||||
) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
|
||||
}
|
||||
if (compact.includes('llp') && compact.includes('conversion')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION;
|
||||
}
|
||||
if (compact.includes('llp')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||
}
|
||||
if (compact.includes('partnership') && compact.includes('change')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE;
|
||||
}
|
||||
if (compact.includes('partnership')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
|
||||
}
|
||||
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP;
|
||||
}
|
||||
if (compact.includes('director')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE;
|
||||
}
|
||||
if (compact.includes('ownership') && compact.includes('transfer')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER;
|
||||
}
|
||||
if (compact.includes('company') && compact.includes('formation')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION;
|
||||
}
|
||||
const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
|
||||
return exact || null;
|
||||
}
|
||||
@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
||||
const t = String(changeType || '').trim();
|
||||
if (!t) return null;
|
||||
|
||||
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||
|
||||
const structureTargets = [
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
||||
@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
||||
];
|
||||
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t;
|
||||
|
||||
const skipAutoUpdate = [
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION
|
||||
];
|
||||
if (skipAutoUpdate.includes(t as (typeof skipAutoUpdate)[number])) return null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,45 +1,140 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type Mail from 'nodemailer/lib/mailer/index.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import db from '../../database/models/index.js';
|
||||
import handlebars from 'handlebars';
|
||||
import { registerEmailPartials, normalizeCtaPlaceholders } from './handlebars-email.js';
|
||||
import {
|
||||
getNodemailerTransportOptions,
|
||||
getSmtpConfig,
|
||||
isSmtpEnabled
|
||||
} from '../../services/smtpConfig.service.js';
|
||||
import { getFrontendBaseUrl } from './frontendUrl.js';
|
||||
|
||||
export { getFrontendBaseUrl } from './frontendUrl.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Create test account (or use env vars in production)
|
||||
let transporter: nodemailer.Transporter;
|
||||
let transporterPromise: Promise<nodemailer.Transporter>;
|
||||
type TransporterMode = 'none' | 'smtp' | 'ethereal';
|
||||
|
||||
const initTransporter = async () => {
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
let transporterMode: TransporterMode = 'none';
|
||||
let transporterPromise: Promise<nodemailer.Transporter | null> | null = null;
|
||||
|
||||
/** Call after initializeSmtpConfig() so the next send picks up GSM / .env SMTP. */
|
||||
export function resetEmailTransporter(): void {
|
||||
transporter = null;
|
||||
transporterMode = 'none';
|
||||
transporterPromise = null;
|
||||
}
|
||||
|
||||
const initTransporter = async (): Promise<nodemailer.Transporter | null> => {
|
||||
if (transporter) return transporter;
|
||||
if (transporterPromise) return transporterPromise;
|
||||
|
||||
transporterPromise = nodemailer.createTestAccount().then((account) => {
|
||||
transporterPromise = (async () => {
|
||||
if (isSmtpEnabled()) {
|
||||
const options = getNodemailerTransportOptions();
|
||||
if (options) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
secure: options.secure,
|
||||
auth: options.auth
|
||||
});
|
||||
transporterMode = 'smtp';
|
||||
console.log(
|
||||
`[Email Service] SMTP transport: ${options.host}:${options.port} (secure=${options.secure})`
|
||||
);
|
||||
return transporter;
|
||||
}
|
||||
console.warn(
|
||||
'[Email Service] ENABLE_SMTP=true but host/user/password missing — email not sent'
|
||||
);
|
||||
transporterMode = 'none';
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = await nodemailer.createTestAccount();
|
||||
transporter = nodemailer.createTransport({
|
||||
host: account.smtp.host,
|
||||
port: account.smtp.port,
|
||||
secure: account.smtp.secure,
|
||||
auth: {
|
||||
user: account.user,
|
||||
pass: account.pass,
|
||||
},
|
||||
pass: account.pass
|
||||
}
|
||||
});
|
||||
console.log('[Email Service] Test account initialized:', account.user);
|
||||
transporterMode = 'ethereal';
|
||||
console.log(
|
||||
`[Email Service] ENABLE_SMTP=false — Ethereal test transport: ${account.user}`
|
||||
);
|
||||
return transporter;
|
||||
});
|
||||
})();
|
||||
|
||||
return transporterPromise;
|
||||
};
|
||||
|
||||
// Start initialization immediately
|
||||
initTransporter().catch(err => console.error('Failed to initialize transporter:', err));
|
||||
|
||||
const { EmailTemplate } = db;
|
||||
|
||||
export const sendEmail = async (to: string, subject: string, templateCode: string, replacements: Record<string, string>) => {
|
||||
/** If DB row missing, try these codes (then filesystem under that code). */
|
||||
const TEMPLATE_CODE_FALLBACKS: Record<string, string[]> = {
|
||||
INTERVIEW_SCHEDULED_APPLICANT: ['INTERVIEW_SCHEDULED'],
|
||||
INTERVIEW_SCHEDULED_PANELIST: ['INTERVIEW_SCHEDULED'],
|
||||
INTERVIEW_RESCHEDULED_APPLICANT: ['INTERVIEW_SCHEDULED'],
|
||||
INTERVIEW_RESCHEDULED_PANELIST: ['INTERVIEW_SCHEDULED']
|
||||
};
|
||||
|
||||
function findTemplateFile(templatesRoot: string, code: string): string | null {
|
||||
const lowerFile = path.join(templatesRoot, `${code.toLowerCase()}.html`);
|
||||
const exactFile = path.join(templatesRoot, `${code}.html`);
|
||||
if (fs.existsSync(lowerFile)) return lowerFile;
|
||||
if (fs.existsSync(exactFile)) return exactFile;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveEmailTemplate(templateCode: string) {
|
||||
const codesToTry = [templateCode, ...(TEMPLATE_CODE_FALLBACKS[templateCode] || [])];
|
||||
|
||||
for (const code of codesToTry) {
|
||||
const dbTemplate = await EmailTemplate.findOne({
|
||||
where: { templateCode: code, isActive: true }
|
||||
});
|
||||
if (dbTemplate) {
|
||||
return { kind: 'db' as const, resolvedCode: code, dbTemplate };
|
||||
}
|
||||
}
|
||||
|
||||
const templatesRoot = path.join(__dirname, '../../emailtemplates');
|
||||
for (const code of codesToTry) {
|
||||
const filePath = findTemplateFile(templatesRoot, code);
|
||||
if (filePath) {
|
||||
return { kind: 'file' as const, resolvedCode: code, filePath };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface SendEmailOptions {
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
path?: string;
|
||||
content?: Buffer | string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const sendEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
templateCode: string,
|
||||
replacements: Record<string, string>,
|
||||
options?: SendEmailOptions
|
||||
) => {
|
||||
try {
|
||||
let finalHtml = '';
|
||||
let finalSubject = subject;
|
||||
@ -55,70 +150,79 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
|
||||
});
|
||||
};
|
||||
|
||||
// Try fetching from DB first (Master Configuration)
|
||||
const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } });
|
||||
const resolved = await resolveEmailTemplate(templateCode);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`Template not found: ${templateCode} (run: npm run seed:interview-templates)`
|
||||
);
|
||||
}
|
||||
|
||||
if (dbTemplate) {
|
||||
registerEmailPartials(handlebars);
|
||||
registerEmailPartials(handlebars);
|
||||
const allReplacements = normalizeCtaPlaceholders({
|
||||
...replacements,
|
||||
year: new Date().getFullYear().toString()
|
||||
});
|
||||
|
||||
const allReplacements = normalizeCtaPlaceholders({
|
||||
...replacements,
|
||||
year: new Date().getFullYear().toString()
|
||||
});
|
||||
|
||||
const subjectTemplate = handlebars.compile(dbTemplate.subject);
|
||||
if (resolved.kind === 'db') {
|
||||
const subjectTemplate = handlebars.compile(resolved.dbTemplate.subject);
|
||||
finalSubject = subjectTemplate(allReplacements);
|
||||
|
||||
const bodyTemplate = handlebars.compile(dbTemplate.body);
|
||||
const bodyTemplate = handlebars.compile(resolved.dbTemplate.body);
|
||||
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
|
||||
} else {
|
||||
registerEmailPartials(handlebars);
|
||||
const allReplacements = normalizeCtaPlaceholders({
|
||||
...replacements,
|
||||
year: new Date().getFullYear().toString()
|
||||
});
|
||||
const templatesRoot = path.join(__dirname, '../../emailtemplates');
|
||||
const lowerFile = path.join(templatesRoot, `${templateCode.toLowerCase()}.html`);
|
||||
const exactFile = path.join(templatesRoot, `${templateCode}.html`);
|
||||
const templatePath = fs.existsSync(lowerFile)
|
||||
? lowerFile
|
||||
: fs.existsSync(exactFile)
|
||||
? exactFile
|
||||
: null;
|
||||
|
||||
if (!templatePath) {
|
||||
throw new Error(`Template not found: ${templateCode}`);
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(templatePath, 'utf-8');
|
||||
const source = fs.readFileSync(resolved.filePath, 'utf-8');
|
||||
const bodyTemplate = handlebars.compile(source);
|
||||
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
|
||||
}
|
||||
|
||||
if (resolved.resolvedCode !== templateCode) {
|
||||
console.warn(
|
||||
`[Email Service] Template ${templateCode} → fallback ${resolved.resolvedCode} (${resolved.kind})`
|
||||
);
|
||||
}
|
||||
|
||||
const readyTransporter = await initTransporter();
|
||||
if (!readyTransporter) {
|
||||
console.warn('Email transporter not initialized. Using fallback mock.');
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await readyTransporter.sendMail({
|
||||
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
|
||||
const fromAddress = getSmtpConfig().from || 'Royal Enfield <noreply@royalenfield.com>';
|
||||
|
||||
const mail: Mail.Options = {
|
||||
from: fromAddress,
|
||||
to,
|
||||
subject: finalSubject,
|
||||
html: finalHtml
|
||||
});
|
||||
html: finalHtml,
|
||||
...(options?.attachments?.length ? { attachments: options.attachments } : {})
|
||||
};
|
||||
|
||||
console.log(`[Email Service] Email sent to ${to}. MessageId: ${info.messageId}`);
|
||||
console.log(`[Email Service] Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
|
||||
const info = await readyTransporter.sendMail(mail);
|
||||
|
||||
const rejected = (info as { rejected?: string[] }).rejected;
|
||||
console.log(
|
||||
`[Email Service] Sent template=${templateCode} via ${transporterMode} from="${fromAddress}" to="${to}" messageId=${info.messageId}`
|
||||
);
|
||||
if (rejected?.length) {
|
||||
console.warn(`[Email Service] SMTP rejected recipients for ${to}:`, rejected);
|
||||
}
|
||||
if (transporterMode === 'ethereal') {
|
||||
const preview = nodemailer.getTestMessageUrl(info);
|
||||
if (preview) console.log(`[Email Service] Preview URL: ${preview}`);
|
||||
}
|
||||
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error(`Failed to send email (${templateCode}):`, error);
|
||||
console.error(`Failed to send email (${templateCode}) to ${to}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendOpportunityEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||
const link = `http://localhost:5173/questionnaire/${applicationId}`;
|
||||
export const sendOpportunityEmail = async (
|
||||
to: string,
|
||||
applicantName: string,
|
||||
location: string,
|
||||
applicationId: string
|
||||
) => {
|
||||
const link = `${getFrontendBaseUrl()}/questionnaire/${applicationId}`;
|
||||
await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'OPPORTUNITY', {
|
||||
applicantName,
|
||||
location,
|
||||
@ -135,10 +239,20 @@ export const sendNonOpportunityEmail = async (to: string, applicantName: string,
|
||||
});
|
||||
};
|
||||
|
||||
export const sendInterviewScheduledEmail = async (to: string, name: string, applicationId: string, interview: any) => {
|
||||
export const sendInterviewScheduledEmail = async (
|
||||
to: string,
|
||||
name: string,
|
||||
applicationId: string,
|
||||
interview: { scheduleDate: string | Date; level: string; interviewType: string; linkOrLocation: string; status: string }
|
||||
) => {
|
||||
const date = new Date(interview.scheduleDate);
|
||||
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
const formattedDate = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const formattedDate = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
const dateTime = `${formattedDate} ${time}`;
|
||||
|
||||
await sendEmail(to, `Interview Scheduled: ${applicationId}`, 'INTERVIEW_SCHEDULED', {
|
||||
@ -152,7 +266,13 @@ export const sendInterviewScheduledEmail = async (to: string, name: string, appl
|
||||
});
|
||||
};
|
||||
|
||||
export const sendUserAssignedEmail = async (to: string, userName: string, applicationId: string, dealerName: string, participantType: string) => {
|
||||
export const sendUserAssignedEmail = async (
|
||||
to: string,
|
||||
userName: string,
|
||||
applicationId: string,
|
||||
dealerName: string,
|
||||
participantType: string
|
||||
) => {
|
||||
await sendEmail(to, `New Application Assignment: ${applicationId}`, 'USER_ASSIGNED', {
|
||||
userName,
|
||||
applicationId,
|
||||
@ -161,7 +281,12 @@ export const sendUserAssignedEmail = async (to: string, userName: string, applic
|
||||
});
|
||||
};
|
||||
|
||||
export const sendQuestionnaireAckEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||
export const sendQuestionnaireAckEmail = async (
|
||||
to: string,
|
||||
applicantName: string,
|
||||
location: string,
|
||||
applicationId: string
|
||||
) => {
|
||||
await sendEmail(to, `Questionnaire Submitted Successfully: ${applicationId}`, 'QUESTIONNAIRE_SUBMITTED', {
|
||||
applicantName,
|
||||
location,
|
||||
@ -169,8 +294,13 @@ export const sendQuestionnaireAckEmail = async (to: string, applicantName: strin
|
||||
});
|
||||
};
|
||||
|
||||
export const sendShortlistedEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||
const portalLink = 'http://localhost:5173/login';
|
||||
export const sendShortlistedEmail = async (
|
||||
to: string,
|
||||
applicantName: string,
|
||||
location: string,
|
||||
applicationId: string
|
||||
) => {
|
||||
const portalLink = `${getFrontendBaseUrl()}/login`;
|
||||
await sendEmail(to, `Congratulations! You are Shortlisted: ${applicationId}`, 'APPLICANT_SHORTLISTED', {
|
||||
applicantName,
|
||||
location,
|
||||
|
||||
28
src/common/utils/frontendUrl.ts
Normal file
28
src/common/utils/frontendUrl.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Portal base URL for emails, WhatsApp, SLA alerts, and notifications.
|
||||
* Set `FRONTEND_URL` in backend `.env` (e.g. https://dealeronboarding-uat.royalenfield.com).
|
||||
*/
|
||||
export function getFrontendBaseUrl(): string {
|
||||
return (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Portal URL for prospects / applicants — i.e. users with `Prospective Dealer` role.
|
||||
*
|
||||
* Internal pages like `/applications/:id` are admin-only and a prospect would just see
|
||||
* a forbidden page, so applicant-facing emails must always send them through their
|
||||
* own portal (`/prospective-login` → OTP → `/prospective-dashboard`).
|
||||
*
|
||||
* Pass `applicationId` to deep-link to a specific application — the login page reads
|
||||
* the `next` query parameter and routes the prospect there after OTP verification.
|
||||
*
|
||||
* Note: The route is `/prospective-login` (full word). Older code mistakenly used
|
||||
* `/prospect-login` (singular) which 404s — use this helper to avoid the typo.
|
||||
*/
|
||||
export function getProspectPortalUrl(applicationId?: string): string {
|
||||
const base = getFrontendBaseUrl();
|
||||
if (!applicationId) return `${base}/prospective-login`;
|
||||
const next = encodeURIComponent(`/prospective-dashboard/application/${applicationId}`);
|
||||
return `${base}/prospective-login?next=${next}`;
|
||||
}
|
||||
@ -61,7 +61,8 @@ export function registerEmailPartials(h: typeof handlebars = handlebars): void {
|
||||
const map: Record<string, string> = {
|
||||
email_header: 'email_header.html',
|
||||
email_footer: 'email_footer.html',
|
||||
primary_cta: 'primary_cta.html'
|
||||
primary_cta: 'primary_cta.html',
|
||||
cta_button: 'primary_cta.html'
|
||||
};
|
||||
|
||||
let loaded = 0;
|
||||
@ -98,10 +99,12 @@ export function normalizeCtaPlaceholders(replacements: Record<string, string>):
|
||||
'';
|
||||
|
||||
const ctaLabel = replacements.ctaLabel || 'View details';
|
||||
const recipientName = (replacements.recipientName || '').trim() || 'Team Member';
|
||||
|
||||
return {
|
||||
...replacements,
|
||||
ctaUrl,
|
||||
ctaLabel
|
||||
ctaLabel,
|
||||
recipientName
|
||||
};
|
||||
}
|
||||
|
||||
40
src/common/utils/offboardingLwd.ts
Normal file
40
src/common/utils/offboardingLwd.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Last Working Day (LWD) helpers for resignation & termination offboarding.
|
||||
*/
|
||||
|
||||
export const LWD_FNF_READY_REMINDER_ACTION = 'LWD_FNF_READY_REMINDER';
|
||||
|
||||
export function normalizeDateOnly(value: Date | string | null | undefined): Date | null {
|
||||
if (value == null || value === '') return null;
|
||||
const d = value instanceof Date ? new Date(value) : new Date(String(value));
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function isLwdReached(
|
||||
lwd: Date | string | null | undefined,
|
||||
today: Date = new Date()
|
||||
): boolean {
|
||||
const lwdDate = normalizeDateOnly(lwd);
|
||||
if (!lwdDate) return true;
|
||||
const t = normalizeDateOnly(today)!;
|
||||
return t >= lwdDate;
|
||||
}
|
||||
|
||||
export function formatLwdDisplay(lwd: Date | string | null | undefined): string {
|
||||
const d = normalizeDateOnly(lwd);
|
||||
if (!d) return 'N/A';
|
||||
return d.toLocaleDateString('en-IN', { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
/** Milliseconds from now until start of LWD date (00:00 local). Returns 0 if LWD is today or past. */
|
||||
export function msUntilLwdMorning(lwd: Date | string): number {
|
||||
const lwdDate = normalizeDateOnly(lwd);
|
||||
if (!lwdDate) return 0;
|
||||
const now = new Date();
|
||||
const target = new Date(lwdDate);
|
||||
target.setHours(8, 0, 0, 0); // 08:00 local — align with daily cron
|
||||
const diff = target.getTime() - now.getTime();
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
@ -22,13 +22,17 @@ export const normalizeFnFStatus = (status: string | null | undefined): string =>
|
||||
|
||||
export const getResignationStatusForStage = (stage: string): string => {
|
||||
switch (stage) {
|
||||
case RESIGNATION_STAGES.ASM:
|
||||
case RESIGNATION_STAGES.RBM:
|
||||
return RESIGNATION_STAGES.RBM; // It already contains "Review"
|
||||
case RESIGNATION_STAGES.ASM:
|
||||
case RESIGNATION_STAGES.ZBH:
|
||||
case RESIGNATION_STAGES.DD_LEAD:
|
||||
case RESIGNATION_STAGES.DD_HEAD:
|
||||
case RESIGNATION_STAGES.NBH:
|
||||
case RESIGNATION_STAGES.DD_ADMIN:
|
||||
return `${stage} Review`;
|
||||
case RESIGNATION_STAGES.AWAITING_FNF:
|
||||
return 'Awaiting F&F — manual initiation';
|
||||
case RESIGNATION_STAGES.LEGAL:
|
||||
return 'Legal - Resignation Letter';
|
||||
case RESIGNATION_STAGES.FNF_INITIATED:
|
||||
@ -55,6 +59,35 @@ export const getTerminationStatusForStage = (stage: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Legacy DB rows may still use SRS label "Personal Hearing" while workflow code keys the canonical stage constant. */
|
||||
const LEGACY_TERMINATION_STAGE_TO_CANONICAL: Record<string, string> = {
|
||||
'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING
|
||||
};
|
||||
|
||||
export const normalizeTerminationCurrentStage = (stage: string | null | undefined): string => {
|
||||
if (stage == null) return '';
|
||||
const trimmed = String(stage).trim();
|
||||
return LEGACY_TERMINATION_STAGE_TO_CANONICAL[trimmed] || trimmed;
|
||||
};
|
||||
|
||||
/** Returns column updates to align legacy termination rows with current stage/status strings (no-op if already canonical). */
|
||||
export const getLegacyTerminationRowFixes = (termination: {
|
||||
currentStage?: string | null;
|
||||
status?: string | null;
|
||||
}): Record<string, string> | null => {
|
||||
const updates: Record<string, string> = {};
|
||||
const rawStage = termination.currentStage;
|
||||
if (rawStage) {
|
||||
const canonical = normalizeTerminationCurrentStage(rawStage);
|
||||
if (canonical !== rawStage) updates.currentStage = canonical;
|
||||
}
|
||||
const st = termination.status;
|
||||
if (st && /personal hearing/i.test(st)) {
|
||||
updates.status = st.replace(/personal hearing/gi, 'SCN Response Evaluation');
|
||||
}
|
||||
return Object.keys(updates).length ? updates : null;
|
||||
};
|
||||
|
||||
export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => {
|
||||
const normalizedAmount = Math.abs(Number(amount) || 0);
|
||||
const value = (status || '').toLowerCase();
|
||||
|
||||
@ -21,7 +21,7 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
|
||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_HEAD_REVIEW,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION,
|
||||
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING,
|
||||
@ -38,10 +38,12 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
|
||||
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ASM,
|
||||
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.RBM,
|
||||
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.ZBH,
|
||||
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_LEAD,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.NBH,
|
||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN,
|
||||
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.LEGAL,
|
||||
[RESIGNATION_STAGES.DD_HEAD]: RESIGNATION_STAGES.DD_LEAD,
|
||||
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_HEAD,
|
||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.NBH,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
|
||||
[RESIGNATION_STAGES.AWAITING_FNF]: RESIGNATION_STAGES.DD_ADMIN,
|
||||
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.AWAITING_FNF,
|
||||
[RESIGNATION_STAGES.COMPLETED]: RESIGNATION_STAGES.FNF_INITIATED
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
@ -67,10 +69,9 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
|
||||
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
||||
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
[RELOCATION_STAGES.COMPLETED]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ export const ONBOARDING_STAGES = [
|
||||
{ name: '3rd Level Interview', order: 6 },
|
||||
{ name: 'FDD', order: 7 },
|
||||
{ name: 'LOI Approval', order: 8 },
|
||||
{ name: 'Security Details', order: 9 },
|
||||
{ name: 'Security Deposit', order: 9 },
|
||||
{ name: 'LOI Issue', order: 10 },
|
||||
{ name: 'Dealer Code Generation', order: 11 },
|
||||
{ name: 'Architecture Work', order: 12 },
|
||||
@ -107,8 +107,10 @@ export const PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: Record<string, string> = {
|
||||
'Level 3 Approved': '3rd Level Interview',
|
||||
'FDD Verification': 'FDD',
|
||||
'LOI In Progress': 'LOI Approval',
|
||||
'Security Details': 'Security Details',
|
||||
'Payment Pending': 'Security Details',
|
||||
/** @deprecated DB rows may still use pre-rename label until migrated */
|
||||
'Security Details': 'Security Deposit',
|
||||
'Security Deposit': 'Security Deposit',
|
||||
'Payment Pending': 'Security Deposit',
|
||||
'LOI Issued': 'LOI Issue',
|
||||
'Statutory LOI Ack': 'LOI Issue',
|
||||
'Dealer Code Generation': 'Dealer Code Generation',
|
||||
@ -154,7 +156,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
// Statuses that imply the CURRENT stage (single or both parallel) is finished
|
||||
const completionStatuses = [
|
||||
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
|
||||
'Level 2 Approved', 'Level 3 Approved',
|
||||
'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Approved',
|
||||
'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'
|
||||
];
|
||||
|
||||
@ -163,46 +165,72 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
// Fetch application to check model-driven parallel status
|
||||
const application = await db.Application.findByPk(applicationId);
|
||||
|
||||
// Robust Sync: Prepare ALL stages for batch processing
|
||||
const upsertData = [];
|
||||
for (const stage of ONBOARDING_STAGES) {
|
||||
let status: 'pending' | 'active' | 'completed' = 'pending';
|
||||
let percentage = 0;
|
||||
// Robust Sync: Prepare ALL stages for batch processing
|
||||
const upsertData: any[] = [];
|
||||
for (const stage of ONBOARDING_STAGES) {
|
||||
let status: 'pending' | 'active' | 'completed' = 'pending';
|
||||
let percentage = 0;
|
||||
|
||||
if (stage.order < currentStage.order) {
|
||||
status = 'completed';
|
||||
percentage = 100;
|
||||
} else if (stage.order === currentStage.order) {
|
||||
status = isCurrentStageFinished ? 'completed' : 'active';
|
||||
percentage = isCurrentStageFinished ? 100 : 50;
|
||||
|
||||
if (stage.name === 'Architecture Work' && application) {
|
||||
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
|
||||
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||
}
|
||||
if (stage.name === 'Statutory Work' && application) {
|
||||
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
|
||||
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||
if (stage.order < currentStage.order) {
|
||||
status = 'completed';
|
||||
percentage = 100;
|
||||
} else if (stage.order === currentStage.order) {
|
||||
status = isCurrentStageFinished ? 'completed' : 'active';
|
||||
percentage = isCurrentStageFinished ? 100 : 50;
|
||||
|
||||
if (stage.name === 'Architecture Work' && application) {
|
||||
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
|
||||
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||
}
|
||||
if (stage.name === 'Statutory Work' && application) {
|
||||
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
|
||||
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
upsertData.push({
|
||||
applicationId,
|
||||
stageName: stage.name,
|
||||
stageOrder: stage.order,
|
||||
status,
|
||||
completionPercentage: percentage,
|
||||
stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null,
|
||||
stageCompletedAt: status === 'completed' ? new Date() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
upsertData.push({
|
||||
applicationId,
|
||||
stageName: stage.name,
|
||||
stageOrder: stage.order,
|
||||
status,
|
||||
completionPercentage: percentage,
|
||||
stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null,
|
||||
stageCompletedAt: status === 'completed' ? new Date() : null
|
||||
});
|
||||
}
|
||||
|
||||
// Use bulkCreate with updateOnDuplicate to perform an efficient batch upsert
|
||||
await ApplicationProgress.bulkCreate(upsertData, {
|
||||
updateOnDuplicate: ['status', 'completionPercentage', 'stageStartedAt', 'stageCompletedAt']
|
||||
});
|
||||
// DB Duplication Prevention without Schema Changes (Healing corrupted data loops)
|
||||
const existingRecords = await ApplicationProgress.findAll({ where: { applicationId } });
|
||||
const seenStages = new Set<string>();
|
||||
|
||||
// Purge any ghost duplicates created by old logic
|
||||
for (const record of existingRecords) {
|
||||
if (seenStages.has(record.stageName)) {
|
||||
await record.destroy();
|
||||
} else {
|
||||
seenStages.add(record.stageName);
|
||||
}
|
||||
}
|
||||
|
||||
// Perform single row updates/inserts to enforce exact 1:1 mapping safely
|
||||
const cleanedRecords = await ApplicationProgress.findAll({ where: { applicationId } });
|
||||
|
||||
for (const data of upsertData) {
|
||||
const existing = cleanedRecords.find((r: any) => r.stageName === data.stageName);
|
||||
if (existing) {
|
||||
await existing.update({
|
||||
stageOrder: data.stageOrder,
|
||||
status: data.status,
|
||||
completionPercentage: data.completionPercentage,
|
||||
stageStartedAt: data.stageStartedAt || existing.stageStartedAt,
|
||||
stageCompletedAt: data.stageCompletedAt || existing.stageCompletedAt
|
||||
});
|
||||
} else {
|
||||
await ApplicationProgress.create(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
54
src/common/utils/slaBusinessTime.ts
Normal file
54
src/common/utils/slaBusinessTime.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/** SRS §9.4.5 — business hours 09:00–18:00, Mon–Fri (local server timezone). */
|
||||
|
||||
const BUSINESS_START_HOUR = 9;
|
||||
const BUSINESS_END_HOUR = 18;
|
||||
|
||||
export function isBusinessHoursEnabled(): boolean {
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') return false;
|
||||
return process.env.SLA_BUSINESS_HOURS !== 'false';
|
||||
}
|
||||
|
||||
export function businessMsBetween(start: Date, end: Date): number {
|
||||
if (end.getTime() <= start.getTime()) return 0;
|
||||
|
||||
let total = 0;
|
||||
const cursor = new Date(start);
|
||||
|
||||
while (cursor.getTime() < end.getTime()) {
|
||||
const day = cursor.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
const windowStart = new Date(cursor);
|
||||
windowStart.setHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const windowEnd = new Date(cursor);
|
||||
windowEnd.setHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
const sliceStart = Math.max(cursor.getTime(), windowStart.getTime(), start.getTime());
|
||||
const sliceEnd = Math.min(end.getTime(), windowEnd.getTime());
|
||||
if (sliceEnd > sliceStart) {
|
||||
total += sliceEnd - sliceStart;
|
||||
}
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
export function effectiveElapsedMs(
|
||||
track: { startTime: Date | string; metadata?: Record<string, unknown> | null },
|
||||
nowMs: number
|
||||
): number {
|
||||
const meta = track.metadata || {};
|
||||
const start = new Date(track.startTime).getTime();
|
||||
const pausedAt = meta.pausedAt ? new Date(String(meta.pausedAt)).getTime() : null;
|
||||
const effectiveEnd = pausedAt ? Math.min(nowMs, pausedAt) : nowMs;
|
||||
|
||||
let elapsed = isBusinessHoursEnabled()
|
||||
? businessMsBetween(new Date(start), new Date(effectiveEnd))
|
||||
: effectiveEnd - start;
|
||||
|
||||
const accumulatedPause = Number(meta.accumulatedPauseMs || 0);
|
||||
elapsed = Math.max(0, elapsed - accumulatedPause);
|
||||
return elapsed;
|
||||
}
|
||||
36
src/common/utils/slaFnfSync.ts
Normal file
36
src/common/utils/slaFnfSync.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { FNF_DEPARTMENTS } from '../config/constants.js';
|
||||
import { SLAService } from '../../services/SLAService.js';
|
||||
|
||||
export function fnfSlaStageName(department: string): string {
|
||||
return `F&F Clearance: ${department}`;
|
||||
}
|
||||
|
||||
/** Start/stop departmental F&F SLA timer (non-fatal). */
|
||||
export async function syncFnfClearanceSla(fnfId: string, department: string, status: string) {
|
||||
const stageName = fnfSlaStageName(department);
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
|
||||
try {
|
||||
if (normalized === 'pending') {
|
||||
await SLAService.startTrack({
|
||||
entityType: 'fnf',
|
||||
entityId: fnfId,
|
||||
applicationId: null,
|
||||
stageName
|
||||
});
|
||||
} else {
|
||||
await SLAService.stopTrack({
|
||||
entityType: 'fnf',
|
||||
entityId: fnfId,
|
||||
applicationId: null,
|
||||
stageName
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[slaFnfSync] clearance SLA sync failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAllPendingFnfClearanceSlas(fnfId: string) {
|
||||
await Promise.all(FNF_DEPARTMENTS.map((dept) => syncFnfClearanceSla(fnfId, dept, 'Pending')));
|
||||
}
|
||||
67
src/common/utils/slaMetrics.ts
Normal file
67
src/common/utils/slaMetrics.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { effectiveElapsedMs } from './slaBusinessTime.js';
|
||||
|
||||
export type SlaBucket = 'healthy' | 'warning' | 'critical' | 'breached';
|
||||
|
||||
export function getTatInMs(value: number, unit: string): number {
|
||||
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
|
||||
factor = factor / 60;
|
||||
}
|
||||
return value * factor;
|
||||
}
|
||||
|
||||
export function bucketFromPercent(percent: number, isBreached: boolean): SlaBucket {
|
||||
if (isBreached || percent >= 100) return 'breached';
|
||||
if (percent >= 76) return 'critical';
|
||||
if (percent >= 26) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function formatSlaDuration(ms: number): string {
|
||||
const abs = Math.abs(ms);
|
||||
const mins = Math.floor(abs / 60000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function ownerRoleMatchesUser(ownerRole: string, userRoleCode: string | undefined | null): boolean {
|
||||
if (!ownerRole || !userRoleCode) return false;
|
||||
const userNorm = userRoleCode.replace(/_/g, '-').toUpperCase();
|
||||
return ownerRole.split(',').some((part) => {
|
||||
const p = part.trim().replace(/\s+/g, '-').toUpperCase();
|
||||
return p === userNorm || userNorm.includes(p) || p.includes(userNorm);
|
||||
});
|
||||
}
|
||||
|
||||
export function computeSlaTrackView(
|
||||
track: { startTime: Date | string; isBreached?: boolean; metadata?: Record<string, unknown> | null },
|
||||
config: { tatHours: number; tatUnit: string },
|
||||
now = Date.now()
|
||||
) {
|
||||
const startMs = new Date(track.startTime).getTime();
|
||||
const tatMs = getTatInMs(config.tatHours, config.tatUnit);
|
||||
const elapsedMs = effectiveElapsedMs(track, now);
|
||||
const deadline = new Date(startMs + tatMs);
|
||||
const percentUsed = tatMs > 0 ? Math.round((elapsedMs / tatMs) * 100) : 0;
|
||||
const isBreached = Boolean(track.isBreached) || elapsedMs >= tatMs;
|
||||
const bucket = bucketFromPercent(percentUsed, isBreached);
|
||||
const msRemaining = tatMs - elapsedMs;
|
||||
const meta = track.metadata || {};
|
||||
const isPaused = Boolean(meta.pausedAt);
|
||||
|
||||
return {
|
||||
deadline,
|
||||
percentUsed,
|
||||
bucket,
|
||||
isBreached,
|
||||
isPaused,
|
||||
remainingLabel: isPaused
|
||||
? 'Paused'
|
||||
: msRemaining > 0
|
||||
? `${formatSlaDuration(msRemaining)} left`
|
||||
: `${formatSlaDuration(-msRemaining)} overdue`
|
||||
};
|
||||
}
|
||||
72
src/common/utils/slaSeedUtils.ts
Normal file
72
src/common/utils/slaSeedUtils.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { SlaCatalogEntry } from '../config/slaStageCatalog.js';
|
||||
import { ONBOARDING_SLA_DEPRECATED_ACTIVITIES } from '../config/slaStageCatalog.js';
|
||||
|
||||
/** Default reminders / escalations per SRS §9.4.5 (T-24h, T-4h; L1 +4h, L2 +12h, L3 +24h). */
|
||||
export async function applySlaConfigChildren(
|
||||
db: any,
|
||||
configId: string,
|
||||
transaction: any
|
||||
) {
|
||||
await db.SLAReminder.destroy({ where: { slaConfigId: configId }, transaction });
|
||||
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: configId }, transaction });
|
||||
|
||||
await db.SLAReminder.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: configId, timeValue: 1, timeUnit: 'days', isEnabled: true },
|
||||
{ slaConfigId: configId, timeValue: 4, timeUnit: 'hours', isEnabled: true }
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await db.SLAEscalationConfig.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: configId, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
|
||||
{ slaConfigId: configId, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
|
||||
{ slaConfigId: configId, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' }
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedSlaCatalogEntries(
|
||||
db: any,
|
||||
entries: SlaCatalogEntry[],
|
||||
transaction?: any
|
||||
) {
|
||||
for (const item of entries) {
|
||||
const [config, created] = await db.SLAConfiguration.findOrCreate({
|
||||
where: { activityName: item.activityName },
|
||||
defaults: {
|
||||
activityName: item.activityName,
|
||||
ownerRole: item.ownerRole,
|
||||
tatHours: item.tatHours,
|
||||
tatUnit: item.tatUnit,
|
||||
isActive: true
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await config.update(
|
||||
{
|
||||
ownerRole: item.ownerRole,
|
||||
tatHours: item.tatHours,
|
||||
tatUnit: item.tatUnit
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
await applySlaConfigChildren(db, config.id, transaction);
|
||||
}
|
||||
|
||||
if (ONBOARDING_SLA_DEPRECATED_ACTIVITIES.length > 0) {
|
||||
await db.SLAConfiguration.update(
|
||||
{ isActive: false },
|
||||
{
|
||||
where: { activityName: [...ONBOARDING_SLA_DEPRECATED_ACTIVITIES] },
|
||||
transaction
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/common/utils/slaWorkflowSync.ts
Normal file
35
src/common/utils/slaWorkflowSync.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { SLAService } from '../../services/SLAService.js';
|
||||
|
||||
export type SlaEntityType = 'application' | 'termination' | 'resignation' | 'relocation' | 'constitutional' | 'fnf';
|
||||
|
||||
/** Sync SLA timers when a workflow stage changes (non-fatal). */
|
||||
export async function syncSlaOnStageTransition(opts: {
|
||||
entityType: SlaEntityType;
|
||||
entityId: string;
|
||||
applicationId?: string | null;
|
||||
fromStage?: string | null;
|
||||
toStage?: string | null;
|
||||
}) {
|
||||
const { entityType, entityId, applicationId = null, fromStage, toStage } = opts;
|
||||
|
||||
try {
|
||||
if (fromStage) {
|
||||
await SLAService.stopTrack({
|
||||
entityType,
|
||||
entityId,
|
||||
applicationId,
|
||||
stageName: fromStage
|
||||
});
|
||||
}
|
||||
if (toStage) {
|
||||
await SLAService.startTrack({
|
||||
entityType,
|
||||
entityId,
|
||||
applicationId,
|
||||
stageName: toStage
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[slaWorkflowSync] SLA sync failed:', err);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
import { Server as HTTPServer } from 'http';
|
||||
import logger from './logger.js';
|
||||
import { getFrontendBaseUrl } from './frontendUrl.js';
|
||||
|
||||
let io: SocketServer | null = null;
|
||||
|
||||
@ -11,7 +12,7 @@ let io: SocketServer | null = null;
|
||||
export const initSocket = (httpServer: HTTPServer) => {
|
||||
io = new SocketServer(httpServer, {
|
||||
cors: {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
origin: getFrontendBaseUrl(),
|
||||
methods: ['GET', 'POST', 'PUT'],
|
||||
credentials: true
|
||||
}
|
||||
|
||||
61
src/common/utils/terminationJointReviewRound.util.ts
Normal file
61
src/common/utils/terminationJointReviewRound.util.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { TERMINATION_STAGES } from '../config/constants.js';
|
||||
|
||||
const norm = (s: string | undefined | null) =>
|
||||
String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => {
|
||||
const n = norm(targetStage);
|
||||
if (!n) return false;
|
||||
if (n === norm(TERMINATION_STAGES.PERSONAL_HEARING)) return true;
|
||||
if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true;
|
||||
if (n.includes('personal hearing')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => {
|
||||
const n = norm(targetStage);
|
||||
return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm'));
|
||||
};
|
||||
|
||||
function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean {
|
||||
const a = norm(action);
|
||||
return (
|
||||
a.includes('sent back') ||
|
||||
a.includes('send back') ||
|
||||
a.includes('reconsider') ||
|
||||
a.includes('reconsideration')
|
||||
);
|
||||
}
|
||||
|
||||
export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review';
|
||||
|
||||
/**
|
||||
* When a case is sent back / reconsidered to a joint stage, earlier PARTIAL_APPROVE rows must be ignored.
|
||||
* Uses workflow timeline entries (written on transition) — newest matching event wins.
|
||||
*/
|
||||
export function getJointRoundCutoffMsFromTimeline(
|
||||
timeline: unknown,
|
||||
mode: JointRoundTimelineMode
|
||||
): number | null {
|
||||
if (!Array.isArray(timeline) || timeline.length === 0) return null;
|
||||
const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage;
|
||||
const arr = timeline as any[];
|
||||
for (let i = arr.length - 1; i >= 0; i--) {
|
||||
const e = arr[i];
|
||||
if (!isSendBackOrReconsiderTimelineAction(e?.action)) continue;
|
||||
if (!matcher(e?.targetStage)) continue;
|
||||
const t = e?.timestamp != null ? new Date(e.timestamp).getTime() : NaN;
|
||||
if (!Number.isNaN(t)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Only audit rows created at/after send-back / reconsider to this joint stage count for the current round. */
|
||||
export function buildJointRoundCreatedAtFilter(cutoffMs: number | null): { createdAt?: { [Op.gte]: Date } } {
|
||||
if (cutoffMs == null) return {};
|
||||
return { createdAt: { [Op.gte]: new Date(cutoffMs) } };
|
||||
}
|
||||
@ -2,11 +2,17 @@ import db from '../../database/models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { sendEmail } from './email.service.js';
|
||||
import { NotificationService } from '../../services/NotificationService.js';
|
||||
import { REQUEST_TYPES, ROLES } from '../config/constants.js';
|
||||
import {
|
||||
APPLICATION_STAGES,
|
||||
TERMINATION_STAGES,
|
||||
CONSTITUTIONAL_STAGES,
|
||||
RELOCATION_STAGES,
|
||||
REQUEST_TYPES,
|
||||
ROLES
|
||||
} from '../config/constants.js';
|
||||
import { getFrontendBaseUrl } from './frontendUrl.js';
|
||||
|
||||
const { RequestParticipant, User, Outlet, District } = db;
|
||||
|
||||
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const { RequestParticipant, User, Outlet, District, Dealer } = db;
|
||||
|
||||
/** Dealer acknowledgement + internal reviewers after resignation is created. */
|
||||
export async function notifyResignationSubmittedEmails(resignation: any): Promise<void> {
|
||||
@ -15,7 +21,7 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis
|
||||
});
|
||||
if (!dealerUser?.email) return;
|
||||
|
||||
const base = frontendBase();
|
||||
const base = getFrontendBaseUrl();
|
||||
const resignationCode = resignation.resignationId || resignation.id;
|
||||
const lwd =
|
||||
resignation.lastOperationalDateSales ||
|
||||
@ -92,7 +98,7 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi
|
||||
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }]
|
||||
});
|
||||
|
||||
const base = frontendBase();
|
||||
const base = getFrontendBaseUrl();
|
||||
const link = `${base}/constitutional-change/${request.id}`;
|
||||
|
||||
for (const p of participants) {
|
||||
@ -121,7 +127,7 @@ export async function notifyRelocationSubmittedEmails(
|
||||
request: any,
|
||||
submitter: { email: string; fullName?: string | null }
|
||||
): Promise<void> {
|
||||
const base = frontendBase();
|
||||
const base = getFrontendBaseUrl();
|
||||
const code = request.requestId || request.id;
|
||||
const dealerName = submitter.fullName?.trim() || 'Dealer';
|
||||
|
||||
@ -134,7 +140,8 @@ export async function notifyRelocationSubmittedEmails(
|
||||
dealerName,
|
||||
requestId: code,
|
||||
link: `${base}/relocation-requests/${request.id}`,
|
||||
ctaLabel: 'View request'
|
||||
ctaLabel: 'View request',
|
||||
distance: request.distance || '0'
|
||||
}
|
||||
).catch((err) => console.error('[notifyRelocationSubmittedEmails] dealer:', err));
|
||||
}
|
||||
@ -142,7 +149,12 @@ export async function notifyRelocationSubmittedEmails(
|
||||
const outlet = await Outlet.findByPk(request.outletId, {
|
||||
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
|
||||
});
|
||||
const asmId = (outlet as any)?.district?.asmId;
|
||||
const dealerAccount = await User.findByPk(request.dealerId, {
|
||||
attributes: ['id'],
|
||||
include: [{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }]
|
||||
});
|
||||
const outletLevelAsmId = (dealerAccount as any)?.dealerProfile?.asmId ?? null;
|
||||
const asmId = outletLevelAsmId || (outlet as any)?.district?.asmId;
|
||||
if (!asmId) return;
|
||||
|
||||
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
||||
@ -157,9 +169,10 @@ export async function notifyRelocationSubmittedEmails(
|
||||
placeholders: {
|
||||
dealerName,
|
||||
requestId: code,
|
||||
outletCode: outlet?.code || '',
|
||||
outletCode: outlet?.code || 'N/A',
|
||||
link: `${base}/relocation-requests/${request.id}`,
|
||||
ctaLabel: 'Review relocation',
|
||||
ctaLabel: 'Review request',
|
||||
distance: request.distance || '0',
|
||||
phone: asmPhone || ''
|
||||
}
|
||||
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
|
||||
@ -184,7 +197,7 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
'ASM': [ROLES.ASM],
|
||||
'ASM Review': [ROLES.ASM],
|
||||
'RBM': [ROLES.RBM],
|
||||
'RBM Review': [ROLES.RBM],
|
||||
'RBM + DD-ZM Review': [ROLES.RBM, ROLES.DD_ZM],
|
||||
'ZM Review': [ROLES.DD_ZM],
|
||||
'DD ZM Review': [ROLES.DD_ZM],
|
||||
'ZBH': [ROLES.ZBH],
|
||||
@ -193,7 +206,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
'DD Lead Review': [ROLES.DD_LEAD],
|
||||
'DD Head': [ROLES.DD_HEAD],
|
||||
'DD Head Review': [ROLES.DD_HEAD],
|
||||
'DD Head Approval': [ROLES.DD_HEAD],
|
||||
'NBH': [ROLES.NBH],
|
||||
'NBH Approval': [ROLES.NBH],
|
||||
'NBH Evaluation': [ROLES.NBH],
|
||||
@ -229,20 +241,22 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
||||
|
||||
// --- Relocation/Constitutional Specific ---
|
||||
'NBH Clearance with EOR': [ROLES.NBH],
|
||||
'Submitted': [ROLES.ASM],
|
||||
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
|
||||
|
||||
// --- Resignation Specific ---
|
||||
'DD Admin': [ROLES.DD_ADMIN],
|
||||
'Awaiting F&F': [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN],
|
||||
'Spares Clearance': [ROLES.SPARES_MANAGER],
|
||||
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
||||
'Accounts Clearance': [ROLES.ACCOUNTS_MANAGER],
|
||||
'F&F Initiated': [ROLES.DD_ADMIN],
|
||||
'F&F Initiated': [ROLES.DD_ADMIN, ROLES.FINANCE, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM],
|
||||
// SRS §7.5.2 — Legal acceptance letter upload triggers notification to DD-Admin + ASM
|
||||
'Resignation Legal Closure': [ROLES.DD_ADMIN, ROLES.ASM],
|
||||
|
||||
// --- Termination Specific ---
|
||||
'Show Cause Notice': [ROLES.NBH],
|
||||
'Personal Hearing': [ROLES.NBH],
|
||||
'Show Cause Notice': [ROLES.LEGAL_ADMIN],
|
||||
'Personal Hearing': [ROLES.NBH, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD],
|
||||
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN]
|
||||
};
|
||||
|
||||
@ -304,6 +318,7 @@ export async function notifyStakeholdersOnTransition(
|
||||
action: string;
|
||||
remarks: string;
|
||||
link: string;
|
||||
changeType?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
@ -320,6 +335,8 @@ export async function notifyStakeholdersOnTransition(
|
||||
const isWithdrawn = /\b(withdraw)/i.test(targetStage) || /\b(withdraw)/i.test(metadata.action);
|
||||
const isCompleted = /\b(complete|settled|terminated|fnf)/i.test(targetStage);
|
||||
const isTerminalEvent = isRejected || isRevoked || isWithdrawn || isCompleted;
|
||||
// Interview scheduling already sends INTERVIEW_SCHEDULED_PANELIST with full details.
|
||||
const isInterviewPendingStage = /^Level \d Interview Pending$/i.test(targetStage);
|
||||
|
||||
for (const p of participants) {
|
||||
const u = (p as any).user;
|
||||
@ -329,8 +346,12 @@ export async function notifyStakeholdersOnTransition(
|
||||
const isDealer = u.id === metadata.dealerId;
|
||||
const isActingUser = u.fullName === metadata.actionUserFullName;
|
||||
|
||||
// Roles that should receive observer alerts on terminal events
|
||||
const isKeyObserverRole = ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(u.roleCode || '');
|
||||
// Roles that should receive observer alerts on terminal events or F&F triggers
|
||||
const isKeyObserverRole = [
|
||||
'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin',
|
||||
'SUPER_ADMIN', 'DD_ADMIN', 'Finance', 'FINANCE',
|
||||
'ZBH', 'RBM', 'DD-ZM'
|
||||
].includes(u.roleCode || '');
|
||||
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
|
||||
|
||||
// Phone for WhatsApp — directly on include'd user object
|
||||
@ -340,18 +361,25 @@ export async function notifyStakeholdersOnTransition(
|
||||
if (isActingUser && !isNextActor) continue;
|
||||
|
||||
if (isNextActor) {
|
||||
if (isInterviewPendingStage) {
|
||||
// Avoid duplicate mail: scheduleInterview already notifies panelists.
|
||||
continue;
|
||||
}
|
||||
// ── Next Approver: Action Required — WhatsApp + Email + In-App ──
|
||||
// SRS §6.14.3: critical workflow actions (assignment, scheduling) trigger via email & WhatsApp
|
||||
const recipientName = (u.fullName || '').trim() || 'Team Member';
|
||||
await NotificationService.notify(u.id, u.email, {
|
||||
title: `Action Required: ${metadata.code}`,
|
||||
message: `${metadata.code} has reached "${targetStage}" and requires your review.`,
|
||||
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: {
|
||||
recipientName,
|
||||
dealerName: metadata.dealerName,
|
||||
requestId: metadata.code,
|
||||
link: metadata.link,
|
||||
targetStage,
|
||||
ctaLabel: 'View application',
|
||||
phone: phone || ''
|
||||
}
|
||||
}).catch(e => console.error('[notifyStakeholders] next-actor:', e));
|
||||
@ -359,56 +387,87 @@ export async function notifyStakeholdersOnTransition(
|
||||
} else if (isSendBack && isASM) {
|
||||
// ── Send Back → notify ASM via WhatsApp + Email + In-App ──
|
||||
// SRS §6.14.3: pending user actions trigger email & WhatsApp
|
||||
const recipientName = (u.fullName || '').trim() || 'Team Member';
|
||||
await NotificationService.notify(u.id, u.email, {
|
||||
title: `Case Returned for Clarification: ${metadata.code}`,
|
||||
message: `${metadata.code} has been sent back. Please review remarks and resubmit.`,
|
||||
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
||||
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||
placeholders: {
|
||||
recipientName,
|
||||
dealerName: metadata.dealerName,
|
||||
requestId: metadata.code,
|
||||
link: metadata.link,
|
||||
targetStage,
|
||||
remarks: metadata.remarks || 'No remarks provided',
|
||||
ctaLabel: 'View application',
|
||||
phone: phone || ''
|
||||
}
|
||||
}).catch(e => console.error('[notifyStakeholders] send-back-asm:', e));
|
||||
|
||||
} else if (isDealer) {
|
||||
// ── Dealer: in-app always; email + WhatsApp only on terminal events ──
|
||||
// SRS §2052: rejection notifies dealer/applicant via email & WhatsApp
|
||||
// SRS §2324: approvals/outcomes delivered via email & WhatsApp
|
||||
const terminalChannels: Array<'system' | 'email' | 'whatsapp'> = ['system', 'email'];
|
||||
if (phone) terminalChannels.push('whatsapp');
|
||||
|
||||
let templateCode = 'WORKFLOW_STATUS_UPDATE_DEALER';
|
||||
const placeholders: any = {
|
||||
requestId: metadata.code,
|
||||
link: metadata.link,
|
||||
targetStage,
|
||||
dealerName: metadata.dealerName,
|
||||
phone: phone || ''
|
||||
};
|
||||
|
||||
// Override for Termination Final Closure
|
||||
if (targetStage === TERMINATION_STAGES.TERMINATED) {
|
||||
templateCode = 'TERMINATION_FINAL_CLOSURE_DEALER';
|
||||
placeholders.terminationDate = new Date().toLocaleDateString('en-IN', { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
// Override for Constitutional Change Completion
|
||||
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
|
||||
templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED';
|
||||
placeholders.proposedConstitution = metadata.changeType || 'Approved Structure';
|
||||
}
|
||||
|
||||
// Override for Relocation Completion
|
||||
if (targetStage === RELOCATION_STAGES.COMPLETED && requestType === REQUEST_TYPES.RELOCATION) {
|
||||
templateCode = 'RELOCATION_APPROVED';
|
||||
placeholders.newLocation = metadata.remarks || 'Approved Location'; // Remarks usually contain the site address
|
||||
}
|
||||
|
||||
await NotificationService.notify(u.id, u.email, {
|
||||
title: isTerminalEvent
|
||||
? `Application ${isRejected ? 'Rejected' : isRevoked ? 'Revoked' : 'Completed'}: ${metadata.code}`
|
||||
: `Application Update: ${metadata.code}`,
|
||||
message: `Your request is now at "${targetStage}". ${metadata.action}`,
|
||||
channels: isTerminalEvent ? terminalChannels : ['system'],
|
||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
placeholders: {
|
||||
requestId: metadata.code,
|
||||
link: metadata.link,
|
||||
targetStage,
|
||||
dealerName: metadata.dealerName,
|
||||
phone: phone || ''
|
||||
}
|
||||
templateCode,
|
||||
placeholders
|
||||
}).catch(e => console.error('[notifyStakeholders] dealer:', e));
|
||||
|
||||
} else if (isTerminalEvent && isKeyObserverRole) {
|
||||
// ── Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events — in-app only ──
|
||||
// ── Key observers (DD Lead, DD Head, NBH, DD Admin) on terminal events ──
|
||||
let templateCode = 'WORKFLOW_STATUS_UPDATE_DEALER';
|
||||
const placeholders: any = {
|
||||
requestId: metadata.code,
|
||||
link: metadata.link,
|
||||
targetStage,
|
||||
recipientName: u.fullName || 'Team'
|
||||
};
|
||||
|
||||
// Override for Internal Notification of Legal Letter
|
||||
if (targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
|
||||
templateCode = 'TERMINATION_LETTER_ISSUED';
|
||||
}
|
||||
|
||||
await NotificationService.notify(u.id, u.email, {
|
||||
title: `Case Closed: ${metadata.code}`,
|
||||
message: `${metadata.code} has been ${isRejected ? 'rejected' : isRevoked ? 'revoked' : 'completed'} at stage "${targetStage}".`,
|
||||
channels: ['system'],
|
||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
placeholders: {
|
||||
requestId: metadata.code,
|
||||
link: metadata.link,
|
||||
targetStage
|
||||
}
|
||||
channels: ['system', 'email'], // Internal teams get email too on closure
|
||||
templateCode,
|
||||
placeholders
|
||||
}).catch(e => console.error('[notifyStakeholders] observer:', e));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,33 +3,65 @@
|
||||
*/
|
||||
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
||||
'APPLICANT_SHORTLISTED',
|
||||
'APPLICANT_REJECTED',
|
||||
'ARCHITECTURAL_PLAN_REQUEST',
|
||||
'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
||||
'DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST',
|
||||
'CONSTITUTIONAL_CHANGE_APPROVED',
|
||||
'CONSTITUTIONAL_CHANGE_UPDATE',
|
||||
'DEALER_CODE_READY',
|
||||
'DOCUMENT_RECEIVED_ACKNOWLEDGEMENT',
|
||||
'DOCUMENT_REJECTED_RESUBMIT',
|
||||
'DOCUMENT_SUBMISSION_REMINDER',
|
||||
'EOR_COMPLETED',
|
||||
'FDD_DOCUMENT_REQUEST',
|
||||
'FNF_INITIATED',
|
||||
'FNF_LWD_READY',
|
||||
'FNF_SUMMARY_PREPARED',
|
||||
'FNF_SETTLEMENT_APPROVED',
|
||||
'GENERIC_NOTIFICATION',
|
||||
'INAUGURATION_COMPLETED',
|
||||
'INTERVIEW_SCHEDULED',
|
||||
'INTERVIEW_SCHEDULED_APPLICANT',
|
||||
'INTERVIEW_SCHEDULED_PANELIST',
|
||||
'INTERVIEW_RESCHEDULED_APPLICANT',
|
||||
'INTERVIEW_RESCHEDULED_PANELIST',
|
||||
'INTERVIEW_CANCELLED_APPLICANT',
|
||||
'INTERVIEW_CANCELLED_PANELIST',
|
||||
'LOA_ISSUED',
|
||||
'LOI_ACKNOWLEDGEMENT_REQUEST',
|
||||
'LOI_ISSUED',
|
||||
'NON_OPPORTUNITY',
|
||||
'ONBOARDING_PAYMENT_VERIFIED',
|
||||
'ONBOARDING_STATUS_UPDATE',
|
||||
'OPPORTUNITY',
|
||||
'PROSPECT_DOCUMENT_REQUEST',
|
||||
'QUESTIONNAIRE_REMINDER',
|
||||
'QUESTIONNAIRE_SUBMITTED',
|
||||
'SECURITY_DEPOSIT_REQUEST',
|
||||
'RELOCATION_RECEIVED',
|
||||
'RELOCATION_SUBMITTED',
|
||||
'RELOCATION_APPROVED',
|
||||
'RELOCATION_UPDATE',
|
||||
'RESIGNATION_APPROVED',
|
||||
'RESIGNATION_RECEIVED',
|
||||
'RESIGNATION_SUBMITTED',
|
||||
'RESIGNATION_UPDATE',
|
||||
'RESIGNATION_LETTER_DISPATCHED_DEALER',
|
||||
'SLA_BREACH_WARNING',
|
||||
'STATUTORY_DOCUMENT_REQUEST',
|
||||
'SLA_REMINDER',
|
||||
'SLA_BREACH',
|
||||
'SLA_ESCALATION',
|
||||
'TERMINATION_INITIATED',
|
||||
'TERMINATION_SCN_ISSUED',
|
||||
'TERMINATION_LETTER_ISSUED',
|
||||
'TERMINATION_FINAL_CLOSURE_DEALER',
|
||||
'TERMINATION_UPDATE',
|
||||
'USER_ASSIGNED',
|
||||
'WORKNOTE_NOTIFICATION'
|
||||
'WORKNOTE_NOTIFICATION',
|
||||
'WORKFLOW_ACTION_REQUIRED',
|
||||
'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||
] as const;
|
||||
|
||||
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];
|
||||
|
||||
50
src/constants/onboarding-email-defaults.ts
Normal file
50
src/constants/onboarding-email-defaults.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Default copy blocks for onboarding “planned” emails.
|
||||
* Admins can override full templates in Master → Email Templates; these are fallbacks from code.
|
||||
*/
|
||||
export const DEFAULT_PROSPECT_DOCUMENT_CHECKLIST = [
|
||||
'PAN (applicant / entity)',
|
||||
'Identity & address proof',
|
||||
'Address proof of proposed dealership site (lease / title)',
|
||||
'Net worth certificate (CA, last 2 years)',
|
||||
'Bank statements (last 6 months)',
|
||||
'Income tax returns (last 3 years)',
|
||||
'Photographs of the proposed dealership site'
|
||||
].join('\n');
|
||||
|
||||
export const DEFAULT_STATUTORY_DOCUMENT_CHECKLIST = [
|
||||
'GST registration certificate',
|
||||
'Trade licence / Shop & Establishment',
|
||||
'Fire safety NOC',
|
||||
'Pollution control board NOC (if applicable)',
|
||||
'Approved building plan from local authority',
|
||||
'Commercial electricity sanction',
|
||||
'Registered lease / sale deed for premises',
|
||||
'Latest property tax receipt',
|
||||
'Building & public liability insurance (as per policy)',
|
||||
'MOA/AOA or partnership deed (as applicable)',
|
||||
'Board resolution / authorised signatory list (if applicable)'
|
||||
].join('\n');
|
||||
|
||||
export const DEFAULT_FDD_DOCUMENT_CHECKLIST = [
|
||||
'Audited financial statements (last 3 financial years)',
|
||||
'GST returns (last 12 months)',
|
||||
'Bank statements (last 12 months)',
|
||||
'Proof of working capital',
|
||||
'Schedule of existing loans and obligations',
|
||||
'Director / partner net-worth statements (as applicable)'
|
||||
].join('\n');
|
||||
|
||||
export const DEFAULT_ARCHITECTURE_SITE_INPUTS = [
|
||||
'Site dimensions and orientation (sketch or CAD if available)',
|
||||
'Photographs: front, sides, internal, and surroundings',
|
||||
'Civic / municipal approvals and zoning classification',
|
||||
'Soil-test report (if available)',
|
||||
'Preferred entry, parking, and signage locations'
|
||||
].join('\n');
|
||||
|
||||
export function formatDueDateDaysFromNow(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toLocaleDateString('en-IN', { dateStyle: 'medium' });
|
||||
}
|
||||
127
src/database/models/activity/SystemAuditLog.ts
Normal file
127
src/database/models/activity/SystemAuditLog.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
/**
|
||||
* SystemAuditLog
|
||||
* ----------------
|
||||
* Dedicated, segregated audit trail for *system-level / configuration* changes
|
||||
* (questionnaire versions, interview configs, master data — zones / regions /
|
||||
* districts, SLA configs, system configs, role assignments, hierarchy syncs).
|
||||
*
|
||||
* Application-lifecycle audit (per-application stage transitions, documents,
|
||||
* interviews) continues to live in `audit_logs`. Module-specific tables
|
||||
* (`resignation_audit_logs`, `termination_audit_logs`, `fnf_audit_logs`,
|
||||
* `relocation_audit_logs`, `constitutional_audit_logs`) remain unchanged.
|
||||
*/
|
||||
export interface SystemAuditLogAttributes {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
actorName: string | null;
|
||||
actorRole: string | null;
|
||||
module: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
entityLabel: string | null;
|
||||
action: string;
|
||||
description: string | null;
|
||||
oldData: any | null;
|
||||
newData: any | null;
|
||||
metadata: any | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export interface SystemAuditLogInstance
|
||||
extends Model<SystemAuditLogAttributes>,
|
||||
SystemAuditLogAttributes { }
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
const SystemAuditLog = sequelize.define<SystemAuditLogInstance>(
|
||||
'SystemAuditLog',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model: 'users', key: 'id' },
|
||||
onDelete: 'SET NULL'
|
||||
},
|
||||
actorName: {
|
||||
type: DataTypes.STRING(150),
|
||||
allowNull: true
|
||||
},
|
||||
actorRole: {
|
||||
type: DataTypes.STRING(80),
|
||||
allowNull: true
|
||||
},
|
||||
module: {
|
||||
type: DataTypes.STRING(60),
|
||||
allowNull: false
|
||||
},
|
||||
entityType: {
|
||||
type: DataTypes.STRING(80),
|
||||
allowNull: false
|
||||
},
|
||||
entityId: {
|
||||
type: DataTypes.STRING(80),
|
||||
allowNull: true
|
||||
},
|
||||
entityLabel: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
action: {
|
||||
type: DataTypes.STRING(60),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
oldData: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true
|
||||
},
|
||||
newData: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true
|
||||
},
|
||||
ipAddress: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true
|
||||
},
|
||||
userAgent: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'system_audit_logs',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['module'] },
|
||||
{ fields: ['entityType'] },
|
||||
{ fields: ['entityId'] },
|
||||
{ fields: ['action'] },
|
||||
{ fields: ['userId'] },
|
||||
{ fields: ['createdAt'] }
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
(SystemAuditLog as any).associate = (models: any) => {
|
||||
SystemAuditLog.belongsTo(models.User, {
|
||||
foreignKey: 'userId',
|
||||
as: 'user'
|
||||
});
|
||||
};
|
||||
|
||||
return SystemAuditLog;
|
||||
};
|
||||
@ -5,8 +5,6 @@ export interface DocumentAttributes {
|
||||
id: string;
|
||||
applicationId: string | null;
|
||||
dealerId: string | null;
|
||||
requestId: string | null; // Compatibility
|
||||
requestType: string | null; // Compatibility
|
||||
documentType: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
@ -42,14 +40,6 @@ export default (sequelize: Sequelize) => {
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
requestType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
documentType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
@ -92,7 +82,8 @@ export default (sequelize: Sequelize) => {
|
||||
indexes: [
|
||||
{ fields: ['applicationId'] },
|
||||
{ fields: ['dealerId'] },
|
||||
{ fields: ['requestId'] }
|
||||
{ fields: ['applicationId', 'stage'] },
|
||||
{ fields: ['documentType'] }
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
114
src/database/models/compliance/SLANotificationDispatch.ts
Normal file
114
src/database/models/compliance/SLANotificationDispatch.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
export type SlaNotificationDispatchType =
|
||||
| 'pre_breach_reminder'
|
||||
| 'breach'
|
||||
| 'escalation'
|
||||
| 'repeat_overdue';
|
||||
|
||||
export interface SLANotificationDispatchAttributes {
|
||||
id: string;
|
||||
trackingId: string;
|
||||
/** Unique per tracking row — e.g. reminder:uuid, breach, escalation:1 */
|
||||
thresholdKey: string;
|
||||
dispatchType: SlaNotificationDispatchType;
|
||||
templateCode: string | null;
|
||||
stageName: string | null;
|
||||
reminderId: string | null;
|
||||
escalationLevel: number | null;
|
||||
recipientCount: number;
|
||||
sentAt: Date;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SLANotificationDispatchInstance
|
||||
extends Model<SLANotificationDispatchAttributes>,
|
||||
SLANotificationDispatchAttributes {}
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
const SLANotificationDispatch = sequelize.define<SLANotificationDispatchInstance>(
|
||||
'SLANotificationDispatch',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
trackingId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'sla_tracking',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
thresholdKey: {
|
||||
type: DataTypes.STRING(128),
|
||||
allowNull: false
|
||||
},
|
||||
dispatchType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false
|
||||
},
|
||||
templateCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true
|
||||
},
|
||||
stageName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
reminderId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
escalationLevel: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
recipientCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
sentAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: false,
|
||||
defaultValue: 'sent'
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sla_notification_dispatches',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
name: 'sla_notification_dispatches_tracking_threshold_uq',
|
||||
fields: ['trackingId', 'thresholdKey']
|
||||
},
|
||||
{
|
||||
name: 'sla_notification_dispatches_tracking_sent_idx',
|
||||
fields: ['trackingId', 'sentAt']
|
||||
},
|
||||
{
|
||||
name: 'sla_notification_dispatches_type_idx',
|
||||
fields: ['dispatchType']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
(SLANotificationDispatch as any).associate = (models: any) => {
|
||||
SLANotificationDispatch.belongsTo(models.SLATracking, {
|
||||
foreignKey: 'trackingId',
|
||||
as: 'slaTracking'
|
||||
});
|
||||
};
|
||||
|
||||
return SLANotificationDispatch;
|
||||
};
|
||||
@ -11,6 +11,7 @@ export interface SLATrackingAttributes {
|
||||
duration: number | null; // minutes or hours
|
||||
isBreached: boolean;
|
||||
isActive: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SLATrackingInstance extends Model<SLATrackingAttributes>, SLATrackingAttributes { }
|
||||
@ -61,6 +62,11 @@ export default (sequelize: Sequelize) => {
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: {}
|
||||
}
|
||||
}, {
|
||||
tableName: 'sla_tracking',
|
||||
@ -70,6 +76,12 @@ export default (sequelize: Sequelize) => {
|
||||
(SLATracking as any).associate = (models: any) => {
|
||||
SLATracking.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' });
|
||||
SLATracking.hasMany(models.SLABreach, { foreignKey: 'trackingId', as: 'breaches' });
|
||||
if (models.SLANotificationDispatch) {
|
||||
SLATracking.hasMany(models.SLANotificationDispatch, {
|
||||
foreignKey: 'trackingId',
|
||||
as: 'notificationDispatches'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return SLATracking;
|
||||
|
||||
@ -94,6 +94,7 @@ import createSLABreach from './compliance/SLABreach.js';
|
||||
import createSLAEscalationConfig from './compliance/SLAEscalationConfig.js';
|
||||
import createSLAReminder from './compliance/SLAReminder.js';
|
||||
import createSLATracking from './compliance/SLATracking.js';
|
||||
import createSLANotificationDispatch from './compliance/SLANotificationDispatch.js';
|
||||
import createWorkflowStageConfig from './compliance/WorkflowStageConfig.js';
|
||||
import createStageApprovalAction from './compliance/StageApprovalAction.js';
|
||||
import createStageApprovalPolicy from './compliance/StageApprovalPolicy.js';
|
||||
@ -102,6 +103,7 @@ import createRequestParticipant from './compliance/RequestParticipant.js';
|
||||
|
||||
// Activity
|
||||
import createAuditLog from './activity/AuditLog.js';
|
||||
import createSystemAuditLog from './activity/SystemAuditLog.js';
|
||||
import createWorknote from './activity/Worknote.js';
|
||||
import createWorkNoteAttachment from './activity/WorkNoteAttachment.js';
|
||||
import createWorkNoteTag from './activity/WorkNoteTag.js';
|
||||
@ -136,6 +138,7 @@ db.Outlet = createOutlet(sequelize);
|
||||
db.Worknote = createWorknote(sequelize);
|
||||
db.OnboardingDocument = createOnboardingDocument(sequelize);
|
||||
db.AuditLog = createAuditLog(sequelize);
|
||||
db.SystemAuditLog = createSystemAuditLog(sequelize);
|
||||
db.FinancePayment = createFinancePayment(sequelize);
|
||||
db.RelocationDocument = createRelocationDocument(sequelize);
|
||||
db.ResignationDocument = createResignationDocument(sequelize);
|
||||
@ -225,6 +228,7 @@ db.PushSubscription = createPushSubscription(sequelize);
|
||||
// Batch 8: SLA & TAT Tracking
|
||||
db.SLATracking = createSLATracking(sequelize);
|
||||
db.SLABreach = createSLABreach(sequelize);
|
||||
db.SLANotificationDispatch = createSLANotificationDispatch(sequelize);
|
||||
db.StageApprovalPolicy = createStageApprovalPolicy(sequelize);
|
||||
db.StageApprovalAction = createStageApprovalAction(sequelize);
|
||||
db.SystemConfiguration = createSystemConfiguration(sequelize);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { Model, DataTypes, Sequelize, Op } from 'sequelize';
|
||||
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
|
||||
|
||||
export interface ConstitutionalChangeAttributes {
|
||||
@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => {
|
||||
{ fields: ['requestId'] },
|
||||
{ fields: ['outletId'] },
|
||||
{ fields: ['dealerId'] },
|
||||
{ fields: ['currentStage'] }
|
||||
{ fields: ['currentStage'] },
|
||||
/** SRS §12.2 — at most one non-terminal request per dealer (PostgreSQL partial unique index). */
|
||||
{
|
||||
name: 'uq_constitutional_open_per_dealer',
|
||||
unique: true,
|
||||
fields: ['dealerId'],
|
||||
where: {
|
||||
status: {
|
||||
[Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
34
src/emailtemplates/applicant_rejected.html
Normal file
34
src/emailtemplates/applicant_rejected.html
Normal file
@ -0,0 +1,34 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Application Rejected — {{applicationId}}</h2>
|
||||
<p>Dear {{applicantName}},</p>
|
||||
<p>
|
||||
We regret to inform you that your Royal Enfield Dealership Application
|
||||
(<strong>{{applicationId}}</strong>) for location <strong>{{location}}</strong>
|
||||
has been <strong>rejected</strong> after careful evaluation.
|
||||
</p>
|
||||
|
||||
{{#if rejectionReason}}
|
||||
<div class="highlight-box" style="background:#fff3f3; border-left:4px solid #e53935;">
|
||||
<strong>Reason for Rejection:</strong><br />
|
||||
{{rejectionReason}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p>
|
||||
We appreciate your interest in partnering with Royal Enfield.
|
||||
You may reapply in the future when opportunities are available in your area.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For any queries, please contact your local RE representative or reach us at
|
||||
<a href="mailto:dealer-support@royalenfield.com">dealer-support@royalenfield.com</a>.
|
||||
</p>
|
||||
|
||||
<p>We wish you all the best in your endeavours.</p>
|
||||
|
||||
<p>Best Regards,<br /><strong>Royal Enfield Dealer Development Team</strong></p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
13
src/emailtemplates/architectural_plan_request.html
Normal file
13
src/emailtemplates/architectural_plan_request.html
Normal file
@ -0,0 +1,13 @@
|
||||
{{> email_header}}
|
||||
<h2>Hi {{applicantName}},</h2>
|
||||
<p>Our architecture team (coordinated by <strong>{{architectName}}</strong>) will begin the dealership layout and site design work for application <strong>{{applicationId}}</strong>.</p>
|
||||
<p>Please provide the site inputs listed on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
|
||||
{{#if inputsList}}
|
||||
<div class="details">
|
||||
<p style="white-space:pre-line;">{{inputsList}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{> primary_cta}}
|
||||
<p>Timely submission helps us meet your showroom and workshop readiness timelines.</p>
|
||||
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||
{{> email_footer}}
|
||||
31
src/emailtemplates/constitutional_change_approved.html
Normal file
31
src/emailtemplates/constitutional_change_approved.html
Normal file
@ -0,0 +1,31 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2 style="color:#2e7d32;">Constitutional Change Approved — {{requestId}}</h2>
|
||||
<p>Dear {{dealerName}},</p>
|
||||
<p>
|
||||
We are pleased to inform you that your request for a **Change in Constitution** (Request ID: <strong>{{requestId}}</strong>)
|
||||
has been officially approved by the Royal Enfield management.
|
||||
</p>
|
||||
|
||||
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
|
||||
<strong>New Constitution:</strong> {{proposedConstitution}}<br/>
|
||||
<strong>Status:</strong> Approved & Updated
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The system records have been updated to reflect this change. You can now proceed with the legally compliant transition
|
||||
as per the approved structure.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
<a href="{{link}}" class="btn">View Request Details</a>
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
Best Regards,<br/>
|
||||
<strong>Royal Enfield Dealer Development Team</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
@ -0,0 +1,8 @@
|
||||
{{> email_header}}
|
||||
<h2>Congratulations {{applicantName}},</h2>
|
||||
<p>Your <strong>Letter of Appointment</strong> is in place for application <strong>{{applicationId}}</strong>{{#if dealerCode}} (Dealer code: <strong>{{dealerCode}}</strong>){{/if}}.</p>
|
||||
<p>The <strong>Dealership Agreement</strong> is now ready for your review and e-signature on the Dealer Portal. Please complete your signature by <strong>{{dueDate}}</strong>.</p>
|
||||
{{> primary_cta}}
|
||||
<p>Once you sign, the agreement will be routed for Royal Enfield’s counter-signature as per process.</p>
|
||||
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||
{{> email_footer}}
|
||||
10
src/emailtemplates/document_received_acknowledgement.html
Normal file
10
src/emailtemplates/document_received_acknowledgement.html
Normal file
@ -0,0 +1,10 @@
|
||||
{{> email_header}}
|
||||
<h2>Hi {{applicantName}},</h2>
|
||||
<p>Thank you. We have received your upload for application <strong>{{applicationId}}</strong>.</p>
|
||||
<p><strong>Category:</strong> {{documentCategory}}<br>
|
||||
<strong>Document type:</strong> {{documentName}}<br>
|
||||
<strong>Received on:</strong> {{receivedOn}}</p>
|
||||
<p>Our team will verify the submission. You will receive a separate notification if anything needs to be corrected or re-uploaded.</p>
|
||||
{{> primary_cta}}
|
||||
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||
{{> email_footer}}
|
||||
12
src/emailtemplates/document_rejected_resubmit.html
Normal file
12
src/emailtemplates/document_rejected_resubmit.html
Normal file
@ -0,0 +1,12 @@
|
||||
{{> email_header}}
|
||||
<h2>Hi {{applicantName}},</h2>
|
||||
<p>One or more documents for application <strong>{{applicationId}}</strong> could not be accepted in their current form.</p>
|
||||
<div class="details">
|
||||
<p><strong>Document:</strong> {{documentName}}</p>
|
||||
<p><strong>Reason:</strong> {{rejectionReason}}</p>
|
||||
</div>
|
||||
<p>Please re-upload the corrected document on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
|
||||
{{> primary_cta}}
|
||||
<p>For clarification, contact your assigned Dealer Development representative.</p>
|
||||
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||
{{> email_footer}}
|
||||
12
src/emailtemplates/document_submission_reminder.html
Normal file
12
src/emailtemplates/document_submission_reminder.html
Normal file
@ -0,0 +1,12 @@
|
||||
{{> email_header}}
|
||||
<h2>Hi {{applicantName}},</h2>
|
||||
<p>This is a reminder regarding your dealership application <strong>{{applicationId}}</strong>.</p>
|
||||
<p>The following items are still pending on the portal:</p>
|
||||
<div class="details">
|
||||
<p style="white-space:pre-line;">{{pendingDocuments}}</p>
|
||||
</div>
|
||||
<p>Please complete your uploads by <strong>{{dueDate}}</strong> so we can continue processing without delay.</p>
|
||||
{{> primary_cta}}
|
||||
<p>If you have already submitted everything, you may ignore this reminder.</p>
|
||||
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||
{{> email_footer}}
|
||||
38
src/emailtemplates/eor_completed.html
Normal file
38
src/emailtemplates/eor_completed.html
Normal file
@ -0,0 +1,38 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>EOR Checklist Complete — {{requestId}}</h2>
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
All <strong>Essential Operating Requirements (EOR)</strong> for dealer application
|
||||
<strong>{{requestId}}</strong> (Applicant: <strong>{{applicantName}}</strong>) have been
|
||||
marked as completed and verified.
|
||||
</p>
|
||||
|
||||
<div class="highlight-box" style="background:#e8f5e9; border-left:4px solid #43a047;">
|
||||
<strong>EOR Status: 100% Complete</strong><br />
|
||||
The dealership outlet is now ready for Inauguration review.
|
||||
</div>
|
||||
|
||||
<table class="detail-table">
|
||||
<tr><td><strong>Application ID</strong></td><td>{{requestId}}</td></tr>
|
||||
<tr><td><strong>Applicant Name</strong></td><td>{{applicantName}}</td></tr>
|
||||
<tr><td><strong>Location</strong></td><td>{{location}}</td></tr>
|
||||
<tr><td><strong>Completed On</strong></td><td>{{completedOn}}</td></tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
Please review the EOR checklist and, if all criteria are met, authorize the
|
||||
<strong>Inauguration</strong> stage to mark this dealership as live.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This is an automated alert from the Royal Enfield Dealer Onboarding System.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
15
src/emailtemplates/fdd_document_request.html
Normal file
15
src/emailtemplates/fdd_document_request.html
Normal file
@ -0,0 +1,15 @@
|
||||
{{> email_header}}
|
||||
<h2>Hi {{applicantName}},</h2>
|
||||
<p>As part of <strong>Financial Due Diligence (FDD)</strong> for your dealership application <strong>{{applicationId}}</strong>, we have engaged <strong>{{fddPartnerName}}</strong> to complete the assessment.</p>
|
||||
<p>Please upload the documents requested on the Dealer Portal by <strong>{{dueDate}}</strong>.</p>
|
||||
{{#if documentChecklist}}
|
||||
<div class="details">
|
||||
<p style="white-space:pre-line;">{{documentChecklist}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<p>Typical requirements include audited financials, GST returns, bank statements, working-capital proof, and details of existing obligations—refer to the portal for your exact checklist.</p>
|
||||
{{/if}}
|
||||
{{> primary_cta}}
|
||||
<p>The FDD partner may contact you directly for clarifications. Incomplete disclosure may affect the outcome of your application.</p>
|
||||
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||
{{> email_footer}}
|
||||
35
src/emailtemplates/fnf_initiated.html
Normal file
35
src/emailtemplates/fnf_initiated.html
Normal file
@ -0,0 +1,35 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>Full & Final Settlement Initiated — {{requestId}}</h2>
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
The Full & Final (F&F) settlement process has been initiated for dealer
|
||||
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>)
|
||||
effective from the Last Working Day.
|
||||
</p>
|
||||
|
||||
<table class="detail-table">
|
||||
<tr><td><strong>Request ID</strong></td><td>{{requestId}}</td></tr>
|
||||
<tr><td><strong>Dealer Name</strong></td><td>{{dealerName}}</td></tr>
|
||||
<tr><td><strong>Initiated By</strong></td><td>{{initiatedBy}}</td></tr>
|
||||
<tr><td><strong>Last Working Day</strong></td><td>{{lwd}}</td></tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
All department clearances (NOC, Payables, Receivables, etc.) must be submitted
|
||||
within the stipulated timeline. Please log in and update your department's
|
||||
clearance status.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
This is a system-generated notification. F&F settlement can only be
|
||||
initiated on or after the Last Working Day as per Royal Enfield policy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
29
src/emailtemplates/fnf_settlement_approved.html
Normal file
29
src/emailtemplates/fnf_settlement_approved.html
Normal file
@ -0,0 +1,29 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2 style="color:#2e7d32;">F&F Settlement Approved — {{fnfId}}</h2>
|
||||
<p>Dear Team,</p>
|
||||
<p>
|
||||
The final Full & Final (F&F) settlement for dealer <strong>{{dealerName}}</strong>
|
||||
(F&F ID: <strong>{{fnfId}}</strong>) has been <strong>Approved</strong> by Finance.
|
||||
</p>
|
||||
|
||||
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
|
||||
<strong>Settlement Amount:</strong> ₹{{settlementAmount}}<br/>
|
||||
<strong>Status:</strong> Approved & Closed
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The DD-Admin and Legal teams are requested to update their records and proceed with final account closure.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
All financial transactions related to this dealership exit are now finalized.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
29
src/emailtemplates/fnf_summary_prepared.html
Normal file
29
src/emailtemplates/fnf_summary_prepared.html
Normal file
@ -0,0 +1,29 @@
|
||||
{{> email_header}}
|
||||
|
||||
<div class="section">
|
||||
<h2>F&F Settlement Summary Prepared — {{fnfId}}</h2>
|
||||
<p>Dear Finance Team,</p>
|
||||
<p>
|
||||
The initial Full & Final (F&F) settlement summary has been prepared for
|
||||
<strong>{{dealerName}}</strong> (F&F ID: <strong>{{fnfId}}</strong>).
|
||||
</p>
|
||||
|
||||
<div style="background:#f5f5f5; border-left:4px solid #333; padding:12px 16px; margin: 16px 0;">
|
||||
<strong>Calculated Net Amount:</strong> ₹{{netAmount}}<br/>
|
||||
<strong>Status:</strong> Pending Final Approval
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Please review the consolidated departmental responses and the settlement summary to proceed with final approval and payment processing.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center; margin: 24px 0;">
|
||||
{{> cta_button}}
|
||||
</div>
|
||||
|
||||
<p style="color:#888; font-size:12px;">
|
||||
Confidential: This summary contains sensitive financial data. Review only via authorized portal access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{> email_footer}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user