Compare commits

...

7 Commits

79 changed files with 13211 additions and 916 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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-C_7C7ZNJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-COwSK6pX.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View 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 | | | |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,696 @@
# 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 dealerships 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 dealers resignation request and supporting letter.
- Uploads the **resignation email** and **dealers 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 **NBH** for final decision.
- The resignation request is reviewed by the **DD-Lead and DD-Head**. At this stage, both
roles are 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 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.7 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.8 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 dealers 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 dealers 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
dealers 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 dealers 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 dealers 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.

View 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 Legals 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 dealers SCN response.
- Uploads internal comments, Legal feedback, and recommendation for NBHs 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 dealerships 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 REs **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 dealerships 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 REs 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 Enfields 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 dealers 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 dealers 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 dealers 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
reviewers 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 dealers 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 NBHs 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
```

File diff suppressed because it is too large Load Diff

View 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 dealers 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
---

View 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 Notesbased 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 dealers 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 Enfields 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

View 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();

View File

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

View File

@ -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
*/

View File

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

View File

@ -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] },

View File

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

View 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)');
});
});

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

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

View 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');
});
});

View 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.8)', () => {
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');
});
});

View 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']);
});
});

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

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

View File

@ -176,14 +176,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,7 +195,7 @@ 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',
NBH: 'NBH',
@ -223,13 +223,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 +265,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;
@ -493,13 +486,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',
@ -523,7 +518,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 +552,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 +562,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;

View File

@ -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)) {

View File

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

View File

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

View File

@ -22,8 +22,9 @@ 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.NBH:
@ -55,6 +56,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();

View File

@ -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,
@ -67,10 +67,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;
}

View File

@ -154,7 +154,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 +163,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.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 === '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
});
}
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;
// 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);
}
}
}
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
});
}
// Perform single row updates/inserts to enforce exact 1:1 mapping safely
const cleanedRecords = await ApplicationProgress.findAll({ where: { applicationId } });
// Use bulkCreate with updateOnDuplicate to perform an efficient batch upsert
await ApplicationProgress.bulkCreate(upsertData, {
updateOnDuplicate: ['status', 'completionPercentage', 'stageStartedAt', 'stageCompletedAt']
});
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);
}
}
}
}
};

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

View File

@ -2,9 +2,16 @@ 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';
const { RequestParticipant, User, Outlet, District } = db;
const { RequestParticipant, User, Outlet, District, Dealer } = db;
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
@ -134,7 +141,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 +150,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 +170,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 +198,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 +207,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,7 +242,6 @@ 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],
@ -238,11 +250,13 @@ export async function resolveNextActors(requestId: string, requestType: string,
'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 {
@ -329,8 +344,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
@ -376,39 +395,67 @@ export async function notifyStakeholdersOnTransition(
} 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));
}
}

View File

@ -3,20 +3,35 @@
*/
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'APPLICANT_SHORTLISTED',
'APPLICANT_REJECTED',
'CONSTITUTIONAL_CHANGE_SUBMITTED',
'CONSTITUTIONAL_CHANGE_APPROVED',
'CONSTITUTIONAL_CHANGE_UPDATE',
'DEALER_CODE_READY',
'EOR_COMPLETED',
'FNF_INITIATED',
'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_ISSUED',
'NON_OPPORTUNITY',
'ONBOARDING_PAYMENT_VERIFIED',
'ONBOARDING_STATUS_UPDATE',
'OPPORTUNITY',
'QUESTIONNAIRE_REMINDER',
'QUESTIONNAIRE_SUBMITTED',
'RELOCATION_RECEIVED',
'RELOCATION_SUBMITTED',
'RELOCATION_APPROVED',
'RELOCATION_UPDATE',
'RESIGNATION_APPROVED',
'RESIGNATION_RECEIVED',
@ -26,10 +41,15 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'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];

View File

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

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

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

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

View File

@ -0,0 +1,35 @@
{{> email_header}}
<div class="section">
<h2>Full & Final Settlement Initiated — {{requestId}}</h2>
<p>Dear {{recipientName}},</p>
<p>
The Full &amp; Final (F&amp;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&amp;F settlement can only be
initiated on or after the Last Working Day as per Royal Enfield policy.
</p>
</div>
{{> email_footer}}

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

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

View File

@ -0,0 +1,22 @@
{{> email_header}}
<div class="section">
<h2 style="color:#2e7d32;">Dealership Inauguration Logged — {{applicationId}}</h2>
<p>Dear Team,</p>
<p>
We are pleased to inform you that the inauguration for dealer
<strong>{{dealerName}}</strong> (Application ID: <strong>{{applicationId}}</strong>)
at <strong>{{location}}</strong> has been successfully logged on <strong>{{date}}</strong>.
</p>
<p>
The dealership is now marked as <strong>Active</strong> in the system. All relevant teams are requested to
update their records accordingly.
</p>
<p style="color:#888; font-size:12px;">
This is an automated notification confirming the completion of the onboarding lifecycle.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,29 @@
{{> email_header}}
<div class="section">
<h2 style="color:#2e7d32;">Payment Verified — {{applicationId}}</h2>
<p>Dear Team,</p>
<p>
Finance has successfully verified the <strong>{{paymentType}}</strong> for
<strong>{{dealerName}}</strong> (Application ID: <strong>{{applicationId}}</strong>).
</p>
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
<strong>Verified Amount:</strong> ₹{{amount}}<br/>
<strong>Status:</strong> Verified & Approved
</div>
<p>
The onboarding process can now proceed to the next stage.
</p>
<div style="text-align:center; margin: 24px 0;">
<a href="{{link}}" class="btn">View Application</a>
</div>
<p style="color:#888; font-size:12px;">
This is an automated notification from the Royal Enfield Dealer Onboarding System.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,31 @@
{{> email_header}}
<div class="section">
<h2 style="color:#2e7d32;">Relocation Approved — {{requestId}}</h2>
<p>Dear {{dealerName}},</p>
<p>
We are pleased to inform you that your request for **Dealership Relocation** (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 Location:</strong> {{newLocation}}<br/>
<strong>Status:</strong> Approved & Records Updated
</div>
<p>
Our team will coordinate with you for the physical transition and site readiness audit. You can track the next steps
via the portal.
</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}}

View File

@ -1,6 +1,33 @@
{{> email_header}}
<h2>Hi {{dealerName}},</h2>
<p>Your outlet relocation request <strong>{{requestId}}</strong> has been received.</p>
<p>You will receive email updates as the request moves through approvals.</p>
{{> primary_cta}}
<div class="section">
<h2>Relocation Request Received — {{requestId}}</h2>
<p>Dear {{dealerName}},</p>
<p>
We have received your request for dealership relocation (Request ID: <strong>{{requestId}}</strong>).
</p>
<p>
The internal feasibility assessment and multi-level review process have been initiated.
You will be notified of any document requirements or status updates via the portal and email.
</p>
<div style="background:#fff3e0; border-left:4px solid #ff9800; padding:12px 16px; margin: 16px 0;">
<strong>Current Status:</strong> ASM Review (In Progress)
</div>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p>
You can track the real-time progress of your request by logging into the Dealer Portal.
</p>
<p style="color:#888; font-size:12px;">
Best Regards,<br/>
<strong>Royal Enfield Dealer Development Team</strong>
</p>
</div>
{{> email_footer}}

View File

@ -1,7 +1,31 @@
{{> email_header}}
<h2>New relocation request</h2>
<p>A dealer has submitted an outlet relocation request.</p>
<p><strong>Request ID:</strong> {{requestId}}<br><strong>Outlet:</strong> {{outletCode}}</p>
<p>Please review in the Dealer Development portal.</p>
{{> primary_cta}}
<div class="section">
<h2 style="color:#e31837;">New Relocation Request: {{requestId}}</h2>
<p>Dear Team,</p>
<p>
A new dealership relocation request has been submitted by <strong>{{dealerName}}</strong>
for outlet <strong>{{outletCode}}</strong>.
</p>
<div style="background:#f5f5f5; border-left:4px solid #e31837; padding:12px 16px; margin: 16px 0;">
<strong>Request ID:</strong> {{requestId}}<br/>
<strong>Outlet Code:</strong> {{outletCode}}<br/>
<strong>Distance from Existing Site:</strong> {{distance}} km
</div>
<p>
Please log in to the Dealer Development portal to review the proposed location, property documents,
and feasibility details.
</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
This is an internal workflow notification. Audit logs and case chronology are available on the portal.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,30 @@
{{> email_header}}
<div class="section">
<h2 style="color:#e31837;">Notice of Termination — {{requestId}}</h2>
<p>Dear {{dealerName}},</p>
<p>
This is the official notification that your dealership agreement with Royal Enfield (Request ID: <strong>{{requestId}}</strong>)
has been formally terminated effective <strong>{{terminationDate}}</strong>.
</p>
<p>
The final Termination Letter is available for your reference in the dealer portal. Your portal access will remain active
for a limited period to allow you to download relevant documents and track the Full & Final (F&F) settlement progress.
</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p>
For any clarifications regarding the settlement process, please contact the Dealer Development Admin team.
</p>
<p style="color:#888; font-size:12px;">
Best Regards,<br/>
<strong>Royal Enfield Dealer Development Team</strong>
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,41 @@
{{> email_header}}
<div class="section">
<h2>Dealer Termination Initiated — {{requestId}}</h2>
<p>Dear {{recipientName}},</p>
<p>
A formal termination process has been initiated for dealer
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>).
</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>Termination Category</strong></td><td>{{category}}</td></tr>
<tr><td><strong>Initiated By</strong></td><td>{{initiatedBy}}</td></tr>
<tr><td><strong>Current Stage</strong></td><td>{{currentStage}}</td></tr>
</table>
{{#if remarks}}
<div class="highlight-box" style="background:#fff8e1; border-left:4px solid #fb8c00;">
<strong>Remarks:</strong><br />
{{remarks}}
</div>
{{/if}}
<p>
Please review the termination case and provide your evaluation as required.
All decisions must be documented with mandatory work notes.
</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
This notification is confidential and intended only for the named recipient.
Do not share this information externally without authorization.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,25 @@
{{> email_header}}
<div class="section">
<h2 style="color:#e31837;">Termination Letter Generated — {{requestId}}</h2>
<p>Dear {{recipientName}},</p>
<p>
The official <strong>Termination Letter</strong> has been generated and uploaded to the portal for dealer
<strong>{{dealerName}}</strong> (Request ID: <strong>{{requestId}}</strong>).
</p>
<p>
This letter is now visible to the DD-Lead, DD-Admin, and Finance teams. Please proceed with the necessary
administrative and financial closure steps.
</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
This is an internal system notification. Detailed case chronology and audit logs are available on the portal.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,30 @@
{{> email_header}}
<div class="section">
<h2>Action Required: {{requestId}}</h2>
<p>Hi,</p>
<p>
The request <strong>{{requestId}}</strong>
{{#if dealerName}}(Dealer: <strong>{{dealerName}}</strong>){{/if}}
has reached the <strong>{{targetStage}}</strong> stage and requires your review and action.
</p>
{{#if remarks}}
<div class="highlight-box">
<strong>Remarks from previous stage:</strong><br />
{{remarks}}
</div>
{{/if}}
<p>Please log in to the RE Dealer Management Portal to review the case details and take action.</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
This is an automated notification. Please do not reply to this email.
</p>
</div>
{{> email_footer}}

View File

@ -0,0 +1,28 @@
{{> email_header}}
<div class="section">
<h2>Update on Your Request — {{requestId}}</h2>
<p>Dear {{dealerName}},</p>
<p>
Your request <strong>{{requestId}}</strong> has been updated.
Current status: <strong>{{targetStage}}</strong>.
</p>
{{#if remarks}}
<div class="highlight-box">
<strong>Note:</strong> {{remarks}}
</div>
{{/if}}
<p>You can track the live progress of your request on the dealer portal:</p>
<div style="text-align:center; margin: 24px 0;">
{{> cta_button}}
</div>
<p style="color:#888; font-size:12px;">
This is an automated status update. If you have questions, please contact your assigned Area Sales Manager.
</p>
</div>
{{> email_footer}}

View File

@ -20,6 +20,18 @@ const getLocationAncestors = async (locationId: string): Promise<string[]> => {
const interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`;
const formatIST = (date: Date) => {
return new Intl.DateTimeFormat('en-IN', {
timeZone: 'Asia/Kolkata',
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).format(date);
};
const normalizeRoleCode = (value: unknown) =>
String(value || '')
.trim()
@ -567,8 +579,9 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
applicantName: application.applicantName,
applicationId: application.applicationId,
type,
scheduledAt: scheduledAtIso,
link: meetLink,
scheduledAt: formatIST(scheduledDateObj),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
phone: applicantPhone,
ctaLabel: 'View Schedule'
}
@ -682,8 +695,9 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
applicantName: application?.applicantName || 'Applicant',
applicationId: application?.applicationId || '',
type,
scheduledAt: scheduledAtIso,
link: meetLink,
scheduledAt: formatIST(scheduledDateObj),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
phone: pPhone || '',
ctaLabel: 'Open Assessment'
}
@ -749,11 +763,81 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
}
await interview.update(updatePayload);
await interview.reload({
include: [
{
model: InterviewParticipant,
as: 'participants',
include: [{ model: User, as: 'user' }]
}
]
});
const isCancelled = String(status || '').toLowerCase() === 'cancelled' && String(oldStatus || '').toLowerCase() !== 'cancelled';
const isRescheduled = typeof scheduledAt !== 'undefined' && String(status || '').toLowerCase() !== 'cancelled';
const eventType = isCancelled ? 'interview_cancelled' : (isRescheduled ? 'interview_rescheduled' : 'interview_updated');
if (isRescheduled || isCancelled) {
const application = await db.Application.findByPk(interview.applicationId);
if (application) {
const scheduledAtIso = interview.scheduleDate.toISOString();
const type = interview.interviewType;
const meetLink = interview.linkOrLocation;
const notificationPromises: Promise<any>[] = [];
// Notify Applicant
notificationPromises.push(
NotificationService.notify(null, application.email, {
title: isCancelled ? `Interview Cancelled: ${application.applicationId}` : `Interview Rescheduled: ${application.applicationId}`,
message: isCancelled
? `Dear ${application.applicantName}, your ${type} has been cancelled.`
: `Dear ${application.applicantName}, your ${type} has been rescheduled to ${formatIST(interview.scheduleDate)}.`,
channels: application.mobileNumber ? ['email', 'whatsapp', 'system'] : ['email', 'system'],
templateCode: isCancelled ? 'INTERVIEW_CANCELLED_APPLICANT' : 'INTERVIEW_RESCHEDULED_APPLICANT',
placeholders: {
applicantName: application.applicantName,
applicationId: application.applicationId,
type,
scheduledAt: formatIST(interview.scheduleDate),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
phone: application.mobileNumber || '',
ctaLabel: 'View Schedule'
}
}).catch(err => console.error('Failed to notify applicant of reschedule/cancellation:', err))
);
// Notify Panelists
const participants = (interview as any).participants || [];
for (const p of participants) {
const panelist = p.user;
if (panelist?.email) {
notificationPromises.push(
NotificationService.notify(panelist.id, panelist.email, {
title: isCancelled ? `Interview Cancelled: ${application.applicationId}` : `Interview Rescheduled: ${application.applicationId}`,
message: isCancelled
? `The ${type} for ${application.applicantName} has been cancelled.`
: `The ${type} for ${application.applicantName} has been rescheduled to ${formatIST(interview.scheduleDate)}.`,
channels: panelist.mobileNumber ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: isCancelled ? 'INTERVIEW_CANCELLED_PANELIST' : 'INTERVIEW_RESCHEDULED_PANELIST',
placeholders: {
panelistName: panelist.fullName,
applicantName: application.applicantName,
applicationId: application.applicationId,
type,
scheduledAt: formatIST(interview.scheduleDate),
meetLink,
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
phone: panelist.mobileNumber || '',
ctaLabel: 'Open Assessment'
}
}).catch(err => console.error(`Failed to notify panelist (${panelist.id}) of reschedule/cancellation:`, err))
);
}
}
await Promise.all(notificationPromises);
}
}
await db.AuditLog.create({
userId: req.user?.id || null,
action: AUDIT_ACTIONS.INTERVIEW_UPDATED,

View File

@ -208,9 +208,11 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s
(payload?.context as any)?.currentStage ||
null,
actor: {
id: logData.userId || logData.actorId || logData.user?.id || null,
name: actorName,
email: logData.user?.email || logData.userEmail || null
},
actorId: logData.userId || logData.actorId || logData.user?.id || null,
userName: actorName,
userEmail: logData.user?.email || logData.userEmail || null,
remarks: logData.remarks || payload?.remarks || '',

View File

@ -3,6 +3,8 @@ import { Op } from 'sequelize';
import db from '../../database/models/index.js';
const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db;
import { AuthRequest } from '../../types/express.types.js';
import { NotificationService } from '../../services/NotificationService.js';
import { ROLES } from '../../common/config/constants.js';
/** Default EOR rows for relocation (SRS 12.2.8) — must stay aligned with relocation required-doc labels. */
export const RELOCATION_EOR_DEFAULT_ITEMS = [
@ -338,6 +340,27 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
await updateApplicationProgress(checklist.applicationId, 'Inauguration', 'active', 50);
// SRS §6.19.3.4 — Readiness alert to DD-Head and NBH on EOR 100% completion
const app = await db.Application.findByPk(checklist.applicationId);
const eorAlertRoles = [ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN];
for (const role of eorAlertRoles) {
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `EOR Completed: ${app?.applicationId || checklist.applicationId}`,
message: `EOR checklist is 100% complete for ${app?.applicantName || 'the applicant'}. Dealership is ready for inauguration.`,
channels: ['system', 'email'],
templateCode: 'EOR_COMPLETED',
placeholders: {
applicantName: app?.applicantName || '',
applicationId: app?.applicationId || '',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${checklist.applicationId}`,
ctaLabel: 'View Application'
}
}).catch((e: any) => console.error('[EOR] Completion notify failed:', e));
}
}
} else if (checklist.relocationId) {
await db.RelocationRequest.update({
status: 'Completed',
@ -345,7 +368,22 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
currentStage: 'Completed'
}, { where: { id: checklist.relocationId } });
// The workflow service can handle timeline/audit but here we just finalized the status
// SRS §6.19.3.4 — Relocation EOR complete — notify DD-Admin
const adminUsers = await db.User.findAll({ where: { roleCode: ROLES.DD_ADMIN } });
for (const u of adminUsers) {
NotificationService.notify(u.id, u.email, {
title: `Relocation EOR Completed`,
message: `The EOR checklist for relocation ${checklist.relocationId} has been fully verified.`,
channels: ['system', 'email'],
templateCode: 'EOR_COMPLETED',
placeholders: {
applicantName: '',
applicationId: checklist.relocationId,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/relocation-requests/${checklist.relocationId}`,
ctaLabel: 'View Request'
}
}).catch((e: any) => console.error('[EOR] Relocation notify failed:', e));
}
}
}
}

View File

@ -3,9 +3,10 @@ import { Op } from 'sequelize';
import db from '../../database/models/index.js';
const { FddAssignment, FddReport, Application } = db;
import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES } from '../../common/config/constants.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { NotificationService } from '../../services/NotificationService.js';
export const getAssignment = async (req: Request, res: Response) => {
try {
@ -88,6 +89,25 @@ export const assignAgency = async (req: AuthRequest, res: Response) => {
},
});
// SRS §6.15.3.1 — Notify assigned FDD agency user of their assignment
const fddUser = await db.User.findByPk(assignedToAgency);
if (fddUser) {
const phone = (fddUser as any).mobileNumber || null;
NotificationService.notify(fddUser.id, fddUser.email, {
title: `FDD Assignment: ${application.applicationId}`,
message: `You have been assigned to conduct Financial Due Diligence for application ${application.applicationId}.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'USER_ASSIGNED',
placeholders: {
applicantName: application.applicantName || '',
applicationId: application.applicationId,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
ctaLabel: 'View Assignment',
phone: phone || ''
}
}).catch((e: any) => console.error('[FDD] Agency notify failed:', e));
}
res.status(201).json({ success: true, message: 'FDD Agency assigned', data: assignment });
} catch (error) {
console.error('Assign FDD agency error:', error);
@ -158,6 +178,27 @@ export const uploadReport = async (req: AuthRequest, res: Response) => {
reason: 'FDD Report uploaded. Pending review to proceed to LOI stage.',
forceLog: true // Log even if status is same
});
// SRS §6.15.3.1 — Notify Finance + DD-Admin that FDD report is submitted
const fddReportRoles = [ROLES.FINANCE, ROLES.DD_ADMIN];
for (const role of fddReportRoles) {
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `FDD Report Submitted: ${application.applicationId}`,
message: `The FDD agency has submitted their financial due diligence report for ${application.applicationId}. Review is required before LOI stage.`,
channels: ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: application.applicantName || '',
requestId: application.applicationId,
targetStage: 'FDD Review',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: ''
}
}).catch((e: any) => console.error('[FDD] Finance/Admin notify failed:', e));
}
}
}
}

View File

@ -2,8 +2,9 @@ import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES } from '../../common/config/constants.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { NotificationService } from '../../services/NotificationService.js';
const LOA_STAGE_CODE = 'LOA_APPROVAL';
@ -86,6 +87,25 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
progressPercentage: 92
});
// SRS §6.18.3.1 — Notify DD-Head that LOA needs their approval
const ddHeadUsers = await User.findAll({ where: { roleCode: ROLES.DD_HEAD } });
for (const ddHead of ddHeadUsers) {
const phone = (ddHead as any).mobileNumber || null;
NotificationService.notify(ddHead.id, ddHead.email, {
title: `Action Required: LOA Approval for ${application.applicationId}`,
message: `LOA request has been initiated for ${application.applicationId}. Your approval is required.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: application.applicantName || application.applicationId,
requestId: application.applicationId,
targetStage: 'LOA Approval',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: phone || ''
}
}).catch((e: any) => console.error('[LOA] DD-Head notify failed:', e));
}
res.status(201).json({ success: true, message: 'LOA Request initiated with DD Head approval', data: request });
} catch (error) {
console.error('Create LOA request error:', error);
@ -207,6 +227,28 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
progressPercentage: 97
});
}
// SRS §6.18.3.1 — LOA issued: notify all relevant teams (System + Email)
const app = await db.Application.findByPk(request.applicationId);
const loaTeamRoles = [ROLES.DD_ADMIN, ROLES.FINANCE, ROLES.LEGAL_ADMIN, ROLES.DD_HEAD, ROLES.NBH];
for (const role of loaTeamRoles) {
const roleUsers = await User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `LOA Issued: ${app?.applicationId || request.applicationId}`,
message: `Letter of Appointment fully approved and issued for ${app?.applicantName || 'the applicant'}. EOR process may now begin.`,
channels: ['system', 'email'],
templateCode: 'LOA_ISSUED',
placeholders: {
applicantName: app?.applicantName || '',
applicationId: app?.applicationId || '',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`,
ctaLabel: 'View Application'
}
}).catch((e: any) => console.error('[LOA] team notify failed:', e));
}
}
res.json({ success: true, message: 'LOA fully approved and issued' });
} else {
// SEQUENTIAL APPROVAL BRIDGE:
@ -225,6 +267,27 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
}
});
console.log(`[LOA] Generated sequential approval record for Level ${nextLevel}: ${nextRole}`);
// SRS §6.18.3.1 — Sequential: DD-Head approved → notify NBH
if (req.user.roleCode === ROLES.DD_HEAD) {
const nbhUsers = await User.findAll({ where: { roleCode: ROLES.NBH } });
for (const nbh of nbhUsers) {
const phone = (nbh as any).mobileNumber || null;
NotificationService.notify(nbh.id, nbh.email, {
title: `Action Required: LOA Final Approval`,
message: `DD Head has approved LOA request ${requestId}. Your final sign-off is required.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: '',
requestId,
targetStage: 'NBH LOA Approval',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`,
phone: phone || ''
}
}).catch((e: any) => console.error('[LOA] NBH notify failed:', e));
}
}
}
res.json({

View File

@ -2,8 +2,10 @@ import { Request, Response } from 'express';
import db from '../../database/models/index.js';
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
import { AuthRequest } from '../../types/express.types.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
import { AUDIT_ACTIONS, APPLICATION_STATUS, ROLES } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { NotificationService } from '../../services/NotificationService.js';
import { sendEmail } from '../../common/utils/email.service.js';
const LOI_STAGE_CODE = 'LOI_APPROVAL';
@ -125,6 +127,25 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
progressPercentage: 75
});
// SRS §6.16.3.5 — Notify DD-Head that LOI needs their approval (System + Email + WhatsApp)
const ddHeadUsers = await User.findAll({ where: { roleCode: ROLES.DD_HEAD } });
for (const ddHead of ddHeadUsers) {
const phone = (ddHead as any).mobileNumber || null;
NotificationService.notify(ddHead.id, ddHead.email, {
title: `Action Required: LOI Approval for ${application.applicationId}`,
message: `LOI request initiated for application ${application.applicationId}. Your approval is required.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: application.applicantName || application.applicationId,
requestId: application.applicationId,
targetStage: 'LOI Approval',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
phone: phone || ''
}
}).catch((e: any) => console.error('[LOI] DD-Head notify failed:', e));
}
res.status(201).json({ success: true, message: 'LOI Request initiated for DD Head approval', data: request });
} catch (error) {
console.error('Create LOI request error:', error);
@ -259,8 +280,48 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
});
}
// SRS §6.16.3.5 — Notify Finance + DD-Admin that LOI is fully approved
const notifyRoles = [ROLES.FINANCE, ROLES.DD_ADMIN];
const application2 = await db.Application.findByPk(request.applicationId);
for (const role of notifyRoles) {
const roleUsers = await User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `LOI Fully Approved: ${application2?.applicationId || request.applicationId}`,
message: `LOI request has been approved by all required stakeholders and is ready to issue.`,
channels: ['system', 'email'],
templateCode: 'ONBOARDING_STATUS_UPDATE',
placeholders: {
dealerName: application2?.applicantName || '',
requestId: application2?.applicationId || '',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`,
ctaLabel: 'View Application'
}
}).catch((e: any) => console.error('[LOI] finance/admin notify failed:', e));
}
}
res.json({ success: true, message: 'LOI Request fully approved and document generated' });
} else {
// SRS §6.16.3.5 — Sequential: DD-Head approved → now notify NBH
if (action === 'Approved' && req.user.roleCode === ROLES.DD_HEAD) {
const nbhUsers = await User.findAll({ where: { roleCode: ROLES.NBH } });
for (const nbh of nbhUsers) {
const phone = (nbh as any).mobileNumber || null;
NotificationService.notify(nbh.id, nbh.email, {
title: `Action Required: LOI Final Approval`,
message: `DD Head has approved the LOI for ${request.applicationId}. Your final sign-off is required.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'WORKFLOW_ACTION_REQUIRED',
placeholders: {
dealerName: '',
requestId: String(request.applicationId),
targetStage: 'NBH LOI Approval',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`,
phone: phone || ''
}
}).catch((e: any) => console.error('[LOI] NBH notify failed:', e));
}
}
res.json({
success: true,
message: 'Approval recorded. Waiting for remaining required approvers.',
@ -359,6 +420,41 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
reason: 'LOI Document issued. Proceeding to Dealer Code Generation.',
progressPercentage: 85
});
// SRS §6.16.3.6 — LOI issued: email ONLY to applicant (explicitly NO WhatsApp per SRS)
if (application.email) {
sendEmail(
application.email,
`Letter of Intent Issued: ${application.applicationId}`,
'LOI_ISSUED',
{
applicantName: application.applicantName || 'Applicant',
applicationId: application.applicationId,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/prospect-login`,
ctaLabel: 'View Your Application'
}
).catch((e: any) => console.error('[LOI] Applicant email failed:', e));
}
// SRS §6.16.3.6 — Alert Finance, DD-Head, NBH confirming LOI issuance (System + Email)
const stakeholderRoles = [ROLES.FINANCE, ROLES.DD_HEAD, ROLES.NBH];
for (const role of stakeholderRoles) {
const roleUsers = await User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `LOI Issued: ${application.applicationId}`,
message: `Letter of Intent has been issued for ${application.applicantName || application.applicationId}. Dealer code generation is now pending.`,
channels: ['system', 'email'],
templateCode: 'LOI_ISSUED',
placeholders: {
applicantName: application.applicantName || '',
applicationId: application.applicationId,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`,
ctaLabel: 'View Application'
}
}).catch((e: any) => console.error('[LOI] stakeholder notify failed:', e));
}
}
}
}

View File

@ -13,6 +13,7 @@ import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityServic
import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
import { NotificationService } from '../../services/NotificationService.js';
const { DocumentStageConfig } = db;
@ -195,6 +196,27 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
entityId: application.id
});
// SRS §6.1.3 — WhatsApp acknowledgement to applicant on submission (alongside email)
// The email is handled above; now send WhatsApp if phone is available
if (phone) {
NotificationService.notify(null, email, {
title: isOpportunityAvailable
? `Your Royal Enfield Dealership Application: ${applicationId}`
: `We received your interest: ${applicationId}`,
message: isOpportunityAvailable
? `Hi ${displayApplicantName}, your application for ${displayLocation} has been received. We will contact you soon.`
: `Hi ${displayApplicantName}, we have noted your interest for ${displayLocation}. We'll reach out when an opportunity arises.`,
channels: ['whatsapp'],
templateCode: isOpportunityAvailable ? 'OPPORTUNITY' : 'NON_OPPORTUNITY',
placeholders: {
applicantName: displayApplicantName,
location: displayLocation,
applicationId,
phone
}
}).catch((err: any) => console.error('[Onboarding] WhatsApp ack failed:', err));
}
res.status(201).json({
success: true,
message: 'Application submitted successfully',
@ -256,17 +278,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
// Apply Filters
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
// 1. Date Filters (createdAt range)
if (fromDate || toDate) {
whereClause.createdAt = {};
if (fromDate) {
const dateClause: any = {};
if (fromDate && fromDate !== 'undefined') {
const start = new Date(fromDate as string);
start.setHours(0, 0, 0, 0);
whereClause.createdAt[Op.gte] = start;
if (!isNaN(start.getTime())) {
start.setHours(0, 0, 0, 0);
dateClause[Op.gte] = start;
}
}
if (toDate) {
if (toDate && toDate !== 'undefined') {
const end = new Date(toDate as string);
end.setHours(23, 59, 59, 999);
whereClause.createdAt[Op.lte] = end;
if (!isNaN(end.getTime())) {
end.setHours(23, 59, 59, 999);
dateClause[Op.lte] = end;
}
}
if (Object.keys(dateClause).length > 0) {
whereClause.createdAt = dateClause;
}
}
@ -291,9 +321,13 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
};
// Pipeline Logic - Forced strict filtering by lifecycle stage
// 3. Status Grouping Logic (Prospects vs Leads vs Workflow)
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
// Use a conditions array to prevent Op.or overwrites
const conditions: any[] = [];
if (isShortlistedStr === 'false') {
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
whereClause.overallStatus = 'Submitted';
@ -302,10 +336,12 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
whereClause.opportunityId = null; // Strictly lead-gen records only
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
whereClause[Op.or] = [
{ isShortlisted: true },
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
];
conditions.push({
[Op.or]: [
{ isShortlisted: true },
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
]
});
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
whereClause.ddLeadShortlisted = { [Op.ne]: true };
@ -323,6 +359,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
applyStatusFilter(status);
}
if (conditions.length > 0) {
whereClause[Op.and] = [...(whereClause[Op.and] || []), ...conditions];
}
if (location && location !== 'all') {
whereClause.preferredLocation = location;
}
@ -1599,3 +1639,46 @@ export const bulkConvertToOpportunity = async (req: AuthRequest, res: Response)
res.status(500).json({ success: false, message: 'Internal error during batch conversion' });
}
};
export const sendBulkReminders = async (req: AuthRequest, res: Response) => {
try {
const { applicationIds } = req.body;
if (!applicationIds || !Array.isArray(applicationIds)) {
return res.status(400).json({ success: false, message: 'Invalid application IDs' });
}
const applications = await Application.findAll({
where: { id: { [Op.in]: applicationIds } }
});
for (const app of applications) {
await NotificationService.sendQuestionnaireReminder(
app.email,
app.phone,
app.applicantName,
{
location: app.preferredLocation,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/questionnaire/${app.applicationId}`
}
);
// Log Audit
await safeAuditLogCreate({
userId: req.user?.id || null,
action: 'REMINDER_SENT',
entityType: 'application',
entityId: app.id,
newData: {
template: 'QUESTIONNAIRE_REMINDER',
sentAt: new Date(),
context: pickApplicationAuditContext(app)
}
});
}
res.json({ success: true, message: `Reminders sent to ${applications.length} applicants` });
} catch (error) {
console.error('Send bulk reminders error:', error);
res.status(500).json({ success: false, message: 'Error sending reminders' });
}
};

View File

@ -6,7 +6,7 @@ import {
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders
} from './onboarding.controller.js';
import { authenticate } from '../../common/middleware/auth.js';
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
@ -29,6 +29,7 @@ router.get('/applications/export-responses', exportApplicationResponses);
router.get('/document-configs/metadata', getDocumentConfigMetadata);
router.get('/document-configs', getDocumentConfigs);
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
router.post('/applications/reminders', sendBulkReminders);
router.get('/applications/:id', checkRevocation as any, getApplicationById);
router.put('/applications/:id', checkRevocation as any, updateApplication);
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);

View File

@ -26,6 +26,7 @@ import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow
const STRUCTURE_TARGET_VALUES = new Set<string>(
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string)
);
const CLOSED_CONSTITUTIONAL_STATUSES = ['Completed', 'Closed', 'Rejected', 'Revoked'];
const resolveConstitutionalUuid = async (id: string) => {
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional');
@ -166,6 +167,30 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
const requestId = await NomenclatureService.generateConstitutionalChangeId();
const dealerProfileStatus = String(subjectDealerProfile?.status || '').toLowerCase();
const isDealerActive = !dealerProfileStatus || dealerProfileStatus === 'active';
if (!isDealerActive || !subjectDealerProfile?.onboardedAt) {
return res.status(400).json({
success: false,
message: 'Constitutional change can be initiated only for active onboarded dealers.'
});
}
const existingOpenRequest = await ConstitutionalChange.findOne({
where: {
dealerId: dealerUserId,
status: { [Op.notIn]: CLOSED_CONSTITUTIONAL_STATUSES }
},
attributes: ['id', 'requestId', 'status', 'currentStage'],
order: [['createdAt', 'DESC']]
});
if (existingOpenRequest) {
return res.status(409).json({
success: false,
message: `Open constitutional request ${existingOpenRequest.requestId} already exists at ${existingOpenRequest.currentStage}. Complete it before creating a new one.`
});
}
const metadata = {
newPartnersDetails,
shareholdingPattern,
@ -215,19 +240,23 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
});
try {
await ParticipantService.assignConstitutionalParticipants(request.id);
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
await notifyConstitutionalSubmittedEmails(request, displayName);
} catch (e) {
console.error('Error assigning participants or sending constitutional submit emails:', e);
}
res.status(201).json({
success: true,
message: 'Constitutional change request submitted successfully',
requestId: request.requestId
});
// Run participant assignment + notifications asynchronously to keep API responsive.
// With Redis disabled, email sending is synchronous and can otherwise block for many seconds.
void (async () => {
try {
await ParticipantService.assignConstitutionalParticipants(request.id);
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
await notifyConstitutionalSubmittedEmails(request, displayName);
} catch (e) {
console.error('Error assigning participants or sending constitutional submit emails:', e);
}
})();
} catch (error) {
console.error('Submit constitutional change error:', error);
res.status(500).json({ success: false, message: 'Error submitting request' });
@ -366,6 +395,9 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
});
if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
if (req.user?.roleCode === ROLES.DEALER && String(request.dealerId) !== String(req.user.id)) {
return res.status(403).json({ success: false, message: 'Forbidden: You can only access your own request.' });
}
res.json({ success: true, request });
} catch (error) {
@ -423,6 +455,9 @@ const actionSuccessMessage = (raw: string): string => {
export const takeAction = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
if (req.user.roleCode === ROLES.DEALER) {
return res.status(403).json({ success: false, message: 'Dealers cannot perform review actions.' });
}
const { id } = req.params;
const rawAction = String(req.body.action || '').trim();
@ -446,6 +481,23 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
const isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK);
const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE);
const stageRoleMap: Record<string, string[]> = {
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: [ROLES.ASM, ROLES.SUPER_ADMIN],
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: [ROLES.DD_ZM, ROLES.RBM, ROLES.SUPER_ADMIN],
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: [ROLES.ZBH, ROLES.SUPER_ADMIN],
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: [ROLES.DD_LEAD, ROLES.SUPER_ADMIN],
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: [ROLES.DD_HEAD, ROLES.SUPER_ADMIN],
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: [ROLES.NBH, ROLES.SUPER_ADMIN],
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: [ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN]
};
const allowedRoles = stageRoleMap[sourceStage] || [ROLES.SUPER_ADMIN];
if (!allowedRoles.includes(req.user.roleCode as any)) {
return res.status(403).json({
success: false,
message: `Role ${req.user.roleCode} cannot act at stage ${sourceStage}. Allowed: ${allowedRoles.join(', ')}`
});
}
if (isReject) {
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
@ -498,6 +550,17 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
return res.status(400).json({ success: false, message: 'Unsupported action. Use Approve, Reject, Send Back, or Revoke.' });
}
const missingDocs = ConstitutionalWorkflowService.getMissingMandatoryDocuments(
request.changeType,
request.documents || []
);
if (missingDocs.length > 0) {
return res.status(400).json({
success: false,
message: `Mandatory documents missing for ${request.changeType}: ${missingDocs.join(', ')}. Upload all required documents before approval.`
});
}
const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW;
if (isZmRbmJointStage && isApprove) {

View File

@ -2,7 +2,7 @@ import { Response } from 'express';
import db from '../../database/models/index.js';
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js';
const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog } = db;
const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog, Dealer } = db;
import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS, OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
@ -167,29 +167,18 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
}
// Stage 6: DD Head (national)
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
if (ddHead) {
evaluators.push({ id: ddHead.id, role: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL });
}
// Stage 7: NBH Approval (national)
// Stage 6: NBH Approval (national)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
}
// Stage 8: Legal Clearance (national)
// Stage 7: Legal Clearance (national)
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
if (legal) {
evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
}
// Stage 9: NBH Clearance with EOR (national)
if (nbh) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR });
}
console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`);
const evaluatorInfo = evaluators.map(e => ({
@ -298,6 +287,21 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
message: `Relocation can only be requested for active outlets. Current outlet status: ${outlet.status}`
});
}
const subjectDealerUser = await User.findByPk(outlet.dealerId, {
attributes: ['id', 'status', 'dealerId']
});
const subjectDealerProfile = subjectDealerUser?.dealerId
? await db.Dealer.findByPk(subjectDealerUser.dealerId, { attributes: ['id', 'status', 'onboardedAt'] })
: null;
const isDealerActive = String(subjectDealerUser?.status || '').toLowerCase() === 'active';
const dealerProfileStatus = String(subjectDealerProfile?.status || '').toLowerCase();
const isDealerProfileEligible = dealerProfileStatus === '' || dealerProfileStatus === 'active';
if (!isDealerActive || !subjectDealerProfile?.onboardedAt || !isDealerProfileEligible) {
return res.status(403).json({
success: false,
message: 'Relocation can be initiated only for active onboarded dealers.'
});
}
const roleCode = req.user.roleCode as string;
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) {
@ -317,7 +321,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
const openExisting = await RelocationRequest.findOne({
where: {
outletId,
status: { [Op.notIn]: ['Completed', 'Rejected'] }
status: { [Op.notIn]: ['Completed', 'Rejected', 'Revoked'] }
},
attributes: ['id', 'requestId', 'status', 'currentStage']
});
@ -441,7 +445,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
{
model: User,
as: 'dealer',
attributes: ['fullName']
attributes: ['fullName'],
include: [
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
]
}
],
order: [['createdAt', 'DESC']],
@ -483,12 +490,14 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
return true;
}
// Check if user is assigned to any evaluator role for this outlet
// ASM is tied to the dealer/outlet (Dealer.asmId); district.asmId kept as fallback for legacy data.
const outletAsmId = (request as any).dealer?.dealerProfile?.asmId as string | undefined;
const isAssigned =
district.asmId === userId || // ASM
region?.rbmId === userId || // RBM
district.zmId === userId || // DD-ZM
zone?.zbhId === userId; // ZBH
outletAsmId === userId ||
district.asmId === userId ||
region?.rbmId === userId ||
district.zmId === userId ||
zone?.zbhId === userId;
return isAssigned;
});
@ -553,7 +562,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
{
model: User,
as: 'dealer',
attributes: ['fullName', 'email']
attributes: ['fullName', 'email'],
include: [
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
]
},
{
model: Worknote,
@ -575,8 +587,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
const region = district.region;
const zone = district.zone;
const outletLevelAsmId = (request as any).dealer?.dealerProfile?.asmId ?? null;
const asmReviewerId = outletLevelAsmId || district.asmId;
const evaluatorRoles: any[] = [
{ id: district.asmId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW },
{ id: asmReviewerId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW },
{ id: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW },
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW }
@ -593,10 +607,6 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
});
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
// Get DD Head (national)
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
if (ddHead) evaluatorRoles.push({ id: ddHead.id, roleCode: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL });
// Get NBH (national) - Approval Stage
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) {
@ -607,11 +617,6 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
if (legal) evaluatorRoles.push({ id: legal.id, roleCode: 'Legal Admin', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
// Get NBH (national) - Final Clearance Stage
if (nbh) {
evaluatorRoles.push({ id: nbh.id, roleCode: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR });
}
// Fetch user details for each evaluator
for (const evaluator of evaluatorRoles) {
if (evaluator.id) {
@ -718,11 +723,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
[RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.NBH_APPROVAL,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.NBH_APPROVAL,
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR,
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED
};
const reverseStageFlow: Record<string, string> = {
@ -731,10 +734,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
[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
};
/** Canonical order for progress % (must stay aligned with stageFlow chain, excluding terminal). */
@ -744,10 +746,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
RELOCATION_STAGES.DD_ZM_REVIEW,
RELOCATION_STAGES.ZBH_REVIEW,
RELOCATION_STAGES.DD_LEAD_REVIEW,
RELOCATION_STAGES.DD_HEAD_APPROVAL,
RELOCATION_STAGES.NBH_APPROVAL,
RELOCATION_STAGES.LEGAL_CLEARANCE,
RELOCATION_STAGES.NBH_CLEARANCE_EOR
RELOCATION_STAGES.LEGAL_CLEARANCE
];
/** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */
@ -833,25 +833,6 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
progressPercentage: newProgress
});
// 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR (header row + default checklist lines + doc map)
if (newCurrentStage === RELOCATION_STAGES.NBH_CLEARANCE_EOR && normalizedAction === 'APPROVE') {
try {
await db.EorChecklist.findOrCreate({
where: { relocationId: request.id },
defaults: {
status: 'In Progress',
relocationId: request.id,
applicationId: null
}
});
const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
await ensureRelocationEorChecklistSeeded(request.id);
console.log(`[RelocationController] EOR Checklist initiated/synced for ${request.requestId}`);
} catch (e) {
console.error('Failed to auto-initiate EOR checklist:', e);
}
}
// 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
const shouldWriteWorknote =
Boolean(String(reviewComments).trim()) &&

View File

@ -1,4 +1,4 @@
import { Response, NextFunction } from 'express';
import { Response, NextFunction } from 'express'; // Triggering reload to pick up constants changes
import db from '../../database/models/index.js';
import logger from '../../common/utils/logger.js';
import {
@ -17,11 +17,12 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js';
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { ParticipantService } from '../../services/ParticipantService.js';
import { notifyResignationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
import { notifyResignationSubmittedEmails, notifyStakeholdersOnTransition } from '../../common/utils/workflow-email-notifications.js';
import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js';
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
// Removed generateResignationId and moved to NomenclatureService
const resolveResignationUuid = async (id: string) => {
@ -35,16 +36,32 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
try {
if (!req.user) throw new Error('Unauthorized');
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
const dealerId = req.user.id;
const userRole = req.user.roleCode || req.user.role;
const isInternalInitiator = [ROLES.ASM, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN].includes(userRole as any);
const outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } });
if (!outlet) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
let dealerId: string;
let outlet: any;
if (isInternalInitiator) {
// Internal initiator (ASM/Admin) selects the outlet
outlet = await db.Outlet.findOne({ where: { id: outletId } });
if (!outlet) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Outlet not found' });
}
dealerId = outlet.dealerId;
} else {
// Dealer (Self-Service) initiates for their own outlet
dealerId = req.user.id;
outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } });
if (!outlet) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
}
}
const existingResignation = await db.Resignation.findOne({
where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected'] } }
where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected', 'Withdrawn', 'Revoked'] } }
});
if (existingResignation) {
await transaction.rollback();
@ -74,10 +91,11 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
documents: [],
departmentalClearances: initialClearances,
timeline: [{
stage: 'Submitted',
stage: 'Request Submitted',
timestamp: new Date(),
user: req.user.fullName,
action: 'Resignation request submitted'
action: isInternalInitiator ? 'Resignation initiated by ASM' : 'Resignation request submitted by dealer',
remarks: reason || ''
}]
}, { transaction });
@ -86,7 +104,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
userId: req.user.id,
action: AUDIT_ACTIONS.CREATED,
resignationId: resignation.id,
remarks: 'Dealer submitted resignation request'
remarks: isInternalInitiator ? 'ASM initiated resignation request' : 'Dealer submitted resignation request'
}, { transaction });
await transaction.commit();
@ -308,6 +326,30 @@ export const uploadResignationDocument = async (req: AuthRequest, res: Response,
await resignation.update({ timeline }, { transaction });
await transaction.commit();
// SRS §7.5.2 — When Legal uploads the acceptance letter, notify DD-Admin and ASM
// so they can communicate official closure to the dealer (field hierarchy).
if (stage === RESIGNATION_STAGES.LEGAL && resignation.currentStage === RESIGNATION_STAGES.LEGAL) {
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
const resignationCode = resignation.resignationId || resignation.id;
setImmediate(() =>
notifyStakeholdersOnTransition(
resignation.id,
REQUEST_TYPES.RESIGNATION,
'Resignation Legal Closure', // synthetic stage key mapped to DD_ADMIN + ASM in resolveNextActors
{
code: resignationCode,
dealerName: 'Dealer', // enriched below if needed
dealerId: resignation.dealerId,
actionUserFullName: req.user!.fullName || 'Legal Team',
action: 'Legal acceptance letter uploaded — ready for DD-Admin review and ASM communication',
remarks: `Document: ${req.file!.originalname}`,
link: `${portalBase}/dealer-resignation/${resignation.id}`
}
).catch((err: any) => logger.error('[uploadResignationDocument] legal-closure notify:', err))
);
}
res.status(201).json({ success: true, message: 'Document uploaded successfully', document });
} catch (error) {
await transaction.rollback();
@ -353,11 +395,9 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
// Legal approval should complete only the Legal stage.
// F&F initiation is explicitly triggered via `pushfnf` action (with LWD/force gates).
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.FNF_INITIATED, // DD Admin approval moves to F&F initiation
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
};
@ -367,20 +407,29 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' });
}
// Guard before transition: F&F initiation is allowed only on/after LWD unless forced.
// Guard before transition: F&F initiation is allowed only on/after LWD as per SRS §4.2.2.8
let shouldTriggerFnF = false;
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
const today = new Date();
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
const { force } = req.body;
if (!force && lwd && today < new Date(lwd)) {
const lwd = lwdString ? new Date(lwdString) : null;
if (lwd) {
// Clear time for date-only comparison
today.setHours(0, 0, 0, 0);
lwd.setHours(0, 0, 0, 0);
}
if (!force && lwd && today < lwd) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `F&F can only be initiated on or after the Last Working Day (${lwd}).`,
message: `F&F settlement process is initiated only on the Last Working Day (${lwdString}) of the dealership.`,
canForce: true
});
}
shouldTriggerFnF = true;
}
// Sequence guard: resignation can be marked completed only after F&F settlement is complete.
@ -400,6 +449,66 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
const sourceStage = resignation.currentStage;
// JOINT APPROVAL LOGIC FOR RBM STAGE
if (sourceStage === RESIGNATION_STAGES.RBM) {
// Log the current user's approval in audit
await db.ResignationAudit.create({
userId: req.user.id,
resignationId: resignation.id,
action: 'PARTIAL_APPROVE',
remarks: `Partial approval by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
details: { roleCode: req.user.roleCode, stage: sourceStage }
}, { transaction });
// Ensure worknote is added for this partial approval
if (remarks) {
await writeWorkflowActivityWorknote({
requestId: resignation.id,
requestType: 'resignation',
userId: req.user.id,
noteText: `Approved: ${remarks}`,
noteType: 'internal'
});
}
// Check if both RBM and DD_ZM have approved
const requiredRoles = [ROLES.RBM, ROLES.DD_ZM];
const partialLogs = await db.ResignationAudit.findAll({
where: {
resignationId: resignation.id,
action: 'PARTIAL_APPROVE'
},
transaction
});
const approvedRoles = new Set(
partialLogs.map((log: any) => log.details?.roleCode)
);
const hasAllRequiredApprovals = requiredRoles.every(role => approvedRoles.has(role));
if (!hasAllRequiredApprovals) {
// Append to timeline directly without transitioning the stage
const timelineEntry = {
stage: sourceStage,
targetStage: nextStage,
timestamp: new Date(),
user: req.user.fullName,
action: `Approved by ${req.user.roleCode}`,
remarks: remarks || ''
};
const updatedTimeline = [...(resignation.timeline || []), timelineEntry];
await resignation.update({ timeline: updatedTimeline }, { transaction });
await transaction.commit();
return res.json({
success: true,
message: 'Approval recorded. Waiting for the other required approver (RBM or DD-ZM).',
resignation
});
}
}
// Transition via Workflow Service
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
remarks,
@ -415,41 +524,12 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
.catch(err => logger.error('Error syncing resignation completion to SAP:', err));
}
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
if (shouldTriggerFnF) {
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
let fnfId = existingFnF?.id;
if (!existingFnF) {
const dealerProfileId = (resignation as any).dealer?.dealerId;
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
const fnf = await db.FnF.create({
settlementId: await NomenclatureService.generateFnFId(),
resignationId: resignation.id,
outletId: resignation.outletId,
dealerId: dealerProfileId, // Correctly using the Dealer model ID
status: 'Initiated',
totalReceivables: 0,
totalPayables: 0,
netAmount: 0
}, { transaction });
const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js');
await db.FffClearance.bulkCreate(
FNF_DEPARTMENTS.map(dept => ({
fnfId: fnf.id,
department: dept,
status: 'Pending'
})),
{ transaction }
);
fnfId = fnf.id;
}
// Always assign/sync Participants for F&F (Sub-application chat) to ensure robustness
if (fnfId) {
await ParticipantService.assignFnFParticipants(fnfId);
const fnf = await ResignationWorkflowService.initiateFnF(resignation, req.user.id, transaction);
// Assign/sync Participants for F&F (Sub-application chat) to ensure robustness
await ParticipantService.assignFnFParticipants(fnf.id);
}
}
@ -759,23 +839,23 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
clearanceType === 'payable'
? 'Payable'
: clearanceType === 'deduction'
? 'Deduction'
: clearanceType === 'recovery' || clearanceType === 'receivable'
? 'Receivable'
: type === 'Payable' || type === 'Deduction'
? type
: type === 'Recovery'
? 'Deduction'
: clearanceType === 'recovery' || clearanceType === 'receivable'
? 'Receivable'
: type === 'Receivable'
? 'Receivable'
: 'Receivable';
: type === 'Payable' || type === 'Deduction'
? type
: type === 'Recovery'
? 'Receivable'
: type === 'Receivable'
? 'Receivable'
: 'Receivable';
const clearanceStoredType: 'Payable' | 'Receivable' | 'Deduction' =
resolvedItemType === 'Payable'
? 'Payable'
: resolvedItemType === 'Deduction'
? 'Deduction'
: 'Receivable';
? 'Deduction'
: 'Receivable';
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
@ -977,14 +1057,15 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
const hasLegalStageDocument = await db.ResignationDocument.findOne({
where: {
resignationId: resignation.id,
stage: RESIGNATION_STAGES.LEGAL
stage: RESIGNATION_STAGES.LEGAL,
documentType: 'Resignation Acceptance Letter'
},
attributes: ['id']
});
if (!hasLegalStageDocument) {
return res.status(400).json({
success: false,
message: 'Cannot trigger F&F. Legal-stage acceptance/communication document is required first.'
message: 'Cannot trigger F&F. Resignation Acceptance Letter is required first.'
});
}
}

View File

@ -10,6 +10,9 @@ import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.j
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { NotificationService } from '../../services/NotificationService.js';
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
const LINE_ITEM_DESCRIPTION_PREFIX = {
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
@ -202,6 +205,31 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
},
});
// SRS §11.1.3.1 — Notify DD-Admin and DD-Lead when payment is verified
if (isVerifying && status === 'Paid') {
const notifyRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD];
for (const role of notifyRoles) {
const roleUsers = await User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `Payment Verified: ${p.application?.applicationId || 'New Dealer'}`,
message: `Finance has verified the ${p.paymentType || 'Security Deposit'} for ${p.application?.applicantName || 'Dealer'}.`,
channels: ['system', 'email'],
templateCode: 'ONBOARDING_PAYMENT_VERIFIED',
placeholders: {
applicationId: p.application?.applicationId || 'N/A',
dealerName: p.application?.applicantName || 'Dealer',
paymentType: p.paymentType || 'Security Deposit',
amount: p.amount,
link: `${portalBase}/applications/${p.applicationId}`
}
}).catch((e: any) => console.error('[Finance] Payment verification notify failed:', e));
}
}
}
res.json({ success: true, message: 'Payment updated successfully', data: updatedPayment });
} catch (error) {
console.error('Update payment error:', error);
@ -258,6 +286,48 @@ export const updateFnF = async (req: AuthRequest, res: Response) => {
});
}
}
// SRS §4.4.2.7 — F&F Completed: final alerts to DD-Lead, NBH, Legal
const completionRoles = [ROLES.DD_LEAD, ROLES.NBH, ROLES.LEGAL_ADMIN, ROLES.DD_ADMIN];
for (const role of completionRoles) {
const roleUsers = await User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `F&F Settlement Completed: ${fnf.fnfId || id}`,
message: `Full & Final Settlement has been completed and closed. All departmental clearances confirmed.`,
channels: ['system', 'email'],
templateCode: 'FNF_SETTLEMENT_APPROVED',
placeholders: {
fnfId: fnf.fnfId || id,
dealerName: 'Dealer', // Can be refined if dealer info is fetched
settlementAmount: fnf.settlementAmount || fnf.netAmount,
link: `${portalBase}/fnf/${id}`
}
}).catch((e: any) => console.error('[FnF] Completion notify failed:', e));
}
}
}
// SRS §4.4.2.6 — Finance approval reached: notify DD-Admin + Legal
if (normalizedStatus === FNF_STATUS.FINANCE_APPROVAL) {
const financeApprovalRoles = [ROLES.DD_ADMIN, ROLES.LEGAL_ADMIN];
for (const role of financeApprovalRoles) {
const roleUsers = await User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `F&F Finance Approval Stage: ${fnf.fnfId || id}`,
message: `All departments have submitted their clearances. F&F is now pending Finance approval.`,
channels: ['system', 'email'],
templateCode: 'FNF_INITIATED',
placeholders: {
dealerName: '',
fnfId: fnf.fnfId || id,
link: `${portalBase}/fnf/${id}`,
ctaLabel: 'Review Settlement'
}
}).catch((e: any) => console.error('[FnF] Finance approval notify failed:', e));
}
}
}
res.json({ success: true, message: 'F&F settlement updated successfully', data: fnf });

View File

@ -16,10 +16,16 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js';
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { ParticipantService } from '../../services/ParticipantService.js';
import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
import { getTerminationStatusForStage, normalizeClearanceStatus, getLegacyTerminationRowFixes } from '../../common/utils/offboardingStatus.js';
import {
buildJointRoundCreatedAtFilter,
getJointRoundCutoffMsFromTimeline
} from '../../common/utils/terminationJointReviewRound.util.js';
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
import { NotificationService } from '../../services/NotificationService.js';
import { sendEmail } from '../../common/utils/email.service.js';
const resolveTerminationUuid = async (id: string) => {
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'termination');
@ -31,9 +37,21 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const allowedRoles = [ROLES.DD_LEAD, ROLES.ASM, ROLES.DD_ADMIN, ROLES.DD_AM, ROLES.SUPER_ADMIN];
if (!allowedRoles.includes(req.user.roleCode as any)) {
return res.status(403).json({
success: false,
message: 'Only DD Lead, ASM, DD Admin, or DD AM are authorized to initiate termination requests.'
});
}
const { dealerId, category, reason, proposedLwd, comments } = req.body;
const requestId = await NomenclatureService.generateTerminationId();
const isUnethical = String(category).trim().toLowerCase().includes('unethical');
const startStage = isUnethical ? TERMINATION_STAGES.DD_LEAD_REVIEW : TERMINATION_STAGES.RBM_REVIEW;
const termination = await db.TerminationRequest.create({
requestId,
dealerId,
@ -42,15 +60,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
proposedLwd,
comments,
initiatedBy: req.user.id,
currentStage: TERMINATION_STAGES.RBM_REVIEW,
status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW),
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW),
currentStage: startStage,
status: getTerminationStatusForStage(startStage),
progressPercentage: TerminationWorkflowService.calculateProgress(startStage),
timeline: [{
stage: 'Submitted',
targetStage: TERMINATION_STAGES.RBM_REVIEW,
targetStage: startStage,
timestamp: new Date(),
user: req.user.fullName,
action: `Termination request initiated and forwarded to ${TERMINATION_STAGES.RBM_REVIEW}`,
action: isUnethical ? 'Immediate escalation due to Unethical Practice' : `Termination request initiated and forwarded to ${startStage}`,
remarks: comments
}]
}, { transaction });
@ -68,6 +86,29 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
ParticipantService.assignTerminationParticipants(termination.id)
.catch(err => logger.error('Error assigning participants to termination:', err));
// SRS §4.3.2.1 — Notify appropriate stakeholders that a new termination has been initiated
const notifyOnCreateRoles = isUnethical ? [ROLES.DD_LEAD] : [ROLES.RBM, ROLES.DD_ZM];
for (const role of notifyOnCreateRoles) {
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
const phone = (u as any).mobileNumber || null;
NotificationService.notify(u.id, u.email, {
title: `New Termination Request: ${termination.requestId}`,
message: `A termination request has been initiated by ${req.user!.fullName || 'Admin'}. Your review is required.`,
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
templateCode: 'TERMINATION_INITIATED',
placeholders: {
dealerName: '',
requestId: termination.requestId,
reason: reason || '',
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/termination/${termination.id}`,
ctaLabel: 'Review Request',
phone: phone || ''
}
}).catch((e: any) => logger.error('[Termination] Create notify failed:', e));
}
}
res.status(201).json({ success: true, message: 'Termination request created', termination });
} catch (error) {
if (transaction) await transaction.rollback();
@ -193,6 +234,14 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next:
if (!termination) {
return res.status(404).json({ success: false, message: 'Termination request not found' });
}
const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any);
if (legacyTerminationFixes) {
await termination.update(legacyTerminationFixes);
(termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage ?? termination.currentStage);
(termination as any).setDataValue('status', legacyTerminationFixes.status ?? termination.status);
}
res.json({ success: true, termination });
} catch (error) {
logger.error('Error fetching termination:', error);
@ -257,11 +306,11 @@ export const uploadTerminationDocument = async (req: AuthRequest, res: Response,
}, { transaction });
const timeline = [...(termination.timeline || []), {
stage: termination.currentStage,
stage: stage || termination.currentStage,
timestamp: new Date(),
user: req.user.fullName,
action: `Document uploaded: ${documentType}`,
remarks: req.file.originalname
remarks: `Attachment: ${req.file.originalname}`
}];
await termination.update({ timeline }, { transaction });
@ -302,6 +351,17 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
return res.status(404).json({ success: false, message: 'Termination not found' });
}
const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any);
if (legacyTerminationFixes) {
await termination.update(legacyTerminationFixes, { transaction });
if (legacyTerminationFixes.currentStage) {
(termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage);
}
if (legacyTerminationFixes.status) {
(termination as any).setDataValue('status', legacyTerminationFixes.status);
}
}
const fromStage = termination.currentStage;
let approvedToStage: string | null = null;
@ -311,6 +371,27 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
status: 'Rejected',
remarks
});
} else if (action === OFFBOARDING_ACTIONS.HOLD) {
// SRS §4.3.2.7 — Hold Decision (Pause temporarily); NBH may hold at evaluation or final approval
const holdStages = [TERMINATION_STAGES.NBH_EVALUATION, TERMINATION_STAGES.NBH_FINAL_APPROVAL];
if (!holdStages.includes(termination.currentStage as any) && req.user.roleCode !== ROLES.SUPER_ADMIN) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Hold action is only available at NBH Evaluation or NBH Final Approval stage.'
});
}
await termination.update({ status: 'On Hold' }, { transaction });
await db.TerminationAudit.create({
userId: req.user.id,
terminationRequestId: termination.id,
action: 'ON_HOLD',
remarks: remarks || 'Case placed on hold for further monitoring.',
details: { stage: fromStage }
}, { transaction });
await transaction.commit();
return res.json({ success: true, message: 'Termination case placed on hold.' });
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
// Validation: Remarks mandatory for Revoke
const validation = validateOffboardingAction(action, remarks);
@ -384,18 +465,157 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
[TERMINATION_STAGES.LEGAL_LETTER]: TERMINATION_STAGES.TERMINATED
};
const nextStage = stageFlow[termination.currentStage];
logger.info(`[TerminationController] transitioning from ${termination.currentStage} to ${nextStage}`);
const sourceStage = termination.currentStage;
const nextStage = stageFlow[sourceStage];
logger.info(`[TerminationController] attempting transition from ${sourceStage} to ${nextStage}`);
if (!nextStage) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
}
// SRS §4.3.2.2 — JOINT APPROVAL LOGIC FOR RBM STAGE
if (sourceStage === TERMINATION_STAGES.RBM_REVIEW && req.user.roleCode !== ROLES.SUPER_ADMIN) {
const rbmRoundTime = buildJointRoundCreatedAtFilter(
getJointRoundCutoffMsFromTimeline(termination.timeline, 'rbm_review')
);
// Prevent duplicate approval from same user
const existingUserApproval = await db.TerminationAudit.findOne({
where: {
terminationRequestId: termination.id,
userId: req.user.id,
action: 'PARTIAL_APPROVE',
'details.stage': sourceStage,
...rbmRoundTime
},
transaction
});
if (existingUserApproval) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'You have already recorded your approval for this stage.' });
}
// 1. Record this partial approval in Audit Logs
await db.TerminationAudit.create({
userId: req.user.id,
terminationRequestId: termination.id,
action: 'PARTIAL_APPROVE',
remarks: `Partial approval by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
details: { roleCode: req.user.roleCode, stage: sourceStage }
}, { transaction });
// 2. Check for both RBM and DD_ZM approvals in this stage
const requiredRoles = [ROLES.RBM, ROLES.DD_ZM];
const partialLogs = await db.TerminationAudit.findAll({
where: {
terminationRequestId: termination.id,
action: 'PARTIAL_APPROVE',
'details.stage': sourceStage,
...rbmRoundTime
},
transaction
});
const approvedRoles = partialLogs.map(log => (log as any).details?.roleCode);
const isComplete = requiredRoles.every(role => approvedRoles.includes(role));
if (!isComplete) {
// Record partial approval in timeline ONLY if not complete yet
// (The final approver's entry will be handled by transitionTermination)
const partialTimeline = [...(termination.timeline || []), {
stage: sourceStage,
timestamp: new Date(),
user: req.user.fullName,
role: req.user.roleCode,
action: 'Partial Approved',
remarks: remarks || `Partial approval recorded by ${req.user.roleCode}`
}];
await termination.update({ timeline: partialTimeline }, { transaction });
await transaction.commit();
return res.json({
success: true,
message: `Partial approval recorded. Waiting for ${requiredRoles.find(r => !approvedRoles.includes(r))} approval to proceed to ZBH Review.`,
isPartial: true
});
}
logger.info(`[TerminationController] Joint approval complete for ${termination.requestId}. Moving to ${nextStage}.`);
}
// SRS §4.3.2.9 — JOINT APPROVAL LOGIC FOR SCN EVALUATION (PERSONAL HEARING STAGE)
if (sourceStage === TERMINATION_STAGES.PERSONAL_HEARING && req.user.roleCode !== ROLES.SUPER_ADMIN) {
const scnEvalAuditStages = [TERMINATION_STAGES.PERSONAL_HEARING, 'Personal Hearing'];
const scnRoundTime = buildJointRoundCreatedAtFilter(
getJointRoundCutoffMsFromTimeline(termination.timeline, 'scn_response_eval')
);
const existingUserApproval = await db.TerminationAudit.findOne({
where: {
terminationRequestId: termination.id,
userId: req.user.id,
action: 'PARTIAL_APPROVE',
[Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })),
...scnRoundTime
},
transaction
});
if (existingUserApproval) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'You have already recorded your approval for this stage.' });
}
await db.TerminationAudit.create({
userId: req.user.id,
terminationRequestId: termination.id,
action: 'PARTIAL_APPROVE',
remarks: `SCN Response Review by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
details: { roleCode: req.user.roleCode, stage: sourceStage }
}, { transaction });
const requiredRoles = [ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD];
const partialLogs = await db.TerminationAudit.findAll({
where: {
terminationRequestId: termination.id,
action: 'PARTIAL_APPROVE',
[Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })),
...scnRoundTime
},
transaction
});
const approvedRoles = partialLogs.map(log => (log as any).details?.roleCode);
const isComplete = requiredRoles.every(role => approvedRoles.includes(role));
if (!isComplete) {
const partialTimeline = [...(termination.timeline || []), {
stage: sourceStage,
timestamp: new Date(),
user: req.user.fullName,
role: req.user.roleCode,
action: 'Partial Approved (SCN Review)',
remarks: remarks || `Review recorded by ${req.user.roleCode}`
}];
await termination.update({ timeline: partialTimeline }, { transaction });
await transaction.commit();
return res.json({
success: true,
message: `Review recorded. Waiting for ${requiredRoles.filter(r => !approvedRoles.includes(r)).join(', ')} approval to proceed to NBH Final Approval.`,
isPartial: true
});
}
logger.info(`[TerminationController] SCN Joint evaluation complete for ${termination.requestId}. Moving to ${nextStage}.`);
}
approvedToStage = nextStage;
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
remarks,
status: getTerminationStatusForStage(nextStage)
remarks: remarks || `Jointly approved by RBM & DD-ZM`,
status: getTerminationStatusForStage(nextStage),
transaction
});
// If Terminated, trigger F&F initiation via Workflow Service
@ -435,6 +655,11 @@ export const submitScnResponse = async (req: AuthRequest, res: Response, next: N
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
if (!authorizedRoles.includes(req.user.roleCode as any)) {
return res.status(403).json({ success: false, message: 'Direct SCN submission is restricted. Please submit your response to DD Admin.' });
}
const { terminationRequestId, responseBody, documents } = req.body;
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
@ -472,6 +697,45 @@ export const issueScn = async (req: AuthRequest, res: Response, next: NextFuncti
status: 'Show Cause Notice',
remarks: remarks || 'Show Cause Notice issued'
});
// SRS §4.3.2.8 — SCN issued: send official email to dealer (no WhatsApp)
const dealer = await db.Dealer.findByPk(termination.dealerId, {
include: [{ model: db.User, as: 'user', attributes: ['email', 'mobileNumber', 'fullName'] }]
});
const dealerUser = (dealer as any)?.user;
if (dealerUser?.email) {
sendEmail(
dealerUser.email,
`Show Cause Notice: ${termination.requestId}`,
'TERMINATION_SCN_ISSUED',
{
dealerName: dealerUser.fullName || 'Dealer',
requestId: termination.requestId,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/dealer-termination/${termination.id}`,
ctaLabel: 'View Notice'
}
).catch((e: any) => logger.error('[Termination] SCN email to dealer failed:', e));
}
// Notify DD-Admin + Legal of SCN issuance
const scnAlertRoles = [ROLES.DD_ADMIN, ROLES.LEGAL_ADMIN];
for (const role of scnAlertRoles) {
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
for (const u of roleUsers) {
NotificationService.notify(u.id, u.email, {
title: `SCN Issued: ${termination.requestId}`,
message: `Show Cause Notice has been issued for termination case ${termination.requestId}.`,
channels: ['system', 'email'],
templateCode: 'TERMINATION_SCN_ISSUED',
placeholders: {
dealerName: dealerUser?.fullName || '',
requestId: termination.requestId,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/termination/${termination.id}`,
ctaLabel: 'View Case'
}
}).catch((e: any) => logger.error('[Termination] SCN admin/legal notify failed:', e));
}
}
}
await transaction.commit();
@ -488,6 +752,11 @@ export const uploadScnResponse = async (req: AuthRequest, res: Response, next: N
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
if (!authorizedRoles.includes(req.user.roleCode as any)) {
return res.status(403).json({ success: false, message: 'Only DD Admin or DD Lead can upload the dealer SCN response.' });
}
const { id } = req.params;
const { remarks } = req.body;
const resolvedId = await resolveTerminationUuid(String(id));

View File

@ -10,7 +10,7 @@ const checkInterviews = async () => {
}]
});
console.log(`--- Interviews ---`);
// console.log(`--- Interviews ---`);
interviews.forEach((i: any) => {
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
i.participants?.forEach((p: any) => {

View File

@ -14,13 +14,16 @@ const seedInterviewTemplates = async () => {
<h2>Dear {{applicantName}},</h2>
<p>Your <strong>{{type}}</strong> for Royal Enfield Dealership Application ({{applicationId}}) has been scheduled.</p>
<p><strong>Scheduled Time:</strong> {{scheduledAt}}</p>
<p><strong>Meeting Link/Location:</strong> {{link}}</p>
<p><strong>Meeting Link/Location:</strong> {{meetLink}}</p>
<p>Please ensure you are available at the scheduled time.</p>
<p><a href="{{link}}">Join Interview / View Details</a></p>
<div style="margin: 20px 0;">
<a href="{{meetLink}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin-right: 10px;">Join Meeting</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">View Application</a>
</div>
<p>Best Regards,<br>Royal Enfield Onboarding Team</p>
</body></html>
`,
placeholders: ['applicantName', 'applicationId', 'type', 'scheduledAt', 'link']
placeholders: ['applicantName', 'applicationId', 'type', 'scheduledAt', 'meetLink', 'appLink']
},
{
templateCode: 'INTERVIEW_SCHEDULED_PANELIST',
@ -32,13 +35,84 @@ const seedInterviewTemplates = async () => {
<p>You have been assigned as a panelist for <strong>{{type}}</strong> with <strong>{{applicantName}}</strong>.</p>
<p><strong>Application ID:</strong> {{applicationId}}</p>
<p><strong>Scheduled Time:</strong> {{scheduledAt}}</p>
<p><strong>Meeting Link/Location:</strong> {{link}}</p>
<p><a href="{{link}}">Open Assessment Dashboard</a></p>
<p><strong>Meeting Link/Location:</strong> {{meetLink}}</p>
<div style="margin: 20px 0;">
<a href="{{meetLink}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin-right: 10px;">Join Meeting</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">Open Assessment Dashboard</a>
</div>
<p>Please review the applicant's profile before the session.</p>
<p>Regards,<br>System Administrator</p>
</body></html>
`,
placeholders: ['panelistName', 'applicantName', 'applicationId', 'type', 'scheduledAt', 'link']
placeholders: ['panelistName', 'applicantName', 'applicationId', 'type', 'scheduledAt', 'meetLink', 'appLink']
},
{
templateCode: 'INTERVIEW_RESCHEDULED_APPLICANT',
description: 'Notification sent to the applicant when an interview is rescheduled',
subject: 'Interview Rescheduled: {{applicationId}}',
body: `
<html><body>
<h2>Dear {{applicantName}},</h2>
<p>Your <strong>{{type}}</strong> for Royal Enfield Dealership Application ({{applicationId}}) has been <strong>rescheduled</strong>.</p>
<p><strong>New Scheduled Time:</strong> {{scheduledAt}}</p>
<p><strong>Meeting Link/Location:</strong> {{meetLink}}</p>
<p>Please ensure you are available at the new scheduled time.</p>
<div style="margin: 20px 0;">
<a href="{{meetLink}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin-right: 10px;">Join Meeting</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">View Application</a>
</div>
<p>Best Regards,<br>Royal Enfield Onboarding Team</p>
</body></html>
`,
placeholders: ['applicantName', 'applicationId', 'type', 'scheduledAt', 'meetLink', 'appLink']
},
{
templateCode: 'INTERVIEW_RESCHEDULED_PANELIST',
description: 'Notification sent to the panelist when an interview is rescheduled',
subject: 'Interview Rescheduled: {{applicationId}}',
body: `
<html><body>
<h2>Hi {{panelistName}},</h2>
<p>The <strong>{{type}}</strong> for <strong>{{applicantName}}</strong> ({{applicationId}}) has been <strong>rescheduled</strong>.</p>
<p><strong>New Scheduled Time:</strong> {{scheduledAt}}</p>
<p><strong>Meeting Link/Location:</strong> {{meetLink}}</p>
<div style="margin: 20px 0;">
<a href="{{meetLink}}" style="background: #c8102e; color: #fff; padding: 10px 20px; text-decoration: none; border-radius: 5px; margin-right: 10px;">Join Meeting</a>
<a href="{{appLink}}" style="background: #f4f4f4; color: #333; padding: 10px 20px; text-decoration: none; border-radius: 5px; border: 1px solid #ccc;">Open Assessment Dashboard</a>
</div>
<p>Please update your calendar accordingly.</p>
<p>Regards,<br>System Administrator</p>
</body></html>
`,
placeholders: ['panelistName', 'applicantName', 'applicationId', 'type', 'scheduledAt', 'meetLink', 'appLink']
},
{
templateCode: 'INTERVIEW_CANCELLED_APPLICANT',
description: 'Notification sent to the applicant when an interview is cancelled',
subject: 'Interview Cancelled: {{applicationId}}',
body: `
<html><body>
<h2>Dear {{applicantName}},</h2>
<p>We inform you that your <strong>{{type}}</strong> for Royal Enfield Dealership Application ({{applicationId}}) has been <strong>cancelled</strong>.</p>
<p>Our team will reach out to you if a new session is required.</p>
<p>Best Regards,<br>Royal Enfield Onboarding Team</p>
</body></html>
`,
placeholders: ['applicantName', 'applicationId', 'type']
},
{
templateCode: 'INTERVIEW_CANCELLED_PANELIST',
description: 'Notification sent to the panelist when an interview is cancelled',
subject: 'Interview Cancelled: {{applicationId}}',
body: `
<html><body>
<h2>Hi {{panelistName}},</h2>
<p>The <strong>{{type}}</strong> for <strong>{{applicantName}}</strong> ({{applicationId}}) has been <strong>cancelled</strong>.</p>
<p>You no longer need to attend this session.</p>
<p>Regards,<br>System Administrator</p>
</body></html>
`,
placeholders: ['panelistName', 'applicantName', 'applicationId', 'type']
},
{
templateCode: 'WORKFLOW_ACTION_REQUIRED',

View File

@ -87,6 +87,13 @@ const seedTemplates = async () => {
fileName: 'onboarding_status_update.html',
placeholders: ['applicantName', 'applicationId', 'status', 'reason', 'salesCode', 'serviceCode']
},
{
templateCode: 'EOR_COMPLETED',
description: 'Notification when EOR readiness is 100% complete',
subject: 'EOR Readiness 100% Completed: {{applicationId}}',
fileName: 'eor_completed.html',
placeholders: ['applicantName', 'applicationId', 'location']
},
{
templateCode: 'RESIGNATION_SUBMITTED',
description: 'Notification for new Resignation submission',
@ -101,12 +108,47 @@ const seedTemplates = async () => {
fileName: 'resignation_approved.html',
placeholders: ['dealerName', 'resignationId', 'lwd']
},
{
templateCode: 'TERMINATION_INITIATED',
description: 'Notification for new Termination request initiation',
subject: 'Dealer Termination Case Initiated — {{requestId}}',
fileName: 'termination_initiated.html',
placeholders: ['recipientName', 'dealerName', 'requestId', 'category', 'initiatedBy', 'currentStage', 'remarks', 'link', 'ctaLabel']
},
{
templateCode: 'TERMINATION_SCN_ISSUED',
description: 'Notification for Show Cause Notice issuance',
subject: 'URGENT: Show Cause Notice Issued: {{terminationId}}',
fileName: 'termination_scn.html',
placeholders: ['dealerName', 'terminationId', 'deadline']
placeholders: ['dealerName', 'terminationId', 'deadline', 'link', 'ctaLabel']
},
{
templateCode: 'FNF_INITIATED',
description: 'Notification for F&F Settlement initiation',
subject: 'F&F Settlement Initiated — {{requestId}}',
fileName: 'fnf_initiated.html',
placeholders: ['recipientName', 'dealerName', 'requestId', 'initiatedBy', 'lwd', 'link', 'ctaLabel']
},
{
templateCode: 'FNF_SUMMARY_PREPARED',
description: 'Notification to Finance team when F&F summary is ready for review',
subject: 'F&F Settlement Summary Prepared: {{fnfId}}',
fileName: 'fnf_summary_prepared.html',
placeholders: ['fnfId', 'dealerName', 'netAmount', 'link', 'ctaLabel']
},
{
templateCode: 'FNF_SETTLEMENT_APPROVED',
description: 'Notification when Finance approves the final settlement',
subject: 'F&F Settlement Approved: {{fnfId}}',
fileName: 'fnf_settlement_approved.html',
placeholders: ['fnfId', 'dealerName', 'settlementAmount', 'link', 'ctaLabel']
},
{
templateCode: 'ONBOARDING_PAYMENT_VERIFIED',
description: 'Notification when security deposit or initial payment is verified',
subject: 'Payment Verified: {{applicationId}}',
fileName: 'onboarding_payment_verified.html',
placeholders: ['applicationId', 'dealerName', 'paymentType', 'amount', 'link']
},
{
templateCode: 'WORKNOTE_NOTIFICATION',
@ -143,6 +185,20 @@ const seedTemplates = async () => {
fileName: 'resignation_update.html',
placeholders: ['dealerName', 'status', 'remarks']
},
{
templateCode: 'TERMINATION_LETTER_ISSUED',
description: 'Internal alert to DD-Lead, Admin, and Finance when termination letter is generated',
subject: 'Termination Letter Generated: {{requestId}}',
fileName: 'termination_letter_issued.html',
placeholders: ['recipientName', 'dealerName', 'requestId', 'link', 'ctaLabel']
},
{
templateCode: 'TERMINATION_FINAL_CLOSURE_DEALER',
description: 'Final closure notification sent to the dealer upon termination completion',
subject: 'Official Notice: Termination of Dealership — {{requestId}}',
fileName: 'termination_final_closure.html',
placeholders: ['dealerName', 'requestId', 'terminationDate', 'link', 'ctaLabel']
},
{
templateCode: 'TERMINATION_UPDATE',
description: 'General status update for Termination',
@ -150,6 +206,20 @@ const seedTemplates = async () => {
fileName: 'termination_update.html',
placeholders: ['dealerName', 'status', 'remarks']
},
{
templateCode: 'INAUGURATION_COMPLETED',
description: 'Notification to relevant teams when a dealership inauguration is logged',
subject: 'Dealership Inauguration Logged: {{applicationId}}',
fileName: 'inauguration_completed.html',
placeholders: ['dealerName', 'applicationId', 'location', 'date']
},
{
templateCode: 'CONSTITUTIONAL_CHANGE_APPROVED',
description: 'Final approval notification for Constitutional Change',
subject: 'Constitutional Change Approved — {{requestId}}',
fileName: 'constitutional_change_approved.html',
placeholders: ['dealerName', 'requestId', 'proposedConstitution', 'link']
},
{
templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE',
description: 'General status update for Constitutional Change',
@ -178,6 +248,13 @@ const seedTemplates = async () => {
fileName: 'relocation_submitted.html',
placeholders: ['dealerName', 'requestId', 'outletCode']
},
{
templateCode: 'RELOCATION_APPROVED',
description: 'Final approval notification for Relocation',
subject: 'Relocation Approved — {{requestId}}',
fileName: 'relocation_approved.html',
placeholders: ['dealerName', 'requestId', 'newLocation', 'link']
},
{
templateCode: 'RELOCATION_UPDATE',
description: 'Dealer-visible status updates during relocation workflow',
@ -212,6 +289,20 @@ const seedTemplates = async () => {
subject: 'SLA ESCALATION [L{{level}}]: {{applicationId}} — {{stageName}}',
fileName: 'sla_escalation.html',
placeholders: ['applicationId', 'stageName', 'level', 'timeValue', 'timeUnit', 'link']
},
{
templateCode: 'WORKFLOW_ACTION_REQUIRED',
description: 'Turn-based notification for next reviewer/actor in any workflow',
subject: 'Action Required: {{requestId}} — {{targetStage}}',
fileName: 'workflow_action_required.html',
placeholders: ['requestId', 'dealerName', 'targetStage', 'remarks', 'link', 'ctaLabel']
},
{
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
description: 'Status update notification sent to dealer for interim workflow stages',
subject: 'Update on Your Request — {{requestId}}',
fileName: 'workflow_status_update_dealer.html',
placeholders: ['dealerName', 'requestId', 'targetStage', 'remarks', 'link', 'ctaLabel']
}
];

View File

@ -0,0 +1,316 @@
/**
* @file seed-missing-templates.ts
* @description Seeds the 6 email templates that are referenced in workflow code
* but were missing from the DB and the allowed-email-template-codes list.
*
* Run with: npx ts-node src/scripts/seed-missing-templates.ts
*/
import db from '../database/models/index.js';
const seedMissingTemplates = async () => {
try {
console.log('--- Seeding Missing Workflow Email Templates ---');
const templates = [
// ── 1. Workflow Action Required ────────────────────────────────────────
// Used in: workflow-email-notifications.ts (next-actor, send-back notifications)
// constitutional.controller.ts
// Recipients: DD-ZM, RBM, ZBH, DD-Lead, DD-Head, NBH, Legal, Finance, FDD
// Channels: system + email + whatsapp (when phone available)
{
templateCode: 'WORKFLOW_ACTION_REQUIRED',
description: 'Sent to the next actor in any workflow when their action is required. Used across Onboarding, Resignation, Termination, Constitutional, and Relocation.',
subject: 'Action Required: {{requestId}} — {{targetStage}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#c8102e;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;">Action Required</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<p>Hi,</p>
<p>
The request <strong>{{requestId}}</strong>
{{#if dealerName}}(Dealer: <strong>{{dealerName}}</strong>){{/if}}
has reached the <strong>{{targetStage}}</strong> stage and requires your review and action.
</p>
{{#if remarks}}
<div style="background:#fff8e1;border-left:4px solid #fb8c00;padding:12px 16px;margin:16px 0;">
<strong>Remarks:</strong><br/>{{remarks}}
</div>
{{/if}}
<p>Please log in to the RE Dealer Management Portal to review and act:</p>
<div style="text-align:center;margin:24px 0;">
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
{{#if ctaLabel}}{{ctaLabel}}{{else}}Review & Take Action{{/if}}
</a>
</div>
<p style="color:#888;font-size:12px;">This is an automated notification. Do not reply to this email.</p>
</div>
</div>`,
placeholders: ['requestId', 'dealerName', 'targetStage', 'link', 'remarks', 'ctaLabel']
},
// ── 2. Workflow Status Update — Dealer ─────────────────────────────────
// Used in: workflow-email-notifications.ts (dealer on interim + terminal events)
// Recipients: Dealer / Applicant
// Channels: system always; email+whatsapp on terminal (rejection/completion)
{
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
description: 'Milestone/status update sent to the dealer or applicant when their request moves to a new stage.',
subject: 'Update on Your Request — {{requestId}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#c8102e;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;">Request Status Update</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<p>Dear {{dealerName}},</p>
<p>
Your request <strong>{{requestId}}</strong> has been updated.
Current status: <strong>{{targetStage}}</strong>.
</p>
{{#if remarks}}
<div style="background:#f0f4ff;border-left:4px solid #3d6be8;padding:12px 16px;margin:16px 0;">
<strong>Note:</strong> {{remarks}}
</div>
{{/if}}
<p>Track the live progress of your request:</p>
<div style="text-align:center;margin:24px 0;">
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
View Request
</a>
</div>
<p style="color:#888;font-size:12px;">
For queries, contact your assigned Area Sales Manager.
</p>
</div>
</div>`,
placeholders: ['requestId', 'dealerName', 'targetStage', 'link', 'remarks']
},
// ── 3. F&F Initiated ───────────────────────────────────────────────────
// Used in: resignation.controller.ts (when F&F is triggered on LWD)
// termination.controller.ts (post-termination F&F)
// Recipients: Finance team, Department heads, DD-Admin
// Channels: system + email
{
templateCode: 'FNF_INITIATED',
description: 'Notifies Finance and department heads when Full & Final (F&F) settlement is triggered on the Last Working Day.',
subject: 'F&F Settlement Initiated — {{requestId}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#c8102e;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;">Full &amp; Final Settlement Initiated</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<p>Dear {{recipientName}},</p>
<p>
The Full &amp; Final (F&amp;F) settlement process has been initiated for dealer
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>).
</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Request ID</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{requestId}}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ddd;"><strong>Dealer Name</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{dealerName}}</td>
</tr>
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Last Working Day</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{lwd}}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ddd;"><strong>Initiated By</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{initiatedBy}}</td>
</tr>
</table>
<p>Please update your department clearance status promptly.</p>
<div style="text-align:center;margin:24px 0;">
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
View F&amp;F Settlement
</a>
</div>
<p style="color:#888;font-size:12px;">
Per Royal Enfield policy, F&amp;F settlement is initiated only on or after the Last Working Day.
</p>
</div>
</div>`,
placeholders: ['recipientName', 'dealerName', 'requestId', 'lwd', 'initiatedBy', 'link']
},
// ── 4. EOR Completed ───────────────────────────────────────────────────
// Used in: eor.controller.ts (when all EOR checklist items are complete)
// Recipients: DD-Head, NBH
// Channels: system (alert only — no email per SRS §6.19.3.4 design choice)
// Adding email template so Admin can optionally enable
{
templateCode: 'EOR_COMPLETED',
description: 'Alert to DD-Head and NBH when 100% of EOR (Essential Operating Requirements) checklist items are verified. Signals readiness for Inauguration stage.',
subject: 'EOR Checklist Complete — Ready for Inauguration: {{requestId}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#2e7d32;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;"> EOR Checklist 100% Complete</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<p>Dear {{recipientName}},</p>
<p>
All <strong>Essential Operating Requirements (EOR)</strong> for dealer application
<strong>{{requestId}}</strong> (Applicant: <strong>{{applicantName}}</strong>)
have been completed and verified.
</p>
<div style="background:#e8f5e9;border-left:4px solid #43a047;padding:12px 16px;margin:16px 0;">
<strong>EOR Status: 100% Complete</strong><br/>
The dealership is now ready for Inauguration review.
</div>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Application ID</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{requestId}}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ddd;"><strong>Applicant</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{applicantName}}</td>
</tr>
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Location</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{location}}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ddd;"><strong>Completed On</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{completedOn}}</td>
</tr>
</table>
<p>Please review and authorize the <strong>Inauguration</strong> stage to mark this dealership as Live.</p>
<div style="text-align:center;margin:24px 0;">
<a href="{{link}}" style="background:#2e7d32;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
Authorize Inauguration
</a>
</div>
</div>
</div>`,
placeholders: ['recipientName', 'applicantName', 'requestId', 'location', 'completedOn', 'link']
},
// ── 5. Applicant Rejected ──────────────────────────────────────────────
// Used in: WorkflowService.transitionApplication (any rejection)
// Recipients: Applicant (external)
// Channels: email (SRS §6.12.3 — rejection via email)
// WhatsApp if mobileNumber present
{
templateCode: 'APPLICANT_REJECTED',
description: 'Sent to the applicant/dealer when their application is rejected at any stage of the onboarding process.',
subject: 'Update on Your Dealership Application — {{applicationId}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#b71c1c;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;">Application Status Update</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<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 style="background:#fff3f3;border-left:4px solid #e53935;padding:12px 16px;margin:16px 0;">
<strong>Reason:</strong><br/>{{rejectionReason}}
</div>
{{/if}}
<p>
We appreciate your interest in partnering with Royal Enfield. You may reapply
when opportunities are available in your area in the future.
</p>
<p>
For any queries, contact us at
<a href="mailto:dealer-support@royalenfield.com">dealer-support@royalenfield.com</a>.
</p>
<p>Best Regards,<br/><strong>Royal Enfield Dealer Development Team</strong></p>
</div>
</div>`,
placeholders: ['applicantName', 'applicationId', 'location', 'rejectionReason']
},
// ── 6. Termination Initiated ───────────────────────────────────────────
// Used in: termination.controller.ts (case creation)
// Recipients: RBM, DD-ZM, ZBH, DD-Lead, Legal, NBH
// Channels: system + email + whatsapp
{
templateCode: 'TERMINATION_INITIATED',
description: 'Notifies internal stakeholders (RBM, DD-ZM, ZBH, Legal, NBH) when a dealer termination case is formally initiated by ASM.',
subject: 'Dealer Termination Case Initiated — {{requestId}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#4a148c;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;">Dealer Termination Case Initiated</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #eee;">
<p>Dear {{recipientName}},</p>
<p>
A formal termination process has been initiated for dealer
<strong>{{dealerName}}</strong> (Request ID: <strong>{{requestId}}</strong>).
</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Request ID</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{requestId}}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ddd;"><strong>Dealer Name</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{dealerName}}</td>
</tr>
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Termination Category</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{category}}</td>
</tr>
<tr>
<td style="padding:8px;border:1px solid #ddd;"><strong>Initiated By</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{initiatedBy}}</td>
</tr>
<tr style="background:#f0f0f0;">
<td style="padding:8px;border:1px solid #ddd;"><strong>Current Stage</strong></td>
<td style="padding:8px;border:1px solid #ddd;">{{currentStage}}</td>
</tr>
</table>
{{#if remarks}}
<div style="background:#fff8e1;border-left:4px solid #fb8c00;padding:12px 16px;margin:16px 0;">
<strong>Remarks:</strong><br/>{{remarks}}
</div>
{{/if}}
<p>Please review the case and provide your evaluation with mandatory work notes.</p>
<div style="text-align:center;margin:24px 0;">
<a href="{{link}}" style="background:#4a148c;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
Review Termination Case
</a>
</div>
<p style="color:#888;font-size:12px;">
This notification is confidential. Do not share externally without authorization.
</p>
</div>
</div>`,
placeholders: ['recipientName', 'dealerName', 'requestId', 'category', 'initiatedBy', 'currentStage', 'remarks', 'link']
}
];
for (const t of templates) {
const [, created] = await db.EmailTemplate.upsert({
...t,
isActive: true
});
console.log(`${created ? 'Created' : 'Updated'}: ${t.templateCode}`);
}
console.log('\n--- Missing Templates Seeded Successfully ---');
console.log(`Total: ${templates.length} templates seeded.`);
process.exit(0);
} catch (error) {
console.error('Error seeding missing templates:', error);
process.exit(1);
}
};
seedMissingTemplates();

View File

@ -6,6 +6,55 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
export class ConstitutionalWorkflowService {
private static normalizeDocLabel(input: string): string {
return String(input || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
private static extractUploadedDocumentLabels(documents: any[]): string[] {
if (!Array.isArray(documents)) return [];
return documents
.flatMap((doc: any) => [
doc?.documentType,
doc?.type,
doc?.name,
doc?.title,
doc?.label,
doc?.fileName,
typeof doc === 'string' ? doc : null
])
.filter((v: any) => typeof v === 'string' && v.trim().length > 0)
.map((v: string) => this.normalizeDocLabel(v));
}
static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] {
const checklist = this.getDocumentChecklist(targetConstitution);
const uploaded = this.extractUploadedDocumentLabels(documents);
const hasToken = (aliases: string[]) => aliases.some((a) => uploaded.some((u) => u.includes(a)));
const aliasesByRequirement: Record<string, string[]> = {
[DOCUMENT_TYPES.GST_CERTIFICATE]: ['gst'],
[DOCUMENT_TYPES.PAN_CARD]: ['pan', 'firm pan'],
[DOCUMENT_TYPES.AADHAAR]: ['aadhaar', 'kyc'],
[DOCUMENT_TYPES.CANCELLED_CHECK]: ['cancelled cheque', 'canceled cheque', 'cancelled check', 'cancelled'],
[DOCUMENT_TYPES.OTHER]: ['declaration', 'authorization', 'authorisation'],
[DOCUMENT_TYPES.PARTNERSHIP_DEED]: ['partnership deed', 'partnership agreement'],
[DOCUMENT_TYPES.FIRM_REGISTRATION]: ['firm registration'],
[DOCUMENT_TYPES.LLP_AGREEMENT]: ['llp agreement'],
[DOCUMENT_TYPES.INCORPORATION_CERTIFICATE]: ['incorporation', 'coi'],
[DOCUMENT_TYPES.MOA]: ['moa'],
[DOCUMENT_TYPES.AOA]: ['aoa'],
'Business Purchase Agreement (BPA)': ['business purchase agreement', 'bpa']
};
return checklist.filter((required) => {
const tokens = aliasesByRequirement[required] || [this.normalizeDocLabel(required)];
return !hasToken(tokens.map((t) => this.normalizeDocLabel(t)));
});
}
/**
* Transitions a constitutional change request to a new stage
*/
@ -118,7 +167,8 @@ export class ConstitutionalWorkflowService {
actionUserFullName: userFullName || 'System',
action: action || `Moved to ${targetStage}`,
remarks: remarkText,
link: `${portalBase}/constitutional-change/${request.id}`
link: `${portalBase}/constitutional-change/${request.id}`,
changeType: request.changeType
}
);
}

View File

@ -51,7 +51,7 @@ export class NotificationService {
}
}
// 2. Offload other channels to Job Queue (BullMQ)
// 2. Offload other channels to Job Queue (BullMQ) or Send Synchronously if Redis is disabled
const asyncChannels = channels.filter(c => c !== 'system');
if (asyncChannels.length > 0) {
if (process.env.ENABLE_REDIS === 'true') {
@ -67,7 +67,18 @@ export class NotificationService {
metadata
});
} else {
console.log(`[Notification Service] Redis disabled. Skipping async channels: ${asyncChannels.join(', ')}`);
console.log(`[Notification Service] Redis disabled. Sending ${asyncChannels.join(', ')} synchronously...`);
// Fallback: Process immediately if queueing is disabled
await this.processJob({
userId,
email,
title,
message,
channels: asyncChannels,
templateCode,
placeholders,
metadata
});
}
}
}

View File

@ -129,7 +129,6 @@ export class ParticipantService {
// 2. National roles
const nationalRoles = [
ROLES.DD_LEAD,
ROLES.DD_HEAD,
ROLES.NBH,
ROLES.CCO,
ROLES.CEO,
@ -347,6 +346,9 @@ export class ParticipantService {
const outlet = (relocation as any).outlet;
if (outlet && outlet.district) {
const district = outlet.district;
// Canonical ASM for relocation visibility/actions is district.asmId (see relocation.controller getRequests / evaluators).
// Dealer.asmId may differ or be unset while district.asmId is set.
if (district.asmId) participantIds.add(district.asmId);
if (relocation.dealerId) {
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
if (dealerUser?.dealerId) {

View File

@ -1,5 +1,5 @@
import db from '../database/models/index.js';
const { RelocationRequest, AuditLog, User } = db;
const { RelocationRequest, AuditLog, User, RequestParticipant, Outlet } = db;
import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
@ -131,10 +131,8 @@ export class RelocationWorkflowService {
[RELOCATION_STAGES.DD_ZM_REVIEW]: ROLES.DD_ZM,
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: ROLES.DD_HEAD,
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN,
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: ROLES.NBH
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN
};
const requiredRole = stageMapping[request.currentStage];
@ -143,10 +141,65 @@ export class RelocationWorkflowService {
// Role-based check
if (user.roleCode !== requiredRole) return false;
// Optional: Hierarchy check
// We could verify if the user is the SPECIFIC person assigned in participants
// but for now, any user with the correct role can act (consistent with simple RBAC)
// Stage-specific participant assignment enforcement: actor must be mapped on this request.
const participant = await RequestParticipant.findOne({
where: {
requestId: request.id,
requestType: REQUEST_TYPES.RELOCATION,
userId: user.id
},
attributes: ['id']
});
if (participant) return true;
const anyParticipant = await RequestParticipant.findOne({
where: { requestId: request.id, requestType: REQUEST_TYPES.RELOCATION },
attributes: ['id']
});
// Backward compatibility for legacy requests created before participant auto-assignment.
if (!anyParticipant) return true;
return true;
// Match relocation.controller getRequests: territory actors must act even if RequestParticipant
// omitted them (e.g. ASM was taken only from Dealer.asmId while list uses district.asmId).
return await RelocationWorkflowService.userMatchesRelocationOutletHierarchy(request.id, user);
}
/** Same outlet hierarchy checks as internal-user filter on relocation list (ASM/RBM/DD-ZM/ZBH). */
static async userMatchesRelocationOutletHierarchy(requestId: string, user: any): Promise<boolean> {
const row = await RelocationRequest.findByPk(requestId, {
include: [
{
model: User,
as: 'dealer',
attributes: ['id'],
include: [{ model: db.Dealer, as: 'dealerProfile', attributes: ['asmId'] }]
},
{
model: Outlet,
as: 'outlet',
include: [
{
model: db.District,
as: 'district',
include: [
{ model: db.Region, as: 'region' },
{ model: db.Zone, as: 'zone' }
]
}
]
}
]
});
if (!row) return false;
const outlet = (row as any).outlet;
const district = outlet?.district;
if (!district) return false;
const uid = user.id;
const rc = user.roleCode;
const outletLevelAsmId = (row as any).dealer?.dealerProfile?.asmId ?? null;
if (rc === ROLES.ASM && (outletLevelAsmId === uid || district.asmId === uid)) return true;
if (rc === ROLES.RBM && district.region?.rbmId === uid) return true;
if (rc === ROLES.DD_ZM && district.zmId === uid) return true;
if (rc === ROLES.ZBH && district.zone?.zbhId === uid) return true;
return false;
}
}

View File

@ -1,12 +1,13 @@
import db from '../database/models/index.js';
const { User } = db;
import { RESIGNATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
import { RESIGNATION_STAGES, ROLES, REQUEST_TYPES, FNF_DEPARTMENTS } from '../common/config/constants.js';
import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js';
import { NotificationService } from './NotificationService.js';
import { Op } from 'sequelize';
import { Op, Transaction } from 'sequelize';
import logger from '../common/utils/logger.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { NomenclatureService } from '../common/utils/nomenclature.js';
export class ResignationWorkflowService {
@ -131,9 +132,9 @@ export class ResignationWorkflowService {
[RESIGNATION_STAGES.RBM]: 30,
[RESIGNATION_STAGES.ZBH]: 40,
[RESIGNATION_STAGES.DD_LEAD]: 50,
[RESIGNATION_STAGES.NBH]: 60,
[RESIGNATION_STAGES.DD_ADMIN]: 75,
[RESIGNATION_STAGES.LEGAL]: 85,
[RESIGNATION_STAGES.NBH]: 65,
[RESIGNATION_STAGES.LEGAL]: 80,
[RESIGNATION_STAGES.DD_ADMIN]: 90,
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
[RESIGNATION_STAGES.COMPLETED]: 100,
[RESIGNATION_STAGES.REJECTED]: 100
@ -148,18 +149,83 @@ export class ResignationWorkflowService {
if (!user) return false;
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
const stageToRole: Record<string, string> = {
const stageToRole: Record<string, string | string[]> = {
[RESIGNATION_STAGES.ASM]: ROLES.ASM,
[RESIGNATION_STAGES.RBM]: ROLES.RBM,
[RESIGNATION_STAGES.RBM]: [ROLES.RBM, ROLES.DD_ZM],
[RESIGNATION_STAGES.ZBH]: ROLES.ZBH,
[RESIGNATION_STAGES.DD_LEAD]: ROLES.DD_LEAD,
[RESIGNATION_STAGES.NBH]: ROLES.NBH,
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
[RESIGNATION_STAGES.LEGAL]: ROLES.LEGAL_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
[RESIGNATION_STAGES.FNF_INITIATED]: ROLES.DD_ADMIN
};
const requiredRole = stageToRole[resignation.currentStage];
if (Array.isArray(requiredRole)) {
return requiredRole.includes(user.roleCode);
}
return user.roleCode === requiredRole;
}
/**
* Initiates the F&F settlement process for a resignation
* SRS §4.2.2.8 Standardized trigger mechanism
*/
static async initiateFnF(resignation: any, userId: string, transaction: Transaction) {
try {
// 1. Resolve Dealer Entity ID (from User profile)
let dealerEntityId = resignation.dealerId; // Fallback to User ID if not linked, though DB FK prefers dealers.id
if (resignation.dealer && resignation.dealer.dealerId) {
dealerEntityId = resignation.dealer.dealerId;
} else {
// If not eager loaded, fetch the user to get dealerId
const user = await db.User.findByPk(resignation.dealerId);
if (user && user.dealerId) {
dealerEntityId = user.dealerId;
}
}
const fnf = await db.FnF.create({
settlementId: await NomenclatureService.generateFnFId(),
resignationId: resignation.id,
dealerId: dealerEntityId,
outletId: resignation.outletId,
status: 'Initiated',
initiatedAt: new Date(),
initiatedBy: userId,
totalPayables: 0,
totalReceivables: 0,
totalDeductions: 0,
netAmount: 0,
departmentalClearances: {}
}, { transaction });
// 2. Initialize Departmental Clearances
const clearancePromises = FNF_DEPARTMENTS.map(dept =>
db.FffClearance.create({
fnfId: fnf.id,
department: dept,
status: 'Pending',
amount: 0,
remarks: 'Awaiting departmental input'
}, { transaction })
);
await Promise.all(clearancePromises);
// 3. Create Audit Trail
await db.FnFAudit.create({
userId,
fnfId: fnf.id,
action: 'INITIATED',
remarks: 'F&F Settlement workflow triggered from Resignation',
details: { source: 'Resignation Workflow', resignationId: resignation.resignationId }
}, { transaction });
logger.info(`[ResignationWorkflowService] F&F ${fnf.settlementId} initiated for Resignation ${resignation.resignationId}`);
return fnf;
} catch (error) {
logger.error('[ResignationWorkflowService] Failed to initiate F&F:', error);
throw error;
}
}
}

View File

@ -16,7 +16,7 @@ export class TerminationWorkflowService {
* Standardized method to transition a termination request status
*/
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
const { action, remarks, status } = metadata;
const { action, remarks, status, transaction } = metadata;
const sourceStage = termination.currentStage;
const updateData: any = {
@ -35,6 +35,7 @@ export class TerminationWorkflowService {
targetStage: targetStage,
timestamp: new Date(),
user: actor ? actor.fullName : 'System',
role: actor ? actor.roleCode : null,
action: action || `Approved to ${targetStage}`,
remarks: remarks || ''
};
@ -45,7 +46,7 @@ export class TerminationWorkflowService {
await termination.update({
...updateData,
timeline: updatedTimeline
});
}, transaction ? { transaction } : undefined);
// 4. Create Audit Log using standardized mapper
const { actionType } = metadata;
@ -57,7 +58,7 @@ export class TerminationWorkflowService {
action: formatOffboardingAction(auditAction),
remarks: remarks || '',
details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) }
});
}, transaction ? { transaction } : undefined);
// 5. Create Worknote for standardized communication trail
if (remarks && userId) {
@ -227,7 +228,7 @@ export class TerminationWorkflowService {
placeholders: {
dealerName: dealerUser?.fullName || 'Dealer',
requestId: termination.requestId,
link: `${portalBase}/fnf-settlements/${fnf.id}`,
link: `${portalBase}/fnf/${fnf.id}`,
phone: phone || ''
}
});
@ -279,7 +280,7 @@ export class TerminationWorkflowService {
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
action: 'SCN_SUBMITTED',
status: 'Personal Hearing Pending',
status: 'SCN Response Evaluation Pending',
remarks: 'Dealer response submitted'
});
}
@ -300,7 +301,7 @@ export class TerminationWorkflowService {
});
const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL;
const status = recommendation === 'Reject' ? 'Rejected after Hearing' : 'NBH Final Approval Pending';
const status = recommendation === 'Reject' ? 'Rejected after Evaluation' : 'NBH Final Approval Pending';
return this.transitionTermination(termination, nextStage, userId, {
action: `Hearing Recorded - ${recommendation}`,
@ -308,4 +309,39 @@ export class TerminationWorkflowService {
remarks: summary
});
}
/**
* Checks if a user is authorized to perform an action based on their role and current stage
*/
static async canUserAction(termination: any, user: any) {
if (!user) return false;
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
const stageToRole: Record<string, string | string[]> = {
[TERMINATION_STAGES.SUBMITTED]: ROLES.ASM,
[TERMINATION_STAGES.RBM_REVIEW]: [ROLES.RBM, ROLES.DD_ZM],
[TERMINATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
[TERMINATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
[TERMINATION_STAGES.LEGAL_VERIFICATION]: ROLES.LEGAL_ADMIN,
[TERMINATION_STAGES.DD_HEAD_REVIEW]: ROLES.DD_HEAD,
[TERMINATION_STAGES.NBH_EVALUATION]: ROLES.NBH,
[TERMINATION_STAGES.SCN_ISSUED]: [ROLES.LEGAL_ADMIN, ROLES.DD_ADMIN],
[TERMINATION_STAGES.PERSONAL_HEARING]: [ROLES.NBH, ROLES.DD_LEAD, ROLES.RBM, ROLES.ZBH, ROLES.DD_HEAD],
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: ROLES.NBH,
[TERMINATION_STAGES.CCO_APPROVAL]: ROLES.CCO,
[TERMINATION_STAGES.CEO_APPROVAL]: ROLES.CEO,
[TERMINATION_STAGES.LEGAL_LETTER]: ROLES.LEGAL_ADMIN
};
const stageAliases: Record<string, string> = {
'Personal Hearing': TERMINATION_STAGES.PERSONAL_HEARING,
'Show Cause Notice': TERMINATION_STAGES.SCN_ISSUED
};
const normalizedStage = stageAliases[termination.currentStage] || termination.currentStage;
const requiredRole = stageToRole[normalizedStage];
if (Array.isArray(requiredRole)) {
return requiredRole.includes(user.roleCode);
}
return user.roleCode === requiredRole;
}
}

View File

@ -14,6 +14,7 @@ const EMAILS = {
DEALER: args.dealerEmail,
ASM: 'abhishek@royalenfield.com',
RBM: 'manish@royalenfield.com',
DD_ZM: 'piyush@royalenfield.com',
ZBH: 'manav@royalenfield.com',
DD_LEAD: 'jaya@royalenfield.com',
NBH: 'yashwin@royalenfield.com',
@ -158,13 +159,14 @@ async function run() {
await delay();
const approvals = [
{ name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' },
{ name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' },
{ name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' },
{ name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
{ name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
{ name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' },
{ name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' }
{ stage: 'ASM', name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' },
{ stage: 'RBM + DD-ZM Review', name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' },
{ stage: 'RBM + DD-ZM Review', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' },
{ stage: 'ZBH', name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' },
{ stage: 'DD Lead', name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
{ stage: 'NBH', name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
{ stage: 'Legal', name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' },
{ stage: 'DD Admin', name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' }
];
// Fetch resignation data to determine current stage for skipping
@ -174,16 +176,40 @@ async function run() {
console.log(`Current Stage: ${currentStage}`);
const stageOrder = [
'ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal Admin', 'F&F Initiated', 'Completed'
'Request Submitted', 'ASM', 'RBM + DD-ZM Review', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'
];
const startIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage);
let startStageIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage);
const startingStage = stageOrder[startStageIndex];
let startApproveIndex = approvals.findIndex(a => a.stage === startingStage);
if (startApproveIndex === -1) startApproveIndex = 0;
let currentStep = 2;
for (let i = startIndex; i < approvals.length; i++) {
for (let i = startApproveIndex; i < approvals.length; i++) {
const actor = approvals[i];
log(currentStep, `${actor.name} (${actor.email}) approving...`);
const token = await login(actor.email);
// Special Case: Legal Admin must upload 'Resignation Acceptance Letter' before approving
if (actor.stage === 'Legal') {
log(currentStep, `[Legal] Uploading mandatory 'Resignation Acceptance Letter'...`);
const formData = new FormData();
const blob = new Blob(['Mock Acceptance Letter Content'], { type: 'text/plain' });
formData.append('file', blob, 'Acceptance_Letter.txt');
formData.append('documentType', 'Resignation Acceptance Letter');
formData.append('stage', 'Legal');
const uploadRes = await fetch(`${BASE_URL}/self-service/resignations/${resignationId}/documents`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const uploadData = await uploadRes.json();
if (!uploadRes.ok) throw new Error(`Document upload failed: ${JSON.stringify(uploadData)}`);
log(currentStep, `[Legal] Document uploaded successfully.`);
}
const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
remarks: actor.remarks,
force: true

View File

@ -12,8 +12,10 @@ const EMAILS = {
DD_ADMIN: 'lince@royalenfield.com',
ASM: 'abhishek@royalenfield.com',
RBM: 'manish@royalenfield.com',
DD_ZM: 'piyush@royalenfield.com',
ZBH: 'manav@royalenfield.com',
DD_LEAD: 'jaya@royalenfield.com',
DD_HEAD: 'ganesh@royalenfield.com',
LEGAL: 'legal@royalenfield.com',
NBH: 'yashwin@royalenfield.com',
CCO: 'admin@royalenfield.com',
@ -72,8 +74,10 @@ async function run() {
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
let terminationId = args.terminationId;
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
if (!terminationId) {
console.log('[STEP 1] ASM Initiating Termination...');
console.log('[STEP 1] Initiating Termination...');
const asmToken = await login(EMAILS.ASM);
const createRes = await apiRequest('/termination', 'POST', {
dealerId: targetDealer.id,
@ -83,7 +87,7 @@ async function run() {
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
}, asmToken);
terminationId = createRes.termination.id;
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}`);
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}. Category: ${args.category || 'Performance'}`);
} else {
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
}
@ -93,40 +97,49 @@ async function run() {
console.log(`[INFO] Current stage before progression: ${currentStage}`);
const approvals = [
{ name: 'RBM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' },
{ name: 'ZBH Review', email: EMAILS.ZBH, remarks: 'Strategic decision aligned with regional growth targets. Approved.' },
{ name: 'DD Lead Review', email: EMAILS.DD_LEAD, remarks: 'Contractual breaches documented. Verified.' },
{ name: 'Legal Verification', email: EMAILS.LEGAL, remarks: 'Legal audit complete. Case is legally sound.' },
{ name: 'DD Head Review', email: EMAILS.NBH, remarks: 'Strategic impact assessed. Proceeding with SCN approval.' },
{ name: 'NBH Evaluation', email: EMAILS.NBH, remarks: 'Functional teams aligned. SCN to be issued.' },
{ name: 'SCN Issued', email: EMAILS.NBH, remarks: 'Show Cause Notice formally dispatched.' },
{ name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD, remarks: 'Hearing completed. Dealer defense not sufficient.' },
{ name: 'NBH Final Approval', email: EMAILS.NBH, remarks: 'Final recommendation for termination sent to CEO.' },
{ name: 'CCO Approval', email: EMAILS.CCO, remarks: 'Commercial impact assessed. Approved.' },
{ name: 'CEO Final Approval', email: EMAILS.CEO, remarks: 'Final authorization granted. Issue termination letter.' },
{ name: 'Legal Termination Letter', email: EMAILS.LEGAL, remarks: 'Termination letter shared via registered mail.' },
{ name: 'Final Terminated Status', email: EMAILS.DD_ADMIN, remarks: 'Closure completed.' }
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
{ stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] },
{ stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] },
{ stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] },
{ stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] },
{ stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] },
{ stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] },
{ stage: 'Personal Hearing', actors: [
{ email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' },
{ email: EMAILS.ZBH, remarks: 'Review recorded.' },
{ email: EMAILS.RBM, remarks: 'Review recorded.' },
{ email: EMAILS.DD_HEAD, remarks: 'Review recorded.' }
] },
{ stage: 'NBH Final Approval', actors: [{ email: EMAILS.NBH, remarks: 'Final recommendation.' }] },
{ stage: 'CCO Approval', actors: [{ email: EMAILS.CCO, remarks: 'Approved.' }] },
{ stage: 'CEO Final Approval', actors: [{ email: EMAILS.CEO, remarks: 'Final authorization.' }] },
{ stage: 'Legal - Termination Letter', actors: [{ email: EMAILS.LEGAL, remarks: 'Termination letter shared.' }] }
];
const stageOrder = [
'Submitted', 'RBM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
'NBH Evaluation', 'Show Cause Notice', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
'NBH Evaluation', 'Show Cause Notice (SCN)', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
'CEO Final Approval', 'Legal - Termination Letter', 'Terminated'
];
const currentIndex = Math.max(0, stageOrder.indexOf(currentStage));
const currentStepStart = 2 + currentIndex;
let currentStep = currentStepStart;
for (let i = currentIndex; i < approvals.length; i++) {
const actor = approvals[i];
log(currentStep, `${actor.name} (${actor.email}) processing approval...`);
const token = await login(actor.email);
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
action: 'approve',
remarks: actor.remarks
}, token);
log(currentStep, `${actor.name} Result: SUCCESS`);
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
let currentStep = 2;
for (let i = startIndex; i < approvals.length; i++) {
const step = approvals[i];
log(currentStep, `Stage: ${step.stage} - Processing approvals...`);
for (const actor of step.actors) {
const token = await login(actor.email);
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
action: 'approve',
remarks: actor.remarks
}, token);
log(currentStep, `Actor ${actor.email} Result: SUCCESS`);
await delay(100);
}
currentStep++;
await delay();
}

View File

@ -123,7 +123,7 @@ async function prospectLogin(phone) {
async function mockUploadDocument(appId, token, docType) {
const formData = new FormData();
const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-24 19-16-05.png');
const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG');
const blob = new Blob([fileBuffer], { type: 'image/png' });
formData.append('file', blob, 'screenshot.png');
formData.append('documentType', docType);
@ -264,312 +264,312 @@ async function triggerWorkflow() {
}, leadToken);
const interviewId2 = intv2Response.data.id;
// log(5.1, 'DD-Lead Giving Feedback...');
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId2,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
// { type: 'Management Capabilities', comments: 'Strong team leadership.' },
// { type: 'Operational Understanding', comments: 'Knows the local market well.' }
// ],
// recommendation: 'Selected'
// }, leadToken);
log(5.1, 'DD-Lead Giving Feedback...');
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId2,
overallScore: 9.5,
feedbackItems: [
{ type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
{ type: 'Management Capabilities', comments: 'Strong team leadership.' },
{ type: 'Operational Understanding', comments: 'Knows the local market well.' }
],
recommendation: 'Selected'
}, leadToken);
// log(5.15, 'ZBH Giving Feedback...');
// const zbhToken = await login(zbhUser.email);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId2,
// overallScore: 9.0,
// feedbackItems: [
// { type: 'Strategic Vision', comments: 'Good alignment with brand.' },
// { type: 'Key Strengths', comments: 'Great location proposed.' },
// { type: 'Areas of Concern', comments: 'None at this time.' }
// ],
// recommendation: 'Selected'
// }, zbhToken);
log(5.15, 'ZBH Giving Feedback...');
const zbhToken = await login(zbhUser.email);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId2,
overallScore: 9.0,
feedbackItems: [
{ type: 'Strategic Vision', comments: 'Good alignment with brand.' },
{ type: 'Key Strengths', comments: 'Great location proposed.' },
{ type: 'Areas of Concern', comments: 'None at this time.' }
],
recommendation: 'Selected'
}, zbhToken);
// log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId2,
// decision: 'Approved',
// remarks: 'Cleared Level 2'
// }, leadToken);
// log(5, 'Level 2 Complete.');
// await delay();
log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId2,
decision: 'Approved',
remarks: 'Cleared Level 2'
}, leadToken);
log(5, 'Level 2 Complete.');
await delay();
// // 6. LEVEL-3 INTERVIEW
// log(6, 'Scheduling Level 3 Interview...');
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
// 6. LEVEL-3 INTERVIEW
log(6, 'Scheduling Level 3 Interview...');
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
// applicationId: applicationUUID,
// level: 3,
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
// type: 'In-Person',
// location: 'HO',
// participants: [headUser.id, nbhUser.id]
// }, leadToken);
// const interviewId3 = intv3Response.data.id;
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
applicationId: applicationUUID,
level: 3,
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
type: 'In-Person',
location: 'HO',
participants: [headUser.id, nbhUser.id]
}, leadToken);
const interviewId3 = intv3Response.data.id;
// log(6.1, 'NBH Giving Feedback...');
// const nbhToken = await login(EMAILS.NBH);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 10,
// feedbackItems: [
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
// ],
// recommendation: 'Selected'
// }, nbhToken);
log(6.1, 'NBH Giving Feedback...');
const nbhToken = await login(EMAILS.NBH);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 10,
feedbackItems: [
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
],
recommendation: 'Selected'
}, nbhToken);
// log(6.15, 'DD-Head Giving Feedback...');
// const headToken = await login(EMAILS.DD_HEAD);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
// ],
// recommendation: 'Selected'
// }, headToken);
log(6.15, 'DD-Head Giving Feedback...');
const headToken = await login(EMAILS.DD_HEAD);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 9.5,
feedbackItems: [
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
],
recommendation: 'Selected'
}, headToken);
// log(6.2, 'Head Finalizing Level 3 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId3,
// decision: 'Approved',
// remarks: 'Cleared Level 3. Moving to FDD.'
// }, headToken);
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
// await delay();
log(6.2, 'Head Finalizing Level 3 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId3,
decision: 'Approved',
remarks: 'Cleared Level 3. Moving to FDD.'
}, headToken);
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
await delay();
// // 6.3 FDD ASSIGNMENT
// log(6.3, 'Admin Assigning Application to FDD Agency...');
// const fddUser = users.data.find(u => u.email === EMAILS.FDD);
// await apiRequest('/fdd/assign', 'POST', {
// applicationId: applicationUUID,
// assignedToAgency: fddUser.id
// }, adminToken);
// log(6.3, 'FDD Agency assigned successfully.');
// await delay();
// 6.3 FDD ASSIGNMENT
log(6.3, 'Admin Assigning Application to FDD Agency...');
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
await apiRequest('/fdd/assign', 'POST', {
applicationId: applicationUUID,
assignedToAgency: fddUser.id
}, adminToken);
log(6.3, 'FDD Agency assigned successfully.');
await delay();
// // 7. FDD MILESTONE
// log(7, 'FDD Agency Discovery & Report Upload...');
// const fddToken = await login(EMAILS.FDD);
// 7. FDD MILESTONE
log(7, 'FDD Agency Discovery & Report Upload...');
const fddToken = await login(EMAILS.FDD);
// // FETCH ASSIGNMENT ID
// const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
// const assignmentId = assignmentRes.data.id;
// log(7, `Found Assignment ID: ${assignmentId}`);
// FETCH ASSIGNMENT ID
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
const assignmentId = assignmentRes.data.id;
log(7, `Found Assignment ID: ${assignmentId}`);
// await apiRequest('/fdd/report', 'POST', {
// assignmentId,
// findings: 'Finance records clean.',
// recommendation: 'Approved'
// }, fddToken);
await apiRequest('/fdd/report', 'POST', {
assignmentId,
findings: 'Finance records clean.',
recommendation: 'Approved'
}, fddToken);
// log(7.1, 'Admin Approving FDD Final Stage...');
// await apiRequest('/assessment/stage-decision', 'POST', {
// applicationId: applicationUUID,
// stageCode: 'FDD_VERIFICATION',
// decision: 'Approved',
// remarks: 'FDD documents verified.'
// }, adminToken);
// log(7, 'FDD Milestone Complete.');
// await delay();
log(7.1, 'Admin Approving FDD Final Stage...');
await apiRequest('/assessment/stage-decision', 'POST', {
applicationId: applicationUUID,
stageCode: 'FDD_VERIFICATION',
decision: 'Approved',
remarks: 'FDD documents verified.'
}, adminToken);
log(7, 'FDD Milestone Complete.');
await delay();
// log(7.4, 'Uploading mandatory documents prior to LOI generation...');
// const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
// for (const doc of requiredDocs) {
// await mockUploadDocument(applicationUUID, adminToken, doc);
// }
// await delay(1000);
log(7.4, 'Uploading mandatory documents prior to LOI generation...');
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
for (const doc of requiredDocs) {
await mockUploadDocument(applicationUUID, adminToken, doc);
}
await delay(1000);
// // 7.5 LOI APPROVAL
// log(7.5, 'LOI Generation & Approval...');
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
// const loiRequestId = loiRes.data.id;
// 7.5 LOI APPROVAL
log(7.5, 'LOI Generation & Approval...');
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
const loiRequestId = loiRes.data.id;
// // Head Approval
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'Head Authorization for LOI'
// }, headToken);
// Head Approval
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'Head Authorization for LOI'
}, headToken);
// // NBH Approval
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'NBH Authorization for LOI'
// }, nbhToken);
// NBH Approval
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'NBH Authorization for LOI'
}, nbhToken);
// log(7.5, 'LOI Milestone Complete.');
// await delay();
log(7.5, 'LOI Milestone Complete.');
await delay();
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
// const financeToken = await login(EMAILS.FINANCE);
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 500000,
// paymentReference: 'PAY-888999',
// depositType: 'SECURITY_DEPOSIT',
// status: 'Verified'
// }, financeToken);
// log(8, 'Security Deposit Verified.')
// // 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
// await delay(300);
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
const financeToken = await login(EMAILS.FINANCE);
await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID,
amount: 500000,
paymentReference: 'PAY-888999',
depositType: 'SECURITY_DEPOSIT',
status: 'Verified'
}, financeToken);
log(8, 'Security Deposit Verified.')
// 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
await delay(300);
// if (statusBeforeCodeGen === 'Security Details') {
// log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 500000,
// paymentReference: `PAY-RETRY-${Date.now()}`,
// depositType: 'SECURITY_DEPOSIT',
// status: 'Verified'
// }, financeToken);
// await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
// }
if (statusBeforeCodeGen === 'Security Details') {
log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID,
amount: 500000,
paymentReference: `PAY-RETRY-${Date.now()}`,
depositType: 'SECURITY_DEPOSIT',
status: 'Verified'
}, financeToken);
await delay();
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
}
// // Current backend flow keeps app at "Security Details" until explicit admin transition.
// if (statusBeforeCodeGen === 'Security Details') {
// log(9, 'Applying admin transition from Security Details -> LOI Issued...');
// await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
// status: 'LOI Issued',
// stage: 'LOI',
// reason: 'E2E script alignment: unlock dealer code generation after Security Details checks.'
// }, adminToken);
// await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
// }
// Current backend flow keeps app at "Security Details" until explicit admin transition.
if (statusBeforeCodeGen === 'Security Details') {
log(9, 'Applying admin transition from Security Details -> LOI Issued...');
await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
status: 'LOI Issued',
stage: 'LOI',
reason: 'E2E script alignment: unlock dealer code generation after Security Details checks.'
}, adminToken);
await delay();
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
}
// if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
// throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
// }
if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
}
// log(9, 'Admin Generating SAP Dealer Codes...');
// await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
// log(9, 'Dealer Codes Generated.');
// await delay();
log(9, 'Admin Generating SAP Dealer Codes...');
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
log(9, 'Dealer Codes Generated.');
await delay();
// // 10. FIRST FILL (POST CODE-GENERATION)
// log(10, 'Finance Verifying FIRST FILL (₹15L)...');
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 1500000,
// paymentReference: 'PAY-FIN-999',
// depositType: 'FIRST_FILL',
// status: 'Verified'
// }, financeToken);
// log(10, 'Final Security Deposit Verified.');
// await delay();
// 10. FIRST FILL (POST CODE-GENERATION)
log(10, 'Finance Verifying FIRST FILL (₹15L)...');
await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID,
amount: 1500000,
paymentReference: 'PAY-FIN-999',
depositType: 'FIRST_FILL',
status: 'Verified'
}, financeToken);
log(10, 'Final Security Deposit Verified.');
await delay();
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
// log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
// await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
// accountHolderName: 'Ramesh Automobiles Private Limited',
// panNumber: 'ABCDE1234F',
// gstNumber: '07ABCDE1234F1Z5',
// bankName: 'HDFC Bank',
// accountNumber: '50100223344556',
// ifscCode: 'HDFC0001234'
// }, adminToken);
// log(11, 'Statutory & Bank details updated.');
// await delay();
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
accountHolderName: 'Ramesh Automobiles Private Limited',
panNumber: 'ABCDE1234F',
gstNumber: '07ABCDE1234F1Z5',
bankName: 'HDFC Bank',
accountNumber: '50100223344556',
ifscCode: 'HDFC0001234'
}, adminToken);
log(11, 'Statutory & Bank details updated.');
await delay();
// // 12. FINAL LOA APPROVAL
// log(12, 'NBH & Head Approving Final LOA...');
// const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
// const finalLoaRequestId = loaRes.data.id;
// 12. FINAL LOA APPROVAL
log(12, 'NBH & Head Approving Final LOA...');
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
const finalLoaRequestId = loaRes.data.id;
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'Head Authorization (Level 1)'
// }, headToken);
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'Head Authorization (Level 1)'
}, headToken);
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'NBH Approval (Level 2)'
// }, nbhToken);
// log(12, 'LOA Fully Approved.');
// await delay();
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'NBH Approval (Level 2)'
}, nbhToken);
log(12, 'LOA Fully Approved.');
await delay();
// // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
// log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
// const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
// const checklistId = eorInit.data.id;
// log(13, `EOR Checklist Created (ID: ${checklistId})`);
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
const checklistId = eorInit.data.id;
log(13, `EOR Checklist Created (ID: ${checklistId})`);
// log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
// const eorItems = [
// { itemType: 'Sales', description: 'Sales Standards' },
// { itemType: 'Service', description: 'Service & Spares' },
// { itemType: 'IT', description: 'DMS infra' },
// { itemType: 'Training', description: 'Manpower Training' },
// { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
// { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
// { itemType: 'Finance', description: 'Inventory Funding' },
// { itemType: 'IT', description: 'Virtual code availability' },
// { itemType: 'Finance', description: 'Vendor payments' },
// { itemType: 'Marketing', description: 'Details for website submission' },
// { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
// { itemType: 'IT', description: 'Auto ordering' }
// ];
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
const eorItems = [
{ itemType: 'Sales', description: 'Sales Standards' },
{ itemType: 'Service', description: 'Service & Spares' },
{ itemType: 'IT', description: 'DMS infra' },
{ itemType: 'Training', description: 'Manpower Training' },
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
{ itemType: 'Finance', description: 'Inventory Funding' },
{ itemType: 'IT', description: 'Virtual code availability' },
{ itemType: 'Finance', description: 'Vendor payments' },
{ itemType: 'Marketing', description: 'Details for website submission' },
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
{ itemType: 'IT', description: 'Auto ordering' }
];
// for (const item of eorItems) {
// process.stdout.write(`.`); // Visual progress
// await apiRequest(`/eor/item/${checklistId}`, 'POST', {
// ...item,
// isCompliant: true,
// remarks: 'Verified by Auditor - Compliant'
// }, adminToken);
// }
// console.log('\n[STEP 13.1] All EOR items marked as compliant.');
for (const item of eorItems) {
process.stdout.write(`.`); // Visual progress
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
...item,
isCompliant: true,
remarks: 'Verified by Auditor - Compliant'
}, adminToken);
}
console.log('\n[STEP 13.1] All EOR items marked as compliant.');
// log(13.2, 'Auditor Submitting Final EOR Audit...');
// await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
// status: 'Completed',
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
// }, adminToken);
log(13.2, 'Auditor Submitting Final EOR Audit...');
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
status: 'Completed',
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
}, adminToken);
// // Status check
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
// await delay();
// Status check
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
await delay();
// // 14. FINAL ONBOARDING
// log(14, 'Admin Finalizing Dealer Onboarding...');
// await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
// await delay();
// 14. FINAL ONBOARDING
log(14, 'Admin Finalizing Dealer Onboarding...');
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
await delay();
// // 15. VERIFICATION
// log(15, 'Verifying Dealer Record Creation...');
// const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
// if (!dealerRes.success || !dealerRes.data) {
// throw new Error('Verification Failed: Dealer record not found after onboarding.');
// }
// log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
// 15. VERIFICATION
log(15, 'Verifying Dealer Record Creation...');
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
if (!dealerRes.success || !dealerRes.data) {
throw new Error('Verification Failed: Dealer record not found after onboarding.');
}
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
// log(15.1, 'Verifying User Account Role Update...');
// const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
// const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
// if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
// throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
// }
// log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
log(15.1, 'Verifying User Account Role Update...');
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
}
log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
// log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
// log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
}
/**

View File

@ -63,12 +63,7 @@ async function run() {
console.log(`Created Test Request: ${request.requestId}`);
// Now call the logic that calculates participants (similar to getRequestById)
// We'll just look at the DB for now to see if DD Head and NBH (dual) would be assigned.
// Check DD Head
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
console.log(`DD Head found in DB: ${ddHead ? ddHead.fullName : 'NO'}`);
// We'll just look at the DB for now to see if NBH exists for the approval stage.
// Check NBH
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`);
@ -76,9 +71,7 @@ async function run() {
// Verify Evaluator Assignment Logic (Re-running a piece of it)
const evaluators = [];
evaluators.push({ id: outlet.district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW });
evaluators.push({ id: ddHead?.id, role: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL });
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE });
console.log('Expected Evaluators for this request:');
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));