Compare commits
7 Commits
main
...
laxman_dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ab90ee356 | |||
| 5ddbe525e6 | |||
| 2b73036bb9 | |||
| 3c95146f4a | |||
| 8d7805acc9 | |||
| ede68caefc | |||
| b6938abc7c |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Royal Enfield Onboarding</title>
|
<title>Royal Enfield Onboarding</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CAe70IkM.js"></script>
|
<script type="module" crossorigin src="/assets/index-C_7C7ZNJ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DkEVuJwH.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-COwSK6pX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1793
docs/RE_Dealer_System_TestStories.md
Normal file
1793
docs/RE_Dealer_System_TestStories.md
Normal file
File diff suppressed because it is too large
Load Diff
163
docs/RE_Dealer_Test_Coverage_Tracker.md
Normal file
163
docs/RE_Dealer_Test_Coverage_Tracker.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# RE Dealer System - Test Coverage Tracker
|
||||||
|
|
||||||
|
This tracker is maintained separately from the test stories document.
|
||||||
|
Update module by module as execution progresses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
|
||||||
|
- `Not Started` - Test story not executed yet
|
||||||
|
- `In Progress` - Execution started but not finalized
|
||||||
|
- `Executed` - Execution completed
|
||||||
|
- `Blocked` - Cannot execute due to dependency/environment issue
|
||||||
|
|
||||||
|
Result values:
|
||||||
|
- `Pass`
|
||||||
|
- `Fail`
|
||||||
|
- `NA` (Not applicable or not executed yet)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall Coverage
|
||||||
|
|
||||||
|
| Metric | Count |
|
||||||
|
|---|---:|
|
||||||
|
| Total Test Stories | 61 |
|
||||||
|
| Executed | 0 |
|
||||||
|
| Passed | 0 |
|
||||||
|
| Failed | 0 |
|
||||||
|
| Blocked | 0 |
|
||||||
|
| Remaining | 61 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Summary
|
||||||
|
|
||||||
|
| Module | Total | Executed | Passed | Failed | Blocked | Remaining |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Module 1 - Dealer Onboarding | 22 | 0 | 0 | 0 | 0 | 22 |
|
||||||
|
| Module 2 - Dealer Resignation | 9 | 0 | 0 | 0 | 0 | 9 |
|
||||||
|
| Module 3 - Dealer Termination | 12 | 0 | 0 | 0 | 0 | 12 |
|
||||||
|
| Module 4 - Constitutional Change | 3 | 0 | 0 | 0 | 0 | 3 |
|
||||||
|
| Module 5 - Dealer Relocation | 3 | 0 | 0 | 0 | 0 | 3 |
|
||||||
|
| Module 6 - Full and Final (F&F) Settlement | 6 | 0 | 0 | 0 | 0 | 6 |
|
||||||
|
| Module 7 - Finance Dashboard | 2 | 0 | 0 | 0 | 0 | 2 |
|
||||||
|
| Module 8 - Admin and System Configuration | 4 | 0 | 0 | 0 | 0 | 4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 1 - Dealer Onboarding
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 1.1.1 | Applicant Submits Application (Opportunity Location) | Not Started | NA | | | |
|
||||||
|
| 1.1.2 | Applicant Submits Application (Non-Opportunity Location) | Not Started | NA | | | |
|
||||||
|
| 1.1.3 | Application Submission Validation Failure | Not Started | NA | | | |
|
||||||
|
| 1.2.1 | Applicant Completes Questionnaire | Not Started | NA | | | |
|
||||||
|
| 1.3.1 | DD-Admin Shortlists Application | Not Started | NA | | | |
|
||||||
|
| 1.3.2 | DD-Admin Archives Application | Not Started | NA | | | |
|
||||||
|
| 1.4.1 | DD-Admin Schedules Interview (Level 1) | Not Started | NA | | | |
|
||||||
|
| 1.4.2 | DD-ZM + RBM Fill KT Matrix and Feedback (Level 1) | Not Started | NA | | | |
|
||||||
|
| 1.4.3 | Level 2 Evaluation (DD-Lead + ZBH) | Not Started | NA | | | |
|
||||||
|
| 1.4.4 | Level 3 Final Evaluation and AI Summary (NBH + DD-Head) | Not Started | NA | | | |
|
||||||
|
| 1.5.1 | FDD Partner Submits Due Diligence Report | Not Started | NA | | | |
|
||||||
|
| 1.5.2 | Finance Team Reviews FDD Report | Not Started | NA | | | |
|
||||||
|
| 1.6.1 | DD-Admin Triggers LOI Document Request | Not Started | NA | | | |
|
||||||
|
| 1.6.2 | Security Deposit Validation | Not Started | NA | | | |
|
||||||
|
| 1.6.3 | LOI Approval Chain (Finance -> DD-Head -> NBH) | Not Started | NA | | | |
|
||||||
|
| 1.6.4 | LOI Issuance to Applicant | Not Started | NA | | | |
|
||||||
|
| 1.7.1 | DD-Admin Triggers Dealer Code Creation | Not Started | NA | | | |
|
||||||
|
| 1.8.1 | Architectural Work Assignment and Completion | Not Started | NA | | | |
|
||||||
|
| 1.8.2 | Statutory Document Collection and Verification | Not Started | NA | | | |
|
||||||
|
| 1.9.1 | LOA Approval and Issuance | Not Started | NA | | | |
|
||||||
|
| 1.10.1 | EOR Completion by Functional Teams | Not Started | NA | | | |
|
||||||
|
| 1.11.1 | Dealership Inauguration and Closure | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 2 - Dealer Resignation
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 2.1 | Dealer Initiates Resignation via Portal | Not Started | NA | | | |
|
||||||
|
| 2.2 | DD-ASM Reviews and Forwards Resignation | Not Started | NA | | | |
|
||||||
|
| 2.3 | RBM + DD-ZM Joint Evaluation | Not Started | NA | | | |
|
||||||
|
| 2.4 | ZBH Review | Not Started | NA | | | |
|
||||||
|
| 2.5 | DD-Lead Review and Presentation | Not Started | NA | | | |
|
||||||
|
| 2.6 | NBH Final Approval | Not Started | NA | | | |
|
||||||
|
| 2.7 | Legal Issues Resignation Acceptance Letter | Not Started | NA | | | |
|
||||||
|
| 2.8 | DD-Admin Closure and F&F Trigger | Not Started | NA | | | |
|
||||||
|
| 2.9 | Dealer Withdraws Resignation Request | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 3 - Dealer Termination
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 3.1 | ASM Creates Termination Request | Not Started | NA | | | |
|
||||||
|
| 3.2 | RBM + DD-ZM Review | Not Started | NA | | | |
|
||||||
|
| 3.3 | ZBH Review | Not Started | NA | | | |
|
||||||
|
| 3.4 | DD-Lead Review and Legal Assignment | Not Started | NA | | | |
|
||||||
|
| 3.5 | Legal Verification | Not Started | NA | | | |
|
||||||
|
| 3.6 | DD-Head Review -> NBH Evaluation | Not Started | NA | | | |
|
||||||
|
| 3.7 | Show Cause Notice (SCN) Issuance | Not Started | NA | | | |
|
||||||
|
| 3.8 | Joint Review of Dealer Response -> NBH Final Decision | Not Started | NA | | | |
|
||||||
|
| 3.9 | CEO and CCO Final Authorization | Not Started | NA | | | |
|
||||||
|
| 3.10 | Legal Issues Termination Letter | Not Started | NA | | | |
|
||||||
|
| 3.11 | DD-Admin Communication and F&F Trigger | Not Started | NA | | | |
|
||||||
|
| 3.12 | Immediate Termination (Unethical Practice) | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 4 - Constitutional Change
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 4.1 | Dealer Initiates Constitutional Change Request | Not Started | NA | | | |
|
||||||
|
| 4.2 | Multi-Level Review and Approval | Not Started | NA | | | |
|
||||||
|
| 4.3 | Legal Validation and Master Data Update | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 5 - Dealer Relocation
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 5.1 | Dealer Initiates Relocation Request | Not Started | NA | | | |
|
||||||
|
| 5.2 | Multi-Level Review and Approval | Not Started | NA | | | |
|
||||||
|
| 5.3 | Final Approval and Master Data Update | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 6 - Full and Final (F&F) Settlement
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 6.1 | F&F Case Initiation | Not Started | NA | | | |
|
||||||
|
| 6.2 | Department Clearance Submission (All 16 Departments) | Not Started | NA | | | |
|
||||||
|
| 6.3 | Finance Consolidates F&F Summary | Not Started | NA | | | |
|
||||||
|
| 6.4 | Dealer Discussion and Acknowledgment | Not Started | NA | | | |
|
||||||
|
| 6.5 | Final Finance Approval and Payment Processing | Not Started | NA | | | |
|
||||||
|
| 6.6 | F&F Closure | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 7 - Finance Dashboard
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 7.1 | Security Deposit Verification (Onboarding) | Not Started | NA | | | |
|
||||||
|
| 7.2 | Finance Flags Payment Discrepancy | Not Started | NA | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module 8 - Admin and System Configuration
|
||||||
|
|
||||||
|
| Test Story ID | Title | Execution Status | Result | Owner | Last Updated | Notes |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 8.1.1 | Admin Configures SLA for a Workflow Activity | Not Started | NA | | | |
|
||||||
|
| 8.1.2 | SLA Breach and Escalation Flow | Not Started | NA | | | |
|
||||||
|
| 8.2.1 | Admin Creates Email Template | Not Started | NA | | | |
|
||||||
|
| 8.3.1 | Admin Creates New Opportunity Window | Not Started | NA | | | |
|
||||||
2719
docs/modular_wise/01_Dealer_Onboarding.md
Normal file
2719
docs/modular_wise/01_Dealer_Onboarding.md
Normal file
File diff suppressed because it is too large
Load Diff
696
docs/modular_wise/02_Dealer_Resignation.md
Normal file
696
docs/modular_wise/02_Dealer_Resignation.md
Normal 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 dealership’s official letterhead to
|
||||||
|
the **ASM**.
|
||||||
|
- The resignation reason must be clearly stated (e.g., personal, financial, business
|
||||||
|
restructuring).
|
||||||
|
- The **dealer is provided portal access** to initiate the resignation request directly through
|
||||||
|
the system. The dealer submits resignation details, reason for exit, and proposed
|
||||||
|
timeline via the portal, after which the request enters the internal review and clearance
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.2.2.2 ASM Review
|
||||||
|
```
|
||||||
|
- The **ASM** reviews the dealer’s resignation request and supporting letter.
|
||||||
|
- Uploads the **resignation email** and **dealer’s letterhead document** onto the portal.
|
||||||
|
- Adds remarks summarizing the discussion and reason for resignation.
|
||||||
|
- Forwards the request to **RBM + DD-ZM** for evaluation.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.2.2.3 RBM + DD-ZM Joint Evaluation
|
||||||
|
```
|
||||||
|
- The **Regional Business Manager (RBM)** and **Dealer Development Zonal Manager (DD-**
|
||||||
|
**ZM)** review the uploaded documents.
|
||||||
|
- Conduct a joint discussion with the dealer to confirm the intent and understand any
|
||||||
|
issues.
|
||||||
|
- Uploads the **Minutes of Meeting (MOM)** or discussion summary.
|
||||||
|
- Adds comments and recommendations before forwarding to **Zonal Business Head**
|
||||||
|
**(ZBH)**.
|
||||||
|
- Actions available at this stage:
|
||||||
|
o **Approve** → Send forward for next-level review
|
||||||
|
o **Send Back for Clarification** → Returns to ASM
|
||||||
|
o **Withdraw** → Cancels the request (with remarks logged)
|
||||||
|
|
||||||
|
```
|
||||||
|
4.2.2.4 ZBH Review
|
||||||
|
```
|
||||||
|
- The **Zonal Business Head (ZBH)** reviews the resignation summary and all remarks.
|
||||||
|
- Adds their comments and recommendations.
|
||||||
|
- Forwards the request to **DD-Lead** through the system.
|
||||||
|
- Worknote is updated automatically to reflect action and timestamp.
|
||||||
|
- The resignation request is reviewed by authorized business stakeholders,
|
||||||
|
including **RBM, ZBH, and DD-Head**. During the review stage, the **ZBH is authorized to**
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
Send Back or Revoke the resignation request for clarification or correction. Send Back
|
||||||
|
actions are communicated to the dealer and internal teams through Work Notes , with
|
||||||
|
mandatory remarks captured for traceability.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
4.2.2.5 DD-Lead Review
|
||||||
|
```
|
||||||
|
- The **DD-Lead** consolidates all discussions, documents, and feedback.
|
||||||
|
- Prepares a **Resignation Presentation** with recommendations and supporting data.
|
||||||
|
- Uploads the presentation to the portal.
|
||||||
|
- Forwards the case to **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 dealer’s resignation details, reasons for exit, and
|
||||||
|
proposed timeline, ensuring all associated departments — including **DD-Admin, DD-**
|
||||||
|
|
||||||
|
**Head, Finance, Legal, and Regional Teams** — are informed and involved in the validation
|
||||||
|
and clearance stages. Each resignation request undergoes systematic review, covering
|
||||||
|
|
||||||
|
asset recovery, financial reconciliation, documentation verification, and contractual
|
||||||
|
obligations before final approval and closure.
|
||||||
|
|
||||||
|
|
||||||
|
### 7.1 Dealer Resignation Request (Initiation)
|
||||||
|
|
||||||
|
**7.1.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Dealer Resignation Request** process begins when a dealer formally communicates their
|
||||||
|
intent to resign via an **official email** to ASM. Once received, the **DD-ASM** initiates the resignation
|
||||||
|
process in the system by creating a digital record using the _Create Resignation Request_ form. The
|
||||||
|
form captures critical dealership, operational, and contextual information — such as business
|
||||||
|
constitution, sales data, and closure type — ensuring that the request is documented in a
|
||||||
|
structured, traceable, and standardized manner. This process establishes a single source of truth
|
||||||
|
for all resignation-related data, facilitating transparent coordination among **DD-Head, Finance,
|
||||||
|
Legal, and Regional Teams** for subsequent review and action. Dealer can login exclusively and
|
||||||
|
can only initiate the Resignation request.
|
||||||
|
|
||||||
|
The **Dealer Resignation Request is initiated by the dealer through the portal** , providing a
|
||||||
|
structured mechanism to formally submit the intent to discontinue the dealership. The dealer
|
||||||
|
captures resignation details, reason for exit, and the proposed effective date. Upon submission,
|
||||||
|
the request is routed to the internal stakeholders for review, validation, and subsequent
|
||||||
|
clearance processes. The **dealer logs into the portal and initiates the resignation request** by
|
||||||
|
submitting the required details and supporting information.
|
||||||
|
|
||||||
|
|
||||||
|
**7.1.2 Width**
|
||||||
|
|
||||||
|
- Accessible exclusively to **DD-ASM** through the **“Create Resignation Request”** interface.
|
||||||
|
- Includes the following mandatory and optional input fields:
|
||||||
|
o **Dealer Code** (it will be fed to SAP API to pull details.)
|
||||||
|
o **Inauguration** , **LOA** , and **LOI Dates** (Will be fetched from system DB, if available)
|
||||||
|
o **Last 6 Months Sales**
|
||||||
|
o **Number of Dealerships / Studios**
|
||||||
|
o **Constitution** (Proprietorship, Partnership, LLP, Pvt. Ltd., etc.)
|
||||||
|
o **Dealership Type** (Main, Satellite, Studio, etc.)
|
||||||
|
o **Type of Closure** (Voluntary, Business Transfer, Termination, etc.)
|
||||||
|
o **Format Category** (Urban, Rural, etc.)
|
||||||
|
o **Dealer Scorecard Band**
|
||||||
|
o **Resignation Reason** (brief summary)
|
||||||
|
o **Dealer Voice** (detailed justification or remarks from dealer’s email)
|
||||||
|
o **Upload Document** (resignation email copy or supporting documents)
|
||||||
|
- **Buttons:**
|
||||||
|
o **Submit Request:** validates data and triggers routing to the next stage of review.
|
||||||
|
o **Cancel:** exits without saving.
|
||||||
|
|
||||||
|
**7.1.3 Depth**
|
||||||
|
|
||||||
|
- Upon submission by **DD-Admin** , the system performs the following
|
||||||
|
o Validates the **Dealer Code** against the dealership master from SAP API to be
|
||||||
|
provided by RE
|
||||||
|
o Generates a unique **Resignation Request ID** and logs submission details
|
||||||
|
(timestamp, user, and role).
|
||||||
|
o Stores the uploaded resignation email or document in the **Central Document**
|
||||||
|
**Repository** for reference.
|
||||||
|
o Automatically notifies the **DD-Head** and relevant stakeholders that a new
|
||||||
|
resignation has been logged.
|
||||||
|
o Marks the case status as **“Resignation Initiated”** in the workflow tracker.
|
||||||
|
o He will also upload the resignation PPT which is build off the system.
|
||||||
|
|
||||||
|
**7.1.4 Personas-Wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona Accessibility Visibility Scope
|
||||||
|
Dealer /
|
||||||
|
Applicant
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Sends official resignation email to Royal Enfield.
|
||||||
|
The dealer is provided portal access to upload
|
||||||
|
resignation-related documents and
|
||||||
|
responses during the applicable workflow
|
||||||
|
stages.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Email communication
|
||||||
|
only (no direct system
|
||||||
|
access).
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Creates resignation request in system, uploads
|
||||||
|
dealer’s email, validates data, and submits for
|
||||||
|
approval.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Receives system notification upon submission;
|
||||||
|
can view request details and attached
|
||||||
|
resignation communication.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Background operation.
|
||||||
|
```
|
||||||
|
### 7.2 Resignation Management Dashboard
|
||||||
|
|
||||||
|
**7.2.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Resignation Management Dashboard** serves as the central workspace for monitoring and
|
||||||
|
managing all dealer resignation requests initiated within the system. It provides a consolidated
|
||||||
|
view of active, pending, and completed cases, enabling stakeholders such as **DD-Admin, ASM,
|
||||||
|
DD-Lead, ZBH, NBH, and Legal Teams** to review progress, take required actions, and ensure
|
||||||
|
compliance with the defined offboarding workflow.
|
||||||
|
|
||||||
|
The **ZBH can review resignation requests and perform Send Back or Revoke actions** prior to final
|
||||||
|
approval. Each action requires **mandatory remarks** and is recorded against the resignation case.
|
||||||
|
|
||||||
|
|
||||||
|
RBM, **ZBH, DD-Lead, DD-Head, and NBH** can review resignation requests and are authorized
|
||||||
|
to **Send Back or Revoke** requests at their respective stages. All such actions require **mandatory
|
||||||
|
remarks** and are logged for audit purposes.
|
||||||
|
|
||||||
|
**7.2.2 Width**
|
||||||
|
|
||||||
|
- Displays a **summary header** with following key counters:
|
||||||
|
o **All Requests:** Total number of resignation requests recorded.
|
||||||
|
o **Open:** Requests currently under review or action.
|
||||||
|
o **Completed:** Finalized resignations where closure is approved.
|
||||||
|
o **Requires Your Action:** Highlights cases awaiting action from the logged-in user.
|
||||||
|
- Shows a **list view** of all resignation requests with the following details:
|
||||||
|
o **Request ID (e.g., RES-001)**
|
||||||
|
o **Dealer Name, Dealer Code, and Location**
|
||||||
|
o **Format Category** (A+, A, B, etc.)
|
||||||
|
o **Dealership Type** (Main, Studio, etc.)
|
||||||
|
o **Reason for Resignation**
|
||||||
|
o **Current Stage** (e.g., ASM Review, DD-Lead Review, NBH Approved, Legal)
|
||||||
|
o **Submitted On** (auto-captured timestamp)
|
||||||
|
- Action options:
|
||||||
|
o **View Details:** Opens complete resignation record and attached documents.
|
||||||
|
o **Create Resignation Request:** Accessible only to **DD-Admin** for entering new
|
||||||
|
requests (from dealer emails).
|
||||||
|
- Filter tabs:
|
||||||
|
o **All Requests** , **Open** , **Completed**
|
||||||
|
|
||||||
|
**7.2.3 Depth**
|
||||||
|
|
||||||
|
- **Workflow Synchronization:** Each resignation request dynamically updates its stage label
|
||||||
|
(e.g., _ASM Review_ , _DD-Lead Review_ , _NBH Approved_ ) based on workflow transitions.
|
||||||
|
- **Notification Logic:**
|
||||||
|
o The assigned reviewer (ASM, DD-Lead, or NBH) receives automated alerts for
|
||||||
|
action items.
|
||||||
|
o Status changes trigger notifications to the next role in sequence.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
### 7.3 Resignation Details & Review
|
||||||
|
|
||||||
|
**7.3.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Resignation Details & Review** module provides a comprehensive view of all dealer
|
||||||
|
resignation information captured during initiation. It enables authorized reviewers to validate
|
||||||
|
dealer data, evaluate the reason and context for resignation, and take appropriate workflow
|
||||||
|
|
||||||
|
|
||||||
|
actions such as **Approval, Withdrawal, Send Back, or Push to Full & Final (F&F)**. The screen
|
||||||
|
consolidates dealer master data, operational metrics, and resignation specifics, ensuring
|
||||||
|
reviewers have complete visibility before making decisions.
|
||||||
|
|
||||||
|
**7.3.2 Width**
|
||||||
|
|
||||||
|
- **Header Actions:**
|
||||||
|
o **Approve:** Marks resignation as validated and forwards it to the next workflow
|
||||||
|
stage (DD-Head / NBH).
|
||||||
|
o **Withdrawal:** Used if the dealer retracts the resignation request or if withdrawal
|
||||||
|
is approved internally.
|
||||||
|
o **Send Back:** Returns the request to DD-Admin for correction or additional details.
|
||||||
|
o **Push to F&F:** Moves the case to the **Full & Final Settlement** process after all
|
||||||
|
approvals are secured.
|
||||||
|
o **Assign User:** Allows reallocation of review responsibility to another internal user.
|
||||||
|
o **View Work Notes:** Opens the shared comment thread for internal collaboration
|
||||||
|
and tagging.
|
||||||
|
- **Tabs:**
|
||||||
|
o **Details** – Displays complete resignation information and dealer data.
|
||||||
|
o **Progress** – Shows stage-wise workflow journey and current reviewer.
|
||||||
|
o **Documents** – Lists uploaded resignation documents and correspondence.
|
||||||
|
o **Audit Trail** – Records every action, decision, and timestamp for traceability.
|
||||||
|
|
||||||
|
**7.3.3 3. Depth**
|
||||||
|
|
||||||
|
- **Information Segments:**
|
||||||
|
o **Request Information:** Pull dealer master details such as Dealer Code, GST,
|
||||||
|
Address, Domain & Service Codes, City Category, and Dealership Name.
|
||||||
|
o **Operational Details:** Displays dealership metrics including inauguration and LOA
|
||||||
|
dates, number of outlets, last six-month sales, business constitution, format
|
||||||
|
category, and dealer scorecard band.
|
||||||
|
o **Resignation Details:** Summarizes the **Resignation Reason** and **Dealer Voice**
|
||||||
|
**(Customer Description)** derived from the dealer’s email submission.
|
||||||
|
|
||||||
|
### 7.4 Resignation Request Review & Action Management
|
||||||
|
|
||||||
|
**7.4.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Resignation Progress Timeline** provides a transparent, stepwise view of the dealer
|
||||||
|
resignation workflow — from initial submission to the issuance of the final **Acceptance Letter**.
|
||||||
|
Since the **Dealer does not have portal access** for resignation, the process starts through an **email
|
||||||
|
submission to the Area Sales Manager (ASM)** , followed by progressive reviews and comments
|
||||||
|
at multiple organizational levels. Each approver in the chain can perform one of three key actions
|
||||||
|
— **Approve** , **Send Back for Clarification** , or **Withdraw** — with remarks captured in **Work
|
||||||
|
Notes** for audit and traceability. Once approved by the **National Business Head (NBH)** , the
|
||||||
|
request automatically routes to the **Legal Team** for the issuance of the acceptance letter, visible
|
||||||
|
to both the DD Admin and DD-ASM.
|
||||||
|
|
||||||
|
The **dealer is provided portal access** to **upload resignation-related documents and
|
||||||
|
responses** during the applicable workflow stages. For termination cases, **dealer upload access is
|
||||||
|
restricted** as per defined governance rules.
|
||||||
|
|
||||||
|
**7.4.2 Width**
|
||||||
|
|
||||||
|
```
|
||||||
|
7.4.2.1 Stage-wise Flow
|
||||||
|
Stage Responsible
|
||||||
|
Role
|
||||||
|
```
|
||||||
|
```
|
||||||
|
System / Process Description
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Dealer
|
||||||
|
Resignation
|
||||||
|
Submission
|
||||||
|
|
||||||
|
```
|
||||||
|
Dealer → via
|
||||||
|
Email to ASM
|
||||||
|
```
|
||||||
|
- Dealer submits resignation via official email and
|
||||||
|
signed letterhead.
|
||||||
|
- No direct portal access.
|
||||||
|
- ASM receives and verifies authenticity.
|
||||||
|
2. ASM Review DD-ASM • Uploads resignation email and presentation
|
||||||
|
(e.g., _Sample resignation.pptx_ ) to portal.
|
||||||
|
- Adds remarks summarizing dealer’s reason and
|
||||||
|
operational background.
|
||||||
|
- Forwards case to **RBM + DD-ZM** for evaluation.
|
||||||
|
3. RBM + DD-ZM
|
||||||
|
Review
|
||||||
|
|
||||||
|
```
|
||||||
|
RBM & DD-ZM • Conduct joint discussion with dealer to understand
|
||||||
|
cause and alternatives.
|
||||||
|
```
|
||||||
|
- Uploads discussion notes and remarks in **Work Notes**.
|
||||||
|
- The final output will be submitted as Approve,
|
||||||
|
Withdrawal or send back.
|
||||||
|
- Has three action options:
|
||||||
|
- **Approve:** Forwards case to ZBH for further review.
|
||||||
|
- **Send Back:** Requests ASM to provide additional
|
||||||
|
details or clarifications (remark mandatory).
|
||||||
|
- **Withdraw:** Stops process if dealer withdraws or
|
||||||
|
case found invalid (remark mandatory).
|
||||||
|
4. ZBH Review Zonal Business
|
||||||
|
Head
|
||||||
|
- Reviews RBM + DD-ZM inputs and validates zonal
|
||||||
|
implications.
|
||||||
|
- Adds comments in **Work Notes** and forwards to **DD
|
||||||
|
Lead**.
|
||||||
|
- Can perform **Approve** , **Send Back** ,
|
||||||
|
or **Withdraw** actions.
|
||||||
|
5. DD Lead
|
||||||
|
Review
|
||||||
|
|
||||||
|
```
|
||||||
|
DD Lead • Prepares a formal Resignation Presentation
|
||||||
|
PPT summarizing business rationale, sales history,
|
||||||
|
dealer feedback, and proposed recommendation.
|
||||||
|
```
|
||||||
|
- Uploads the presentation and comments to the
|
||||||
|
portal.
|
||||||
|
- Approves and shares with **NBH** for final decision.
|
||||||
|
6. NBH Approval National
|
||||||
|
Business Head
|
||||||
|
- Reviews all inputs and puts **final decision remarks** in
|
||||||
|
Work Notes.
|
||||||
|
- On approval, system triggers notification to **DD Lead,
|
||||||
|
ZBH, Zonal Team, Business Zonal Manager, and F&F**.
|
||||||
|
- Automatically routes the case to **Legal Team** for
|
||||||
|
Acceptance Letter issuance.
|
||||||
|
7. Legal Review &
|
||||||
|
Acceptance Letter
|
||||||
|
|
||||||
|
```
|
||||||
|
Legal Team • Prepares and uploads Resignation Acceptance
|
||||||
|
Letter on portal.
|
||||||
|
```
|
||||||
|
- Can raise queries in Work Notes if required.
|
||||||
|
- Uploaded document is visible to **DD-Admin** and **DD-**
|
||||||
|
|
||||||
|
|
||||||
|
#### ASM.
|
||||||
|
|
||||||
|
- Legal completion closes workflow for the request.
|
||||||
|
8. DD Admin &
|
||||||
|
ASM Notification
|
||||||
|
|
||||||
|
```
|
||||||
|
DD Admin +
|
||||||
|
DD-ASM
|
||||||
|
```
|
||||||
|
- DD Admin reviews the uploaded acceptance letter.
|
||||||
|
- Shares with respective **ASM (Field Team)** to
|
||||||
|
communicate official closure to the dealer.
|
||||||
|
|
||||||
|
**7.4.3 3. Depth**
|
||||||
|
|
||||||
|
- **Action Modes Across Stages:**
|
||||||
|
o **Approve:** Advances the resignation request to the next level of the workflow.
|
||||||
|
_Example:_ “Reviewed with dealer and validated. Forwarding to ZBH for next stage.”
|
||||||
|
o **Send Back:** Returns to the previous user or ASM for clarifications.
|
||||||
|
_Example:_ “Incomplete documentation. Dealer statement on financials missing.”
|
||||||
|
o **Withdraw:** Ends the process if dealer withdraws voluntarily or management
|
||||||
|
disapproves continuation.
|
||||||
|
_Example:_ “Dealer requested withdrawal of resignation via email dated 15-Oct.”
|
||||||
|
- **Audit and Transparency:**
|
||||||
|
o All actions (including remarks, uploads, and timestamps) are auto-captured
|
||||||
|
in **Work Notes** and the **Audit Trail**.
|
||||||
|
o Every document and PPT uploaded (e.g., _Sample resignation.pptx_ ) is linked to its
|
||||||
|
stage for version tracking.
|
||||||
|
- **System Automation:**
|
||||||
|
o NBH approval automatically triggers Legal assignment.
|
||||||
|
o SLA tracking continues at each step; escalation is logged in Work Notes if delayed.
|
||||||
|
o Notifications are sent to all relevant stakeholders upon approval, send-back, or
|
||||||
|
withdrawal.
|
||||||
|
|
||||||
|
**7.4.4 Worknotes**
|
||||||
|
|
||||||
|
The **Work Notes** feature acts as the central communication and collaboration thread
|
||||||
|
within the resignation workflow. It captures all user interactions, remarks, and system-
|
||||||
|
|
||||||
|
triggered updates in a structured, time-stamped format. Each stakeholder — from
|
||||||
|
ASM to NBH and Legal — uses Work Notes to record discussions, queries,
|
||||||
|
|
||||||
|
clarifications, and final decisions related to the resignation case will be submitted from
|
||||||
|
Approval, Withdrawal or send back action.
|
||||||
|
|
||||||
|
### 7.5 Resignation Progress Tracker
|
||||||
|
|
||||||
|
|
||||||
|
**7.5.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Progress** section provides a stage-wise, visual representation of the entire dealer resignation
|
||||||
|
workflow. It enables authorized users to track each approval checkpoint — from **request
|
||||||
|
submission** through **multi-level review** to **final legal acceptance**. Every stage dynamically
|
||||||
|
updates based on workflow actions such as _Approve_ , _Send Back_ , or _Withdraw_ , with complete
|
||||||
|
traceability of remarks, uploaded documents, and timestamps. This ensures full transparency,
|
||||||
|
accountability, and operational consistency across all hierarchical levels.
|
||||||
|
|
||||||
|
**7.5.2 Width**
|
||||||
|
|
||||||
|
- Presents a **chronological timeline** of the resignation process, beginning with _Request
|
||||||
|
Submitted_ and concluding with _Legal – Resignation Letter_.
|
||||||
|
- Each stage displays **status indicators** (Pending, In Progress, Approved, or Withdrawn) along
|
||||||
|
with the **responsible reviewer role**.
|
||||||
|
- Shows the **number of documents uploaded** at each stage, with direct view/download options.
|
||||||
|
- Allows reviewers to perform three key actions — _Approve_ , _Send Back_ , and _Withdraw_ — with
|
||||||
|
remarks made mandatory.
|
||||||
|
- If a request is **Sent Back** , it automatically reverts to the previous stage, recording remarks
|
||||||
|
in **Work Notes** and notifying the concerned user.
|
||||||
|
- On **Withdrawal** , the timeline is locked and marked _Closed – Withdrawn_ for historical reference.
|
||||||
|
- Once **NBH** provides final approval, the request is automatically assigned to **Legal** for
|
||||||
|
acceptance letter issuance.
|
||||||
|
- The **Legal stage** finalizes the process upon letter upload, marking the case _Completed_ and
|
||||||
|
notifying DD-Admin and field hierarchy.
|
||||||
|
|
||||||
|
**7.5.3 Depth**
|
||||||
|
|
||||||
|
- Each stage retains all **remarks, approvals, timestamps, and supporting documents** for
|
||||||
|
complete traceability.
|
||||||
|
- Integrates seamlessly with **Work Notes** and **Audit Trail** , ensuring real-time visibility of all
|
||||||
|
communications and escalations.
|
||||||
|
- Supports SLA-driven reminders and escalations that reflect directly in the timeline view.
|
||||||
|
- All uploaded documents (emails, resignation PPT, acceptance letter) remain permanently
|
||||||
|
mapped to their respective stages.
|
||||||
|
- Once the resignation is finalized, historical data stays accessible for compliance and audit
|
||||||
|
review.
|
||||||
|
|
||||||
|
|
||||||
|
### 7.6 Documents & Audit Trail
|
||||||
|
|
||||||
|
**7.6.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Documents** and **Audit Trail** sections collectively ensure complete transparency and
|
||||||
|
traceability across the resignation workflow. The **Documents** tab serves as a centralized
|
||||||
|
repository of all artefacts submitted or generated during the process — including resignation
|
||||||
|
letters, presentations, communications, and acceptance letters. The **Audit Trail** automatically
|
||||||
|
captures every workflow action, recording who performed it, what was changed, and when,
|
||||||
|
ensuring full accountability and data integrity.
|
||||||
|
|
||||||
|
**7.6.2 Width**
|
||||||
|
|
||||||
|
- Allows upload and viewing of all resignation-related documents with type, uploader, and
|
||||||
|
upload date clearly listed.
|
||||||
|
- Supports restricted document viewing to authorized personas with download control.
|
||||||
|
- Provides versioned tracking of uploaded artefacts for compliance.
|
||||||
|
- The **Audit Trail** logs every stage transition, approval, comment, or document addition with
|
||||||
|
precise timestamps.
|
||||||
|
- Automatically records system-triggered events such as SLA reminders or email notifications.
|
||||||
|
|
||||||
|
|
||||||
|
**7.6.3 Depth**
|
||||||
|
|
||||||
|
- Each document remains linked to its respective workflow stage and accessible through
|
||||||
|
the **Progress Timeline**.
|
||||||
|
- All actions — _Approve_ , _Send Back_ , _Withdraw_ , _Upload_ , and _Assign_ — are recorded for
|
||||||
|
traceability.
|
||||||
|
- The system maintains an immutable historical log for governance and audit purposes.
|
||||||
|
- Entries in the Audit Trail display both user-driven and automated actions to ensure
|
||||||
|
comprehensive visibility.
|
||||||
|
|
||||||
756
docs/modular_wise/03_Termination.md
Normal file
756
docs/modular_wise/03_Termination.md
Normal file
@ -0,0 +1,756 @@
|
|||||||
|
# Offboarding System
|
||||||
|
|
||||||
|
**1.1.6 Termination Workflow Governance Updates**
|
||||||
|
|
||||||
|
- Clarified that **CEO is the final approving authority** for dealer termination cases.
|
||||||
|
- Included **CCO and CEO** as approval authorities with **Approve / Hold / Reject** options.
|
||||||
|
- Confirmed that the **Legal team issues termination letters only after CEO approval**.
|
||||||
|
- Removed **dealer portal access** from termination workflows.
|
||||||
|
- Extended **Send Back / Revoke** authority to **ZBH and DD Lead** for termination reviews.
|
||||||
|
- Aligned **F&F trigger for termination** to occur strictly on the **Last Working Day (LWD)**.
|
||||||
|
|
||||||
|
**1.1.7 Role & Persona Alignment**
|
||||||
|
|
||||||
|
- Added **NBH** to the personas section.
|
||||||
|
- Added **RBM** to applicable review and approval tables.
|
||||||
|
- Clarified that **DD ASM is responsible for interview scheduling and coordination** , with
|
||||||
|
no Admin involvement.
|
||||||
|
|
||||||
|
**1.1.8 Access Control & Visibility Refinements**
|
||||||
|
|
||||||
|
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
|
||||||
|
- Granted **approval visibility** to DD Lead where applicable.
|
||||||
|
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
|
||||||
|
with **DD Lead, RBM, and ZBH** having view access.
|
||||||
|
- Limited applicant and dealer portal access to **stage-specific and context-specific**
|
||||||
|
**scenarios only**.
|
||||||
|
- Confirmed that **dealer portal access is revoked after resignation or termination**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**1.2.5 Post-Exit Access Control**
|
||||||
|
|
||||||
|
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
|
||||||
|
completed.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 3 Definitions and Acronyms
|
||||||
|
|
||||||
|
```
|
||||||
|
Acronym Full Form / Description
|
||||||
|
RE Royal Enfield
|
||||||
|
DD Dealer Development
|
||||||
|
DD-AM Dealer Development – Area Manager
|
||||||
|
DD-ZM Dealer Development – Zonal Manager
|
||||||
|
DD-Lead Dealer Development – Lead
|
||||||
|
DD-Head Dealer Development – Head
|
||||||
|
RBM Regional Business Manager
|
||||||
|
ZBH Zonal Business Head
|
||||||
|
NBH National Business Head
|
||||||
|
ASM Area Sales Manager
|
||||||
|
FDD Financial Due Diligence (External Partner/Agency)
|
||||||
|
LOI Letter of Intent
|
||||||
|
EOR Essential Operating Requirements
|
||||||
|
LOA Letter of Appointment
|
||||||
|
F&F Full and Final (Dealer Settlement)
|
||||||
|
KT Matrix Evaluation Matrix used for scoring applicants
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 4.3 Dealer Termination – Process Flow Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.1.1 Overview
|
||||||
|
```
|
||||||
|
```
|
||||||
|
The Dealer Termination Process governs the structured offboarding of a dealership initiated
|
||||||
|
internally by Royal Enfield due to operational, contractual, or ethical concerns.
|
||||||
|
It ensures that any termination—whether due to working-capital issues, poor performance,
|
||||||
|
or unethical practices —is investigated, documented, reviewed at multiple managerial levels,
|
||||||
|
and legally validated before final execution. The process maintains full transparency and
|
||||||
|
traceability through digital records, comments, and worknotes until the Termination
|
||||||
|
Letter is issued and the Full & Final (F&F) settlement begins.
|
||||||
|
```
|
||||||
|
**4.3.2 Step-by-Step Process Flow**
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.1 ASM – Case Initiation
|
||||||
|
```
|
||||||
|
- The **Area Sales Manager (ASM)** regularly visits dealers and records **Minutes of Meeting**
|
||||||
|
**(MOM)** for performance or compliance concerns.
|
||||||
|
- After two consecutive unsatisfactory commitments or escalations, the ASM initiates
|
||||||
|
a **Termination Request** in the portal.
|
||||||
|
- Fills all operational details (Dealer Code, LOI, LOA, Sales Data, etc.), selects
|
||||||
|
a **Termination Category** (Working Capital, Performance, Unethical Practice), and
|
||||||
|
uploads supporting documents (MOMs, commitments, dealer letters).
|
||||||
|
- Submits the case to **RBM + DD-ZM** for review.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.2 RBM + DD-ZM Review
|
||||||
|
```
|
||||||
|
- The **Regional Business Manager (RBM)** and **Dealer Development Zonal Manager (DD-**
|
||||||
|
**ZM)** jointly evaluate the case.
|
||||||
|
|
||||||
|
|
||||||
|
- Conduct a meeting with the dealer and record fresh MOMs; upload dealer
|
||||||
|
commitments on letterhead.
|
||||||
|
- Provide remarks and supporting evidence.
|
||||||
|
- Actions available:
|
||||||
|
o **Approve** → Forward to ZBH
|
||||||
|
o **Send Back for Clarification** → Returns to ASM with comments
|
||||||
|
o **Withdraw** → Terminates workflow with justification
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.3 ZBH Review
|
||||||
|
```
|
||||||
|
- The **Zonal Business Head (ZBH)** reviews the full chronology (ASM visits, RBM/DD-ZM
|
||||||
|
remarks, uploaded MOMs).
|
||||||
|
- Validates escalation authenticity and dealer communication record.
|
||||||
|
- Adds remarks and forwards to **DD-Lead** for deeper review.
|
||||||
|
- The termination request is reviewed by the **ZBH** , who is authorized to **Approve, Send**
|
||||||
|
**Back, or Revoke** the termination request. **Send Back actions are communicated**
|
||||||
|
**through Work Notes** , with **mandatory remarks** recorded for traceability.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.4 DD-Lead Review & Legal Assignment
|
||||||
|
```
|
||||||
|
- The **DD-Lead** cross-verifies case chronology with all stakeholders (ASM, RBM, ZBH).
|
||||||
|
- Prepares a **Termination Presentation** summarizing facts, dealer history, and
|
||||||
|
recommendations.
|
||||||
|
- Assigns the case to **Legal Team** for inputs through the system (visible in worknotes).
|
||||||
|
- The termination request is reviewed by the **DD-Lead** , who is authorized to **Send Back or**
|
||||||
|
**Revoke** the termination request for clarification or reconsideration. All such actions
|
||||||
|
require **mandatory remarks captured in Work Notes**.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.5 Legal Verification
|
||||||
|
```
|
||||||
|
- The **Legal Team** reviews documentation, ensures contractual breaches are well-
|
||||||
|
supported, and checks all precedents.
|
||||||
|
- May raise queries via **Worknotes** or **Send Back** the case to DD-Lead for clarification.
|
||||||
|
- Once satisfied, forwards the verified case back to **DD-Lead** for next action.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.6 DD-Lead → DD-Head Review
|
||||||
|
```
|
||||||
|
- The **DD-Lead** attaches Legal’s feedback and forwards the case to **DD-Head** for strategic
|
||||||
|
review.
|
||||||
|
- **DD-Head** validates the case, evaluates impact, and presents it to **National Business**
|
||||||
|
**Head (NBH)** for final business decision.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.7 NBH Evaluation
|
||||||
|
```
|
||||||
|
- The **NBH** reviews all documentation and Legal remarks.
|
||||||
|
- May choose one of three actions:
|
||||||
|
o **Go Ahead** → Approve for issuance of **Show Cause Notice (SCN)**
|
||||||
|
o **Hold Decision** → Pause temporarily for further monitoring or negotiation
|
||||||
|
o **Raise Query** → Sends back to DD-Lead for additional input
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.8 Show Cause Notice (SCN) Issuance
|
||||||
|
```
|
||||||
|
- Upon NBH approval, the system triggers Legal to prepare and issue the **SCN**.
|
||||||
|
- The **DD-Lead** formally shares the SCN with the dealer through **DD-Admin**.
|
||||||
|
- Dealer replies to the SCN by email or letter, which **DD-Admin uploads** to the portal.
|
||||||
|
- For termination cases, the **F&F settlement process is triggered only on the Last**
|
||||||
|
**Working Day (LWD)**. The system shall **control the F&F trigger based on the LWD date** ,
|
||||||
|
irrespective of the termination approval date.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.9 Evaluation of Dealer Response
|
||||||
|
```
|
||||||
|
- The **DD-Lead** , **ZBH** , **RBM** , and **DD-Head** jointly review the dealer’s SCN response.
|
||||||
|
- Uploads internal comments, Legal feedback, and recommendation for NBH’s final
|
||||||
|
decision.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.10 NBH Final Decision
|
||||||
|
```
|
||||||
|
- The **NBH** reviews the compiled case with Legal advice and decides among:
|
||||||
|
o **Approve Termination** → Moves to CEO/CCO for confirmation
|
||||||
|
o **Reconsider** → Allow additional time or corrective action
|
||||||
|
o **Reject** → Case closed without termination
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.11 11. CEO & CCO Authorization
|
||||||
|
```
|
||||||
|
- **CEO** and **Chief Commercial Officer (CCO)** review the NBH-approved termination.
|
||||||
|
- Provide authorization on the portal.
|
||||||
|
- Once signed off, the decision becomes final.
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.12 12. Legal Termination Letter
|
||||||
|
```
|
||||||
|
- The **Legal Team** generates the **Termination Letter** to the portal.
|
||||||
|
- The letter is auto-visible to **DD-Lead** , **DD-Admin** , and **Finance**.
|
||||||
|
- A system notification is triggered to all linked personas.
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
4.3.2.13 13. DD-Admin Communication & F&F Trigger
|
||||||
|
```
|
||||||
|
- The **DD-Admin** shares the official **Termination Letter** with the dealer and field team.
|
||||||
|
- Marks the case as “Terminated” in the portal.
|
||||||
|
- Forwards the case to **Finance** for **Full & Final Settlement** initiation.
|
||||||
|
- Updates the worknote with final remarks and due-date for settlement.
|
||||||
|
|
||||||
|
### 4.4 Dealer Full & Final (F&F) Settlement – Process Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
4.4.1.1 Overview
|
||||||
|
```
|
||||||
|
The **Full & Final (F&F) Settlement Process** governs the financial closure of a dealership
|
||||||
|
following **Resignation** or **Termination**.
|
||||||
|
It ensures that all financial obligations between Royal Enfield and the dealer —
|
||||||
|
including **security deposits, recoveries, payables, and department-wise dues** — are
|
||||||
|
transparently reconciled, verified, and documented before closure.
|
||||||
|
|
||||||
|
**4.4.2 Step-by-Step Process Flow**
|
||||||
|
|
||||||
|
```
|
||||||
|
4.4.2.1 F&F Initiation
|
||||||
|
```
|
||||||
|
- Triggered automatically once the **Resignation Acceptance Letter** or **Termination**
|
||||||
|
**Letter** is uploaded by **Legal**.
|
||||||
|
- The **DD-Admin** or **DD-Lead** initiates the F&F case in the **Finance Dashboard** , which
|
||||||
|
creates a unique **FNF Case ID** linked to the dealer code.
|
||||||
|
- The system auto-fetches dealer details, associated documents, resignation/termination
|
||||||
|
date, and due dates.
|
||||||
|
- Notification is sent to the **Finance Team** and all functional departments to begin the
|
||||||
|
clearance process.
|
||||||
|
|
||||||
|
|
||||||
|
## 8 Termination
|
||||||
|
|
||||||
|
A **Dealer Termination** process is initiated when a dealership’s continuation is deemed
|
||||||
|
non-viable due to business, financial, or ethical reasons. The termination may arise
|
||||||
|
|
||||||
|
from three primary causes — **working capital inadequacy** , **continued underperformance** ,
|
||||||
|
or **unethical practices**. Cases involving working capital or performance issues follow a
|
||||||
|
|
||||||
|
structured review and approval process, allowing the concerned dealer to provide
|
||||||
|
clarification and supporting data before final decision. However, any instance
|
||||||
|
|
||||||
|
of **unethical practice** — including fraud, policy breach, or reputational risk to the brand
|
||||||
|
— results in **immediate termination**. All termination cases are documented within the
|
||||||
|
|
||||||
|
|
||||||
|
system, with remarks, evidence, and approval trails maintained for audit and
|
||||||
|
compliance verification.
|
||||||
|
|
||||||
|
### 8.1 Create Termination Request
|
||||||
|
|
||||||
|
**8.1.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Create Termination Request** form enables authorized users such as **DD-Lead** , **DD-Admin** ,
|
||||||
|
or **ASM** to initiate a termination case within the system. The form captures comprehensive
|
||||||
|
dealership details including operational timelines, format type, constitution, performance data,
|
||||||
|
and financial indicators. It also specifies the **Termination Category** (e.g., Working Capital,
|
||||||
|
Performance Issue, or Unethical Practice), supported by descriptive justification and relevant
|
||||||
|
documentation. The request forms the starting point of the digital termination workflow and
|
||||||
|
ensures that all necessary contextual data and artefacts are available for subsequent reviews and
|
||||||
|
escalations.
|
||||||
|
|
||||||
|
**8.1.2 Width**
|
||||||
|
|
||||||
|
- Allows creation of new termination requests by entering **Dealer Code** , operational details, and
|
||||||
|
financial data.
|
||||||
|
- Captures **Termination Category** and **Description** for clarity on grounds of termination.
|
||||||
|
|
||||||
|
|
||||||
|
- Supports upload of supporting artefacts such as MOMs, dealer commitments, or financial
|
||||||
|
statements.
|
||||||
|
- Automatically records creator and timestamp for traceability.
|
||||||
|
|
||||||
|
**8.1.3 Depth**
|
||||||
|
|
||||||
|
- Integrates directly with the **Progress Timeline** , displaying real-time status updates across levels.
|
||||||
|
- Each submission auto-generates an internal case ID linked to the dealer code for tracking.
|
||||||
|
- Supports structured escalation logic based on the **Termination Category** — standard route for
|
||||||
|
working capital/performance cases, immediate escalation for unethical practices.
|
||||||
|
- Maintains versioned records for every document uploaded at creation stage.
|
||||||
|
|
||||||
|
**8.1.4 Personas-wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona / Role Access Level Visibility & Permissions
|
||||||
|
ASM / DD-AM Area Level Can initiate termination requests, upload MOMs and
|
||||||
|
dealer commitments.
|
||||||
|
RBM + DD-ZM Regional / Zonal
|
||||||
|
Level
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Can view request details and validate information before
|
||||||
|
escalation.
|
||||||
|
ZBH Zonal Head Reviews initial request data, comments on justification,
|
||||||
|
and forwards to DD-Lead.
|
||||||
|
DD-Lead / DD-
|
||||||
|
Admin
|
||||||
|
```
|
||||||
|
```
|
||||||
|
National
|
||||||
|
Coordination
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Can initiate, review, and forward requests; validates
|
||||||
|
completeness and assigns to Legal if required.
|
||||||
|
Legal Review Level Can view dealer details and supporting documents for
|
||||||
|
legal evaluation.
|
||||||
|
NBH National Business
|
||||||
|
Head
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Can view the entire request summary before decision
|
||||||
|
and closure approval.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Termination Ticket overview
|
||||||
|
|
||||||
|
**8.2.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Details View** provides a consolidated summary of all key information related to the dealer
|
||||||
|
under review. It includes dealership codes, operational history, financial performance, and
|
||||||
|
termination-specific parameters. This enables reviewers at every level—whether ASM, ZBH, or
|
||||||
|
Legal—to quickly assess background context and validate evidence before taking action. The
|
||||||
|
interface also displays the current workflow stage and offers in-screen options
|
||||||
|
to **Approve** , **Withdraw** , or **Send Back** the request with remarks, ensuring traceable and reason-
|
||||||
|
based decisions.
|
||||||
|
|
||||||
|
**8.2.2 Width**
|
||||||
|
|
||||||
|
- Displays complete dealer profile: code, name, location, and GST details.
|
||||||
|
- Shows operational data: inauguration date, LOA, LOI, format, constitution, and last six-month
|
||||||
|
sales.
|
||||||
|
- Captures termination-specific data: **Termination Category** , reason, and case severity (e.g.,
|
||||||
|
“High”).
|
||||||
|
- Provides workflow action buttons— **Approve** , **Withdraw** , **Send Back** —with mandatory remarks
|
||||||
|
input.
|
||||||
|
- Integration with Work Notes for contextual communication and escalation traceability.
|
||||||
|
|
||||||
|
|
||||||
|
**8.2.3 Depth**
|
||||||
|
|
||||||
|
The **Detail Tab** serves as the **central operational dashboard** for viewing all dealer, operational,
|
||||||
|
and termination-related data within a single, structured interface. It merges static dealer master
|
||||||
|
information with dynamic workflow inputs and uploaded artefacts, ensuring contextual visibility
|
||||||
|
for all stakeholders.
|
||||||
|
|
||||||
|
```
|
||||||
|
8.2.3.1 Components & Functional Behavior
|
||||||
|
```
|
||||||
|
- **Dealer Information (Owner: DD-Admin / System Integration Layer)**
|
||||||
|
Displays master data pulled from the Dealer Master table — including **Dealer Code,**
|
||||||
|
**Name, Address, GST, Domain Name, City Category, Sales Code, Service Code, and GMA**
|
||||||
|
**Code**.
|
||||||
|
o Synced automatically from RE’s **Dealer Database (Master Registry)**.
|
||||||
|
o Read-only for all personas except system admin for data correction requests.
|
||||||
|
o Enables search and cross-referencing across termination, resignation, and
|
||||||
|
onboarding records.
|
||||||
|
- **Operational Details (Owner: DD-Lead / Workflow Engine)**
|
||||||
|
Highlights the dealership’s business health indicators and structural data, including **LOA,**
|
||||||
|
**LOI, Inauguration Date, Constitution Type, Dealership Type, Format Category, Dealer**
|
||||||
|
**Score Card Band, and Last Six-Month Sales**.
|
||||||
|
o Pulled dynamically from the Sales & Performance Module.
|
||||||
|
o Reflects the most recent sales cycle, ensuring leadership sees live performance
|
||||||
|
metrics during termination decision-making.
|
||||||
|
o Editable only by DD-Lead or authorized DD-Admin prior to case lock.
|
||||||
|
- **Termination Details (Owner: DD-Lead / DD-ZM / Legal)**
|
||||||
|
Captures case-specific details such as **Termination Category, Reason Description, and**
|
||||||
|
**Attachments**.
|
||||||
|
o Termination Category includes options like _Working Capital Issues, Performance_
|
||||||
|
_Shortfall, Breach of Agreement, or Unethical Practices_.
|
||||||
|
o Documents uploaded here are visible to all reviewers across the approval chain,
|
||||||
|
maintaining transparency.
|
||||||
|
o Legal team references this section while framing the **Show Cause Notice (SCN)** or
|
||||||
|
final termination letter.
|
||||||
|
- **Workflow Actions (Owner: Workflow Engine / DD-Lead)**
|
||||||
|
Displays **Approve, Withdraw, and Send Back** controls based on role permissions.
|
||||||
|
o Triggers automated workflow transitions and real-time updates in **Progress**
|
||||||
|
**Timeline** and **Audit Trail**.
|
||||||
|
o Any action logs mandatory remarks under “Communication & Notes” with
|
||||||
|
timestamp and user identity.
|
||||||
|
o Permissions vary per role:
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
▪ ASM, RBM: Can only comment or escalate.
|
||||||
|
▪ ZBH, DD-Lead, NBH: Can approve or send back.
|
||||||
|
▪ Legal: Can finalize after NBH approval.
|
||||||
|
```
|
||||||
|
- **Document Management Section (Owner: DD-Admin / Legal)**
|
||||||
|
Repository displaying all uploaded evidence or reports associated with the termination.
|
||||||
|
o Documents listed by **name, type, uploader, and date**.
|
||||||
|
o Supports inline viewing (no download needed) for internal confidentiality.
|
||||||
|
o File retention policy aligns with RE’s compliance standards (minimum 7 years).
|
||||||
|
- **Audit Trail (Owner: Workflow Engine / System Log)**
|
||||||
|
Chronologically records every action taken within the termination case — including
|
||||||
|
user, timestamp, and nature of change.
|
||||||
|
|
||||||
|
**8.2.4 Personas-wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona / Role Access Level Visibility & Permissions
|
||||||
|
ASM / DD-AM Area Level Can initiate and upload dealer MOMs and commitment
|
||||||
|
records.
|
||||||
|
RBM + DD-ZM Regional / Zonal
|
||||||
|
Level
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Review dealer details, validate termination rationale,
|
||||||
|
and escalate with remarks.
|
||||||
|
ZBH Zonal Business
|
||||||
|
Head
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approves or returns the case with comments; can
|
||||||
|
forward to DD-Lead.
|
||||||
|
DD-Lead / DD-
|
||||||
|
Admin
|
||||||
|
```
|
||||||
|
```
|
||||||
|
National
|
||||||
|
Coordination
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Validate details, review documents, assign to Legal, or
|
||||||
|
push for F&F after NBH approval.
|
||||||
|
Legal Legal Level Review dealer information, validate grounds, and issue
|
||||||
|
termination letter.
|
||||||
|
NBH National Head Provides final decision and authorization before case
|
||||||
|
closure.
|
||||||
|
```
|
||||||
|
### 8.3 Termination Approval & Review Process
|
||||||
|
|
||||||
|
**8.3.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Termination Approval module** enables Royal Enfield’s internal stakeholders to manage
|
||||||
|
dealership termination cases in a structured, transparent, and traceable workflow. It ensures that
|
||||||
|
|
||||||
|
|
||||||
|
every dealership performance concern — whether due to **working capital shortfall** , **sustained
|
||||||
|
underperformance** , or **unethical practices** — is systematically reviewed, documented, and acted
|
||||||
|
upon through the defined escalation hierarchy.
|
||||||
|
|
||||||
|
This module supports structured documentation of **dealer meetings** , **uploaded
|
||||||
|
artefacts** , **reviewer remarks** , and **legal correspondence** , ensuring no manual communication
|
||||||
|
dependency.
|
||||||
|
All approvals, send-backs, or withdrawals are centrally logged, supported by **Work Notes** ,
|
||||||
|
ensuring collaborative clarity and institutional memory across teams.
|
||||||
|
|
||||||
|
The **CEO is the final approving authority** for dealer termination cases. The **Legal team prepares
|
||||||
|
and issues the termination letter only after CEO approval** , and **not upon NBH approval**.
|
||||||
|
**CCO and CEO** are included as approval authorities with **Approve, Hold, and Reject options**.
|
||||||
|
The **dealer does not have portal access** for termination workflows.
|
||||||
|
|
||||||
|
**8.3.2 Width**
|
||||||
|
|
||||||
|
The process spans across the complete DD and Legal hierarchy, ensuring clear role-based
|
||||||
|
accountability:
|
||||||
|
|
||||||
|
- **ASM:** Conducts monthly visits, logs Meeting of Minutes (MOM), uploads dealer
|
||||||
|
commitment letter and personal observations. Logging MOM is not the part of this system
|
||||||
|
but when he feel to trigger Termination, he will log as description & associate documents
|
||||||
|
while initiating the flow.
|
||||||
|
- **RBM + DD-ZM:** Escalate after repeated concerns, conduct joint meetings, and document
|
||||||
|
dealer responses on portal.
|
||||||
|
- **ZBH:** Reviews zonal-level non-compliance, escalates unresolved cases to DD-Lead and
|
||||||
|
NBH.
|
||||||
|
- **DD-Lead:** Reviews consolidated reports, validates escalation records, prepares case
|
||||||
|
presentation, and assigns to Legal.
|
||||||
|
- **Legal:** Reviews chronology, evaluates policy or contractual breaches, issues SCN, and
|
||||||
|
prepares final Termination Letter.
|
||||||
|
- **DD-Head:** Reviews with DD-Lead and Legal; presents case to NBH for decision.
|
||||||
|
- **NBH:** Provides final decision – approve, query, or hold.
|
||||||
|
- **DD-Admin:** Uploads dealer’s SCN response and handles F&F coordination post Legal
|
||||||
|
issuance.
|
||||||
|
|
||||||
|
|
||||||
|
**8.3.3 Depth**
|
||||||
|
|
||||||
|
- **Structured Case Creation (Owner: DD-Lead / DD-Admin / ASM)**
|
||||||
|
A Termination case is initiated through the “Create Termination Request” form by DD-
|
||||||
|
Lead, DD-Admin, or ASM.
|
||||||
|
o Each request is tagged with a unique **Termination ID** (e.g., TERM-001).
|
||||||
|
o Dealer and operational data are automatically fetched from the **Dealer**
|
||||||
|
**Master** and **Sales System** for accuracy.
|
||||||
|
- **Case Workflow Management (Owner: Workflow Engine)**
|
||||||
|
Each stage of the termination journey — from ASM initiation to Legal closure — is
|
||||||
|
mapped to approval levels.
|
||||||
|
o **ASM → RBM/DD-ZM → ZBH → DD-Lead → Legal → DD-Head → NBH**.
|
||||||
|
o Actions at every level (Approve, Withdraw, Send Back) are recorded with
|
||||||
|
mandatory remarks.
|
||||||
|
o Each remark auto-updates in **Work Notes** and **Progress Timeline** , triggering
|
||||||
|
instant notifications to the next role.
|
||||||
|
- **Work Note Integration (Owner: All Reviewers)**
|
||||||
|
The **Work Note** acts as the **central communication thread** within each termination case.
|
||||||
|
o Each reviewer (ASM, RBM, ZBH, DD-Lead, Legal, etc.) can post contextual remarks,
|
||||||
|
share discussions, or tag specific users.
|
||||||
|
o Tagged users (e.g., @DD-Lead, @Legal) receive instant notifications via **system**
|
||||||
|
**alerts** and **email**.
|
||||||
|
o Work Notes serve as a real-time collaboration and escalation record — every
|
||||||
|
comment, clarification, or update remains **time-stamped and user-tagged**.
|
||||||
|
o Legal and DD-Head may also use Work Notes to request clarification from lower
|
||||||
|
hierarchies (ASM, RBM, ZBH).
|
||||||
|
o Once a note is submitted, it becomes immutable and part of the **permanent**
|
||||||
|
**record** under **Audit Trail**.
|
||||||
|
- **Meeting & Artefact Uploads (Owner: ASM, RBM, ZBH)**
|
||||||
|
Each level of escalation includes upload of MOMs, dealer commitment letters, and
|
||||||
|
observations while Approving at his level.
|
||||||
|
o Artefacts are uploaded as PDFs (e.g., _Meeting_MOM_June2025.pdf_ ).
|
||||||
|
o Dealer commitments are scanned and attached for cross-reference during Legal
|
||||||
|
and NBH reviews.
|
||||||
|
- **Approval Actions (Owner: Workflow Engine)**
|
||||||
|
Reviewers can take the following actions:
|
||||||
|
o **Approve:** Confirms escalation readiness for next level.
|
||||||
|
o **Send Back:** Pushes case back for clarification with remarks visible in Work Notes.
|
||||||
|
o **Withdraw:** Used when the concern is resolved or no termination action is required.
|
||||||
|
Each action is recorded in both **Audit Trail** and **Work Notes** , ensuring clarity on
|
||||||
|
decision paths.
|
||||||
|
|
||||||
|
|
||||||
|
- **Legal Review and Issuance (Owner: Legal Team)**
|
||||||
|
Legal reviews the case chronology and uploaded artefacts.
|
||||||
|
o If clarification is needed, they “Send Back” via Work Notes.
|
||||||
|
o Once validated, Legal create the **Show Cause Notice (SCN)** to the portal and later
|
||||||
|
create the **Termination Letter** post NBH approval.
|
||||||
|
o These Show cause Notice and Termination Letter will be created within the system
|
||||||
|
o All uploaded legal artefacts remain accessible to DD-Lead, DD-Admin, and NBH.
|
||||||
|
- **Dealer Interaction & Closure (Owner: DD-Admin / DD-Lead)**
|
||||||
|
Dealer replies to the SCN via DD-Admin, who uploads the response to the portal.
|
||||||
|
o DD-Lead reviews dealer’s response with inputs from RBM and ZBH, updates
|
||||||
|
closure remarks, and forwards to NBH.
|
||||||
|
o Post-approval, Legal uploads the Termination Letter, visible to DD-Admin and
|
||||||
|
dealer.
|
||||||
|
o DD-Admin initiates **F&F** coordination, ensuring all records are finalized within SLA.
|
||||||
|
- **Immediate Termination (Owner: DD-Lead + Legal)**
|
||||||
|
Cases categorized under “Unethical Practice” trigger direct routing to Legal + DD-
|
||||||
|
Lead, skipping intermediate reviews.
|
||||||
|
o Immediate Legal action and issuance of termination communication occur within
|
||||||
|
the system, ensuring swift compliance.
|
||||||
|
- **Audit Trail (Owner: System Engine)**
|
||||||
|
Each user action — approval, send back, upload, comment — is timestamped and
|
||||||
|
permanently logged.
|
||||||
|
o The trail captures: _User Name, Action Type, Timestamp, Remarks Summary, and_
|
||||||
|
_Linked Artefact_.
|
||||||
|
o Accessible by DD-Lead, Legal, DD-Head, and NBH for compliance review.
|
||||||
|
|
||||||
|
**8.3.4 Personas-wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona Responsibilities & Key Actions Access Rights
|
||||||
|
ASM Creates termination request, uploads MOM & dealer
|
||||||
|
commitments, adds initial remarks and observations.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Create, View,
|
||||||
|
Comment
|
||||||
|
RBM / DD-
|
||||||
|
ZM
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Reviews ASM input, conducts escalation meetings,
|
||||||
|
uploads MOM, provides joint recommendations.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
View, Approve,
|
||||||
|
Send Back
|
||||||
|
ZBH Reviews regional non-compliance, uploads MOM,
|
||||||
|
forwards unresolved cases to DD-Lead.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approve, Send
|
||||||
|
Back
|
||||||
|
DD-Lead Reviews full chronology, validates artefacts, triggers Legal
|
||||||
|
for input, issues SCN, consolidates for final closure.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Full Access,
|
||||||
|
Approve,
|
||||||
|
Withdraw
|
||||||
|
Legal Reviews chronology, uploads SCN, issues Termination
|
||||||
|
Letter, queries if required through Work Notes.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approve, Send
|
||||||
|
Back, Upload
|
||||||
|
DD-Head Reviews consolidated cases, presents them to NBH for
|
||||||
|
final decision.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Review, Comment
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
NBH Approves or holds termination case; final authority on go-
|
||||||
|
ahead decisions.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approve / Hold
|
||||||
|
```
|
||||||
|
```
|
||||||
|
DD-Admin Uploads dealer’s SCN reply, final Termination Letter, and
|
||||||
|
initiates F&F.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Upload, Close
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Dealer
|
||||||
|
(Read-only)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Views SCN and final Termination Letter. View Only
|
||||||
|
```
|
||||||
|
### 8.4 Termination Progress Timeline
|
||||||
|
|
||||||
|
**8.4.1 Functionality Scope**
|
||||||
|
|
||||||
|
The **Termination Progress Timeline** provides a stage-wise visualization of the entire termination
|
||||||
|
journey — from case initiation to final closure. It ensures that every escalation, document, review,
|
||||||
|
and approval is tracked transparently with timestamped accountability.
|
||||||
|
|
||||||
|
Each level in the workflow — from **ASM initiation** to **CEO authorization** — is dynamically
|
||||||
|
reflected with role names, document counts, feedback notes, and status indicators.
|
||||||
|
The module promotes structured collaboration by integrating **Work Notes** and **Audit
|
||||||
|
Trail** updates at each milestone, enabling leadership to monitor the decision flow in real time.
|
||||||
|
|
||||||
|
|
||||||
|
**8.4.2 Width**
|
||||||
|
|
||||||
|
The timeline consolidates inputs from multiple roles, creating an end-to-end view of operational,
|
||||||
|
business, and legal evaluations:
|
||||||
|
|
||||||
|
- **ASM** initiates the request and uploads meeting artefacts.
|
||||||
|
- **RBM / DD-ZM** review and escalate based on repeated violations.
|
||||||
|
- **ZBH** performs zonal validation and comments.
|
||||||
|
- **DD-Lead** consolidates data, reviews chronology, and assigns to Legal.
|
||||||
|
- **Legal** verifies contract breaches and provides legal opinion or Show Cause Notice (SCN).
|
||||||
|
- **NBH** performs business-level evaluation and grants or holds final approval.
|
||||||
|
- **CEO / CCO** complete the executive authorization.
|
||||||
|
- **DD-Admin** coordinates issuance of the final Termination Letter and forwards it to F&F.
|
||||||
|
|
||||||
|
Each transition (approve, send-back, withdraw) automatically updates the timeline with the
|
||||||
|
reviewer’s remarks and uploaded artefacts.
|
||||||
|
|
||||||
|
**8.4.3 Depth**
|
||||||
|
|
||||||
|
The Termination Progress Timeline follows a clearly defined 14-stage lifecycle. Each stage is
|
||||||
|
associated with specific ownership, document uploads, and Work Note actions.
|
||||||
|
|
||||||
|
```
|
||||||
|
8.4.3.1 Stage-wise Breakdown
|
||||||
|
```
|
||||||
|
1. **Request Initiated** – _ASM / Initiator_
|
||||||
|
o Case created with details, termination reason, and dealer code.
|
||||||
|
o Supporting documents like MOM and commitment letters attached.
|
||||||
|
o Remarks and feedback logged in Work Notes.
|
||||||
|
2. **RBM Review** – _RBM + DD-ZM_
|
||||||
|
o Joint meeting notes uploaded; recommendations shared.
|
||||||
|
o Approve or Send-Back with clarification via Work Note.
|
||||||
|
3. **ZBH Review** – _Zonal Business Head_
|
||||||
|
o Evaluates pattern of violations, reviews MOM chain, and adds escalation remarks.
|
||||||
|
4. **DD Lead Review** – _DD-Lead_
|
||||||
|
o Consolidates documentation from ASM, RBM, and ZBH.
|
||||||
|
o Prepares case synopsis and assigns to Legal for compliance validation.
|
||||||
|
5. **Legal Verification** – _Legal Department_
|
||||||
|
o Reviews breach type (Working Capital, Performance, Unethical Practice).
|
||||||
|
o Queries or approves via Work Notes.
|
||||||
|
o Uploads draft SCN if verified.
|
||||||
|
6. **NBH Evaluation** – _National Business Head_
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
o Reviews termination recommendation; may approve, hold, or query.
|
||||||
|
```
|
||||||
|
7. **Show Cause Notice (SCN)** – _Legal + DD-Lead_
|
||||||
|
o Official SCN issued to dealer.
|
||||||
|
o Dealer reply awaited; all correspondence uploaded.
|
||||||
|
8. **DD Lead & Legal Review** – _Joint Review_
|
||||||
|
o Evaluates dealer’s SCN reply.
|
||||||
|
o Records internal discussion outcome in Work Notes.
|
||||||
|
9. **DD-Head Review** – _Dealer Development Head_
|
||||||
|
o Prepares presentation and recommendation for NBH.
|
||||||
|
10. **CCO Approval** – _Chief Commercial Officer_
|
||||||
|
o Reviews and endorses NBH’s decision.
|
||||||
|
11. **CEO Final Approval** – _Chief Executive Officer_
|
||||||
|
o Authorizes final termination execution.
|
||||||
|
12. **Legal – Termination Letter** – _Legal Team_
|
||||||
|
o Uploads signed Termination Letter to portal.
|
||||||
|
o Triggers auto-notifications to DD-Lead and DD-Admin.
|
||||||
|
13. **DD-Admin – Share with Dealer** – _DD-Admin_
|
||||||
|
o Forwards Termination Letter to dealer.
|
||||||
|
o Initiates F&F process and records completion date.
|
||||||
|
14. **Dealer Terminated** – _System Generated_
|
||||||
|
o Marks dealership status as “Terminated.”
|
||||||
|
o Case locked for further edits; all data archived under Audit Trail.
|
||||||
|
|
||||||
|
```
|
||||||
|
8.4.3.2 Work Note Integration
|
||||||
|
```
|
||||||
|
- Each stage allows the reviewer to post contextual **Work Notes** for coordination,
|
||||||
|
clarification, or escalation.
|
||||||
|
- Notes automatically capture **author, timestamp, and linked stage**.
|
||||||
|
- Tagged users receive both **email** and **in-app alerts**.
|
||||||
|
- Work Notes act as the **single source of truth** , capturing every internal discussion and
|
||||||
|
external clarification.
|
||||||
|
- Once the case reaches “Dealer Terminated,” Work Notes are archived as part of the
|
||||||
|
official record visible under **Audit Trail**.
|
||||||
|
|
||||||
|
**8.4.4 Personas-wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona Visibility in Timeline Actions Allowed
|
||||||
|
ASM Initiate request, view complete history, comment
|
||||||
|
in Work Notes.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Create, Upload Docs,
|
||||||
|
Comment
|
||||||
|
RBM / DD-ZM See all lower-level stages, add remarks, approve or
|
||||||
|
send-back.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approve, Send-Back,
|
||||||
|
Comment
|
||||||
|
ZBH Access RBM & ASM artefacts, escalate to DD-Lead. Approve, Send-Back
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
DD-Lead Full timeline visibility, assign to Legal, manage SCN,
|
||||||
|
approve final closure.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Full Access
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Legal Review termination grounds, issue SCN, upload
|
||||||
|
Termination Letter.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approve, Send-Back,
|
||||||
|
Upload Docs
|
||||||
|
NBH View all previous stages, make go/no-go decision. Approve / Hold
|
||||||
|
CCO / CEO Executive-level read access, approve final
|
||||||
|
termination.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Approve Only
|
||||||
|
```
|
||||||
|
```
|
||||||
|
DD-Admin View complete timeline, upload dealer response &
|
||||||
|
Legal letter, initiate F&F.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Upload, Close
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Dealer (Read-
|
||||||
|
only)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
View SCN and Termination Letter post-issuance. View Only
|
||||||
|
```
|
||||||
1023
docs/modular_wise/04_FF_Settlement.md
Normal file
1023
docs/modular_wise/04_FF_Settlement.md
Normal file
File diff suppressed because it is too large
Load Diff
252
docs/modular_wise/05_Constitutional_Change.md
Normal file
252
docs/modular_wise/05_Constitutional_Change.md
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
|
||||||
|
**1.2.4 Dealer Constitutional Change Enablement**
|
||||||
|
|
||||||
|
- Enabled dealers to **initiate constitutional change requests** post onboarding.
|
||||||
|
- Supported all approved constitution change scenarios:
|
||||||
|
o Proprietorship, Partnership, LLP, and Private Limited permutations
|
||||||
|
- Implemented **dynamic document requirement determination** based on target
|
||||||
|
constitution.
|
||||||
|
- Explicitly confirmed **no OCR-based document validation** ; all validations are manual and
|
||||||
|
role-driven.
|
||||||
|
- Ensured statutory compliance via Legal review before master data updates.
|
||||||
|
|
||||||
|
**1.2.5 Post-Exit Access Control**
|
||||||
|
|
||||||
|
- Enforced system rule to **revoke dealer portal access** once resignation or termination is
|
||||||
|
completed.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- **Change in Constitution Request**
|
||||||
|
|
||||||
|
```
|
||||||
|
The dealer can initiate a Change in Constitution request to seek approval from RE
|
||||||
|
management for ownership or structural changes within the dealership. Upon approval,
|
||||||
|
the dealer may proceed with the legally compliant transition.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Supported Change in Constitution scenarios include:
|
||||||
|
```
|
||||||
|
```
|
||||||
|
o Proprietorship (Single Owner) → Partnership
|
||||||
|
o Proprietorship → LLP (Limited Liability Partnership)
|
||||||
|
o Proprietorship → Private Limited
|
||||||
|
o Partnership → LLP
|
||||||
|
o Partnership → Private Limited
|
||||||
|
o Private Limited → LLP
|
||||||
|
o Private Limited → Partnership
|
||||||
|
```
|
||||||
|
All dealer-initiated requests are subject to **defined validations, mandatory document
|
||||||
|
submissions, role-based reviews, and approvals**. The dealer’s access is **restricted to initiation,
|
||||||
|
document upload, and status visibility** , with **final decision-making authority retained by
|
||||||
|
authorized internal stakeholders of RE**
|
||||||
|
|
||||||
|
|
||||||
|
## 3 Definitions and Acronyms
|
||||||
|
|
||||||
|
```
|
||||||
|
Acronym Full Form / Description
|
||||||
|
RE Royal Enfield
|
||||||
|
DD Dealer Development
|
||||||
|
DD-AM Dealer Development – Area Manager
|
||||||
|
DD-ZM Dealer Development – Zonal Manager
|
||||||
|
DD-Lead Dealer Development – Lead
|
||||||
|
DD-Head Dealer Development – Head
|
||||||
|
RBM Regional Business Manager
|
||||||
|
ZBH Zonal Business Head
|
||||||
|
NBH National Business Head
|
||||||
|
ASM Area Sales Manager
|
||||||
|
FDD Financial Due Diligence (External Partner/Agency)
|
||||||
|
LOI Letter of Intent
|
||||||
|
EOR Essential Operating Requirements
|
||||||
|
LOA Letter of Appointment
|
||||||
|
F&F Full and Final (Dealer Settlement)
|
||||||
|
KT Matrix Evaluation Matrix used for scoring applicants
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 Dealer Constitutional Change Management
|
||||||
|
|
||||||
|
|
||||||
|
**12.2.1 Functionality Scope**
|
||||||
|
|
||||||
|
This functionality enables a **dealer to initiate, track, and manage requests for change in business
|
||||||
|
constitution** through the portal after successful onboarding. The system provides a **structured
|
||||||
|
self-service mechanism** to propose constitution changes, capture legally required information,
|
||||||
|
and submit **constitution-specific mandatory documents** , while routing the request through
|
||||||
|
a **defined internal review and approval workflow**.
|
||||||
|
|
||||||
|
**12.2.2 Functional Width**
|
||||||
|
|
||||||
|
- Displays a **dealer-facing constitutional change dashboard** with summary indicators:
|
||||||
|
o Total Requests
|
||||||
|
o Pending Requests
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
o Completed Requests
|
||||||
|
```
|
||||||
|
- Lists all **constitution change requests** with:
|
||||||
|
o Request ID
|
||||||
|
o Current constitution
|
||||||
|
o Proposed constitution
|
||||||
|
o Submission date
|
||||||
|
o Current status
|
||||||
|
o Progress percentage
|
||||||
|
- Enables **initiation of a new constitutional change request**
|
||||||
|
- Supports the following **constitution change cases** :
|
||||||
|
o Proprietorship (Single Owner) → Partnership
|
||||||
|
o Proprietorship → LLP (Limited Liability Partnership)
|
||||||
|
o Proprietorship → Private Limited
|
||||||
|
o Partnership → LLP
|
||||||
|
o Partnership → Private Limited
|
||||||
|
o Private Limited → LLP
|
||||||
|
o Private Limited → Partnership
|
||||||
|
- Dynamically determines **mandatory document requirements** based on the **target**
|
||||||
|
**constitution**
|
||||||
|
- Allows **document upload only as per applicable case**
|
||||||
|
- Provides **role-based visibility** into request details, documents, and progress
|
||||||
|
- Prevents duplicate or parallel requests as per policy
|
||||||
|
|
||||||
|
**12.2.3 Functional Depth**
|
||||||
|
|
||||||
|
- Constitutional change requests can be initiated **only for active and eligible dealers**.
|
||||||
|
- On selecting **“New Constitutional Change”** , the dealer is presented with a structured
|
||||||
|
submission form capturing:
|
||||||
|
o Dealer Code and Dealer Name (auto-populated, non-editable)
|
||||||
|
o Current constitution (auto-populated)
|
||||||
|
o Proposed constitution (selectable from allowed options)
|
||||||
|
o Reason for change
|
||||||
|
o Details of new partners / members (where applicable)
|
||||||
|
o Proposed shareholding pattern
|
||||||
|
- Based on the **proposed constitution** , the system determines the **mandatory document**
|
||||||
|
**checklist** as follows:
|
||||||
|
|
||||||
|
**12.2.4 Document Applicability Rules**
|
||||||
|
|
||||||
|
**A. Any change resulting in Partnership requires:**
|
||||||
|
|
||||||
|
|
||||||
|
- GST Registration Certificate
|
||||||
|
- Firm PAN Copy
|
||||||
|
- Self-attested KYC documents
|
||||||
|
- Partnership Agreement (Notarised)
|
||||||
|
- Business Purchase Agreement (BPA)
|
||||||
|
- Firm Registration Certificate (Partnership)
|
||||||
|
- Cancelled Cheque
|
||||||
|
- Declaration / Authorization Letter
|
||||||
|
|
||||||
|
**B. Any change resulting in LLP requires:**
|
||||||
|
|
||||||
|
- GST Registration Certificate
|
||||||
|
- Firm PAN Copy
|
||||||
|
- Self-attested KYC documents
|
||||||
|
- Certificate of Incorporation (COI)
|
||||||
|
- Business Purchase Agreement (BPA)
|
||||||
|
- LLP Agreement (Notarised)
|
||||||
|
- Cancelled Cheque
|
||||||
|
- Declaration / Authorization Letter
|
||||||
|
|
||||||
|
**C. Any change resulting in Private Limited requires:**
|
||||||
|
|
||||||
|
- GST Registration Certificate
|
||||||
|
- Firm PAN Copy
|
||||||
|
- Self-attested KYC documents
|
||||||
|
- MOA (Memorandum of Association)
|
||||||
|
- AOA (Articles of Association)
|
||||||
|
- Certificate of Incorporation (COI)
|
||||||
|
- Business Purchase Agreement (BPA)
|
||||||
|
- Cancelled Cheque
|
||||||
|
- Declaration / Authorization Letter
|
||||||
|
|
||||||
|
**D. Any change resulting in Proprietorship requires:**
|
||||||
|
|
||||||
|
- GST Registration Certificate
|
||||||
|
- Firm PAN Copy
|
||||||
|
- Self-attested KYC documents
|
||||||
|
- Cancelled Cheque
|
||||||
|
- Declaration / Authorization Letter
|
||||||
|
- The system enforces **document completeness validation** before allowing submission or
|
||||||
|
progression.
|
||||||
|
|
||||||
|
|
||||||
|
- **No OCR or automated document content extraction** is performed; all validations
|
||||||
|
are **manual and role-driven**.
|
||||||
|
- Upon submission:
|
||||||
|
o The request is routed through a **multi-level internal review workflow** (DD ASM →
|
||||||
|
DD ZM / RBM → ZBH → DD Lead → DD Head → NBH → Legal, as applicable).
|
||||||
|
- Authorized internal roles may **Approve, Send Back, or Revoke** the request.
|
||||||
|
- **Send Back and Revoke actions are communicated through Work Notes** , with mandatory
|
||||||
|
remarks.
|
||||||
|
- The **Legal team validates statutory compliance** and facilitates updates to dealer master
|
||||||
|
records post-approval.
|
||||||
|
- Upon final approval:
|
||||||
|
o Dealer constitution details are updated in the system of record.
|
||||||
|
o All actions, documents, and decisions are **logged for audit and compliance**.
|
||||||
|
|
||||||
|
**12.2.5 11.2.4 Personas-wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona Responsibilities Access Rights
|
||||||
|
Dealer Initiates and tracks constitutional
|
||||||
|
change requests.
|
||||||
|
```
|
||||||
|
- Initiate new constitutional change
|
||||||
|
request
|
||||||
|
- Provide change details and reasons
|
||||||
|
- Upload mandatory documents as
|
||||||
|
per applicable case
|
||||||
|
- View request status, progress, and
|
||||||
|
Work Notes
|
||||||
|
DD ASM Coordinates document collection and
|
||||||
|
supports validation.
|
||||||
|
- View requests
|
||||||
|
- Upload supporting documents
|
||||||
|
- Assist in coordination
|
||||||
|
DD ZM Performs zonal-level review and
|
||||||
|
validation.
|
||||||
|
- View requests
|
||||||
|
- Review and provide inputs
|
||||||
|
RBM Conducts regional business evaluation. • View requests
|
||||||
|
- Review and recommend
|
||||||
|
ZBH Ensures zonal governance compliance. • Review requests
|
||||||
|
- Send Back or Revoke with
|
||||||
|
mandatory Work Notes
|
||||||
|
- Approve as per hierarchy
|
||||||
|
DD Lead Ensures adherence to dealer
|
||||||
|
development policies.
|
||||||
|
- Review requests
|
||||||
|
- Send Back or Revoke with
|
||||||
|
mandatory Work Notes
|
||||||
|
- Approval visibility
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
DD Head Oversees dealer development
|
||||||
|
governance.
|
||||||
|
```
|
||||||
|
- Review requests
|
||||||
|
- Send Back or Revoke with
|
||||||
|
mandatory Work Notes
|
||||||
|
- Approve as per hierarchy
|
||||||
|
NBH Provides senior management approval. • Review requests
|
||||||
|
- Send Back or Revoke with
|
||||||
|
mandatory Work Notes
|
||||||
|
- Final approval authority
|
||||||
|
Legal
|
||||||
|
Team
|
||||||
|
|
||||||
|
```
|
||||||
|
Validates statutory compliance and legal
|
||||||
|
documentation.
|
||||||
|
```
|
||||||
|
- Review documents
|
||||||
|
- Validate compliance
|
||||||
|
- Facilitate post-approval updates
|
||||||
|
System Enforces rules and audit compliance. • Determine applicable documents
|
||||||
|
dynamically
|
||||||
|
- Validate completeness (no OCR)
|
||||||
|
- Track progress and status
|
||||||
|
- Maintain audit trail
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
344
docs/modular_wise/06_Relocation.md
Normal file
344
docs/modular_wise/06_Relocation.md
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
# RE Onboarding & Offboarding System
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
System Requirements Specifications
|
||||||
|
```
|
||||||
|
## 16 - Oct- 2025
|
||||||
|
|
||||||
|
## Version 1. 4
|
||||||
|
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### 1.1 Change Log – Version 2.0
|
||||||
|
|
||||||
|
|
||||||
|
**1.1.1 Notification Channel Enhancement**
|
||||||
|
|
||||||
|
- Added **WhatsApp as a supported notification channel** for reminders and workflow
|
||||||
|
communications (e.g., questionnaire completion and status updates), while restricting
|
||||||
|
sensitive document sharing to email only.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**1.1.8 Access Control & Visibility Refinements**
|
||||||
|
|
||||||
|
- Defined **view-only access** for DD ASM, DD ZM, and RBM at relevant workflow stages.
|
||||||
|
- Granted **approval visibility** to DD Lead where applicable.
|
||||||
|
- Enabled **DD ASM and DD ZM** to upload site readiness and LOA-related documents,
|
||||||
|
with **DD Lead, RBM, and ZBH** having view access.
|
||||||
|
- Limited applicant and dealer portal access to **stage-specific and context-specific**
|
||||||
|
**scenarios only**.
|
||||||
|
- Confirmed that **dealer portal access is revoked after resignation or termination**.
|
||||||
|
|
||||||
|
**1.1.9 Terminology & Documentation Corrections**
|
||||||
|
|
||||||
|
- Clarified **KT Matrix as Kepner Tregoe Matrix** for consistency and correctness.
|
||||||
|
|
||||||
|
**1.1.10 Super Admin Role Introduction**
|
||||||
|
|
||||||
|
- Introduced a **Super Admin (Master Role)** with end-to-end access and workflow control
|
||||||
|
across modules.
|
||||||
|
- Defined segregation of duties by splitting Super Admin into **two DD Admin roles** with
|
||||||
|
clearly scoped responsibilities.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**1.2.1 Introduction of Dealer Portal**
|
||||||
|
|
||||||
|
- Introduced a **Dealer Portal capability** enabling onboarded dealers to initiate and track
|
||||||
|
post-onboarding lifecycle requests through the portal.
|
||||||
|
- Dealer actions are governed by **role-based access controls** , approval hierarchies, and
|
||||||
|
audit mechanisms.
|
||||||
|
|
||||||
|
|
||||||
|
**1.2.3 Dealer Relocation Request Enablement**
|
||||||
|
|
||||||
|
- Enabled dealers to **initiate and track relocation requests** through a guided workflow.
|
||||||
|
- Added support for:
|
||||||
|
o Manual or map-based location entry
|
||||||
|
o Distance calculation from existing location
|
||||||
|
o Property type selection and expected relocation date
|
||||||
|
|
||||||
|
|
||||||
|
- Introduced **document-driven relocation validation** , including statutory, legal, property,
|
||||||
|
and infrastructure documents.
|
||||||
|
- Implemented **multi-level approval workflow** with Work Notes–based communication
|
||||||
|
and audit trail.
|
||||||
|
- Ensured dealer has **view and upload access only** , with approvals retained by RE
|
||||||
|
stakeholders.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 1 System Overview & Problem Statement
|
||||||
|
|
||||||
|
**1.1.1 System Overview**
|
||||||
|
|
||||||
|
The **Dealer Onboarding and Offboarding System** for **Royal Enfield (RE)** is designed to **digitize,
|
||||||
|
standardize, and streamline** the complete dealer lifecycle — from **application and
|
||||||
|
evaluation** to **approval, resignation, termination, and full-and-final (F&F) settlement**.
|
||||||
|
|
||||||
|
At present, the process operates through **manual coordination** , involving **emails, spreadsheets,
|
||||||
|
and physical documentation** , which makes it difficult to maintain visibility, accountability, and
|
||||||
|
consistency across teams.
|
||||||
|
|
||||||
|
The proposed solution introduces a **centralized digital platform** that brings all stakeholders onto
|
||||||
|
a single workflow. It ensures that every stage — **onboarding, operational approvals, financial
|
||||||
|
diligence, legal validation, and final closure** — follows a **structured and traceable process**.
|
||||||
|
|
||||||
|
The system integrates seamlessly with existing RE applications such as **SSO** , **SAP** , and **Finance
|
||||||
|
modules** , providing **role-based access** , **real-time tracking** , and **secure document management**.
|
||||||
|
It also offers **automated workflows** , **configurable approval hierarchies** , and **AI-assisted decision
|
||||||
|
support** to improve efficiency and reduce turnaround time.
|
||||||
|
|
||||||
|
By moving to a digital workflow, Royal Enfield will achieve higher levels of **process
|
||||||
|
efficiency** , **data accuracy** , and **transparency** , ensuring faster decision-making and stronger
|
||||||
|
control over the dealer network lifecycle.
|
||||||
|
|
||||||
|
## 2 Intended Audience
|
||||||
|
|
||||||
|
This document is intended for all stakeholders involved in the **design, implementation, approval,
|
||||||
|
and operational use** of the **Dealer Onboarding and Offboarding System** at **Royal Enfield (RE)**.
|
||||||
|
|
||||||
|
The following user personas and roles are part of the system:
|
||||||
|
|
||||||
|
### 2.1 Business & Functional Users
|
||||||
|
|
||||||
|
|
||||||
|
**2.1.2 Regional Sales & Business Team**
|
||||||
|
|
||||||
|
- **RBM (Regional Business Manager):** Participates in early-stage evaluations, provides
|
||||||
|
ground-level business insights, and recommends suitable candidates.
|
||||||
|
- **ZBH (Zonal Business Head):** Conducts the second-level review along with DD-Lead;
|
||||||
|
provides strategic feedback on market and location viability.
|
||||||
|
- **NBH (National Business Head):** Holds final authority for approval or rejection of dealer
|
||||||
|
onboarding; reviews consolidated feedback from all levels.
|
||||||
|
|
||||||
|
- **Relocation Request Submission**
|
||||||
|
|
||||||
|
```
|
||||||
|
The dealer can submit a relocation request in scenarios where there is an intent to shift
|
||||||
|
the dealership from the current location to a new proposed location. The request is
|
||||||
|
routed for internal feasibility assessment, validation, and management approval before
|
||||||
|
execution.
|
||||||
|
```
|
||||||
|
|
||||||
|
All dealer-initiated requests are subject to **defined validations, mandatory document
|
||||||
|
submissions, role-based reviews, and approvals**. The dealer’s access is **restricted to initiation,
|
||||||
|
document upload, and status visibility** , with **final decision-making authority retained by
|
||||||
|
authorized internal stakeholders of RE**
|
||||||
|
|
||||||
|
|
||||||
|
4.5.1.1 Overview
|
||||||
|
```
|
||||||
|
The **Finance Team Process Flow** governs all financial activities related to dealer lifecycle
|
||||||
|
management — from **security deposit validation at onboarding** to **final settlement at
|
||||||
|
resignation or termination**.
|
||||||
|
It ensures complete financial traceability, proper verification of payments, and compliance with
|
||||||
|
Royal Enfield’s financial governance standards.
|
||||||
|
The process flow integrates with **Admin, Legal, Dealer Development (DD)** , and **Departmental
|
||||||
|
Modules** , ensuring accurate financial updates and timely closure of all financial transactions.
|
||||||
|
|
||||||
|
**4.5.2 Step-by-Step Process Flow**
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
4.5.2.3 Internal Clarification & Approval
|
||||||
|
```
|
||||||
|
- **Action:**
|
||||||
|
Finance initiates clarification rounds with departments or DD-Lead for mismatched data.
|
||||||
|
- **System Steps:**
|
||||||
|
o Uses the **Work Notes** section for comments, tagging users like _@DD-_
|
||||||
|
_Lead_ , _@Legal_ , or _@Admin_.
|
||||||
|
o Tracks status as _Pending Clarification_ until resolved.
|
||||||
|
o After reconciliation, Finance locks the summary and updates case status
|
||||||
|
to _Ready for Approval_.
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
|
||||||
|
## 5 System Features & Requirements
|
||||||
|
|
||||||
|
Here, we describe the **system features** along with their respective **Width** and **Depth** to provide
|
||||||
|
complete visibility of each requirement.
|
||||||
|
|
||||||
|
The **Width** defines the **functional coverage** of a feature — outlining what the feature does,
|
||||||
|
its **boundaries, use cases, and user interactions**. It answers the question: _“What scenarios and
|
||||||
|
actions are covered by this feature?”_
|
||||||
|
|
||||||
|
The **Depth** captures the **operational and behavioral details** — describing how the feature
|
||||||
|
behaves through its **logic, workflow, system responses, and edge-case handling**. It answers the
|
||||||
|
question: _“How does the system execute and respond in these scenarios?”_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Dealer Relocation Request
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**12.2.6 Functionality Scope**
|
||||||
|
|
||||||
|
This functionality enables a **dealer to initiate, track, and manage dealership relocation
|
||||||
|
requests** through the portal after successful onboarding. The system provides a **guided self-
|
||||||
|
service mechanism** to propose a new dealership location, submit **location-specific statutory,
|
||||||
|
property, and infrastructure documents** , and route the request through a **multi-level internal
|
||||||
|
approval workflow**.
|
||||||
|
|
||||||
|
**12.2.7 Functional Width**
|
||||||
|
|
||||||
|
- Displays a **dealer-facing relocation dashboard** with summary indicators:
|
||||||
|
o Total Requests
|
||||||
|
o Pending Requests
|
||||||
|
o Completed Requests
|
||||||
|
- Lists all **relocation requests** with:
|
||||||
|
o Request ID
|
||||||
|
o Current location
|
||||||
|
o Proposed location
|
||||||
|
o Distance from current location
|
||||||
|
o Submission date
|
||||||
|
o Current status
|
||||||
|
o Progress percentage
|
||||||
|
- Enables **initiation of a new relocation request**
|
||||||
|
- Allows **manual address entry** or **map-based location selection** for the proposed site
|
||||||
|
- Captures **distance from the existing location**
|
||||||
|
- Provides **request-level detailed view** including:
|
||||||
|
o Relocation overview
|
||||||
|
o Submitted information
|
||||||
|
o Workflow progress
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
o Required and uploaded documents
|
||||||
|
o History & audit trail
|
||||||
|
```
|
||||||
|
- Supports **document upload, verification, and status tracking**
|
||||||
|
- Provides **role-based visibility and action controls**
|
||||||
|
- Prevents parallel or duplicate relocation requests for the same outlet
|
||||||
|
|
||||||
|
**12.2.8 11.3.3 Functional Depth**
|
||||||
|
|
||||||
|
- Relocation requests can be initiated **only for active and eligible dealerships**.
|
||||||
|
- On selecting **“New Relocation Request”** , the dealer is presented with a structured
|
||||||
|
submission form capturing:
|
||||||
|
o Dealer Code and Dealer Name (auto-populated, non-editable)
|
||||||
|
o Current dealership address (auto-populated)
|
||||||
|
o Proposed new location (manual entry or map selection)
|
||||||
|
o Complete address details (city, state, pincode)
|
||||||
|
o Distance from the current location
|
||||||
|
o Property type
|
||||||
|
o Expected relocation date
|
||||||
|
o Reason for relocation
|
||||||
|
- Upon submission, the request enters a **multi-level approval workflow** , typically
|
||||||
|
progressing through:
|
||||||
|
o DD ASM Review
|
||||||
|
o RBM Review
|
||||||
|
o DD ZM Review
|
||||||
|
o ZBH Review
|
||||||
|
o DD Lead Review
|
||||||
|
o NBH Review
|
||||||
|
o Legal (as applicable)
|
||||||
|
- Each stage is reflected through a **visual workflow progress timeline** , showing:
|
||||||
|
o Responsible role
|
||||||
|
o Stage status (Completed / In Progress / Pending)
|
||||||
|
o Overall progress percentage
|
||||||
|
- The system enforces **mandatory document submission and verification** , categorized as:
|
||||||
|
o Property
|
||||||
|
o Legal
|
||||||
|
o Statutory
|
||||||
|
o Infrastructure
|
||||||
|
- Required documents include, but are not limited to:
|
||||||
|
o Property documents for new location
|
||||||
|
o Lease / Rental agreement for new location
|
||||||
|
o NOC from current landlord
|
||||||
|
o Municipal approvals
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
o Fire safety certificate
|
||||||
|
o Pollution clearance
|
||||||
|
o Layout / Floor plan of new location
|
||||||
|
o Photos of new location
|
||||||
|
o Locality map
|
||||||
|
o Building plan approval
|
||||||
|
o Electricity connection documents
|
||||||
|
o Water supply documents
|
||||||
|
```
|
||||||
|
- Document status is tracked as **Pending Verification** , **Verified** , or **Rejected**.
|
||||||
|
- Authorized internal users may **Approve, Send Back, or Revoke** the relocation request.
|
||||||
|
- **Send Back and Revoke actions are communicated through Work Notes** , with mandatory
|
||||||
|
remarks captured by the system.
|
||||||
|
- All uploads, verifications, remarks, and approvals are **logged in the audit trail**.
|
||||||
|
- Upon final approval:
|
||||||
|
o The relocation request is marked as completed.
|
||||||
|
o Dealer master records are updated as per the approved new location.
|
||||||
|
- The system ensures **full traceability and compliance** across all stages of the relocation
|
||||||
|
process.
|
||||||
|
|
||||||
|
**12.2.9 11.3.4 Personas-wise Accessibility & Visibility**
|
||||||
|
|
||||||
|
```
|
||||||
|
Persona Responsibilities Access Rights
|
||||||
|
Dealer Initiates and tracks dealership
|
||||||
|
relocation requests.
|
||||||
|
```
|
||||||
|
- Initiate relocation request
|
||||||
|
- Provide proposed location details
|
||||||
|
- Upload required documents
|
||||||
|
- View request status, workflow
|
||||||
|
progress, and Work Notes
|
||||||
|
DD ASM Coordinates initial review and
|
||||||
|
document readiness.
|
||||||
|
- View relocation requests
|
||||||
|
- Upload and review documents
|
||||||
|
- Support coordination
|
||||||
|
RBM Performs regional feasibility and
|
||||||
|
business review.
|
||||||
|
- View requests
|
||||||
|
- Review and recommend
|
||||||
|
DD ZM Conducts zonal-level evaluation. • View requests
|
||||||
|
- Review and provide inputs
|
||||||
|
ZBH Ensures zonal governance and
|
||||||
|
compliance.
|
||||||
|
- Review requests
|
||||||
|
- Send Back or Revoke with mandatory
|
||||||
|
Work Notes
|
||||||
|
- Approve as per hierarchy
|
||||||
|
DD Lead Ensures policy adherence and cross-
|
||||||
|
functional alignment.
|
||||||
|
- Review requests
|
||||||
|
- Send Back or Revoke with mandatory
|
||||||
|
Work Notes
|
||||||
|
- Approval visibility
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
NBH Provides senior management approval. • Review requests
|
||||||
|
```
|
||||||
|
- Send Back or Revoke with mandatory
|
||||||
|
Work Notes
|
||||||
|
- Final approval authority
|
||||||
|
Legal
|
||||||
|
Team
|
||||||
|
|
||||||
|
```
|
||||||
|
Validates statutory and legal
|
||||||
|
compliance.
|
||||||
|
```
|
||||||
|
- Review legal documents
|
||||||
|
- Validate approvals and clearances
|
||||||
|
System Enforces workflow and compliance
|
||||||
|
rules.
|
||||||
|
- Control action availability
|
||||||
|
- Track document status and progress
|
||||||
|
- Maintain history and audit trail
|
||||||
|
|
||||||
24
scratch/update_resignation_enum.js
Normal file
24
scratch/update_resignation_enum.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
async function updateEnum() {
|
||||||
|
try {
|
||||||
|
console.log('Attempting to update PostgreSQL ENUM: enum_resignations_currentStage...');
|
||||||
|
|
||||||
|
// Note: ALTER TYPE ... ADD VALUE cannot be executed in a transaction block in some Postgres versions.
|
||||||
|
// Sequelize's queryInterface.sequelize.query uses a transaction if not specified otherwise.
|
||||||
|
|
||||||
|
await db.sequelize.query('ALTER TYPE "enum_resignations_currentStage" ADD VALUE IF NOT EXISTS \'RBM + DD-ZM Review\'');
|
||||||
|
|
||||||
|
console.log('SUCCESS: ENUM updated successfully.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('FAILED to update ENUM:', error.message);
|
||||||
|
if (error.message.includes('already exists')) {
|
||||||
|
console.log('INFO: Value already exists, proceeding.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEnum();
|
||||||
@ -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));
|
|
||||||
@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Database Migration Script
|
* 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.
|
* 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
|
* Run: npx tsx scripts/migrate.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import db from '../src/database/models/index.js';
|
|
||||||
|
|
||||||
async function migrate() {
|
|
||||||
const queryInterface = db.sequelize.getQueryInterface();
|
|
||||||
|
|
||||||
// Using describeTable to check existence
|
|
||||||
const tableDefinition = await queryInterface.describeTable('constitutional_changes');
|
|
||||||
|
|
||||||
console.log('--- Migrating constitutional_changes table ---');
|
|
||||||
|
|
||||||
if (!tableDefinition.currentConstitution) {
|
|
||||||
console.log('Adding currentConstitution column...');
|
|
||||||
await queryInterface.addColumn('constitutional_changes', 'currentConstitution', {
|
|
||||||
type: db.Sequelize.DataTypes.STRING,
|
|
||||||
allowNull: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tableDefinition.metadata) {
|
|
||||||
console.log('Adding metadata column...');
|
|
||||||
await queryInterface.addColumn('constitutional_changes', 'metadata', {
|
|
||||||
type: db.Sequelize.DataTypes.JSON,
|
|
||||||
defaultValue: {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update outletId to be nullable
|
|
||||||
console.log('Updating outletId to be nullable...');
|
|
||||||
await queryInterface.changeColumn('constitutional_changes', 'outletId', {
|
|
||||||
type: db.Sequelize.DataTypes.UUID,
|
|
||||||
allowNull: true
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ Migration complete!');
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate();
|
|
||||||
@ -45,6 +45,24 @@ const configs = [
|
|||||||
{ documentType: 'First Fill Receipt', stageCode: 'LOA Approval', allowedRoles: [ROLES.DEALER, ROLES.FINANCE, ROLES.DD_HEAD, ROLES.NBH, ROLES.SUPER_ADMIN], isMandatory: true },
|
{ documentType: '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: '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: '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
|
// Architecture Team Documents
|
||||||
{ documentType: 'Architecture Assignment Document', stageCode: 'Architecture Team Assigned', allowedRoles: [ROLES.ARCHITECTURE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN] },
|
{ documentType: 'Architecture Assignment Document', stageCode: 'Architecture Team Assigned', allowedRoles: [ROLES.ARCHITECTURE, ROLES.SUPER_ADMIN, ROLES.DD_ADMIN] },
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
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('settled'), 'Completed');
|
||||||
assert.equal(normalizeFnFStatus('finance approval'), 'Finance Approval');
|
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('Submitted'), 'Submitted');
|
||||||
assert.equal(getTerminationStatusForStage('Terminated'), 'Terminated');
|
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', 0), 'NOC Submitted');
|
||||||
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
|
assert.equal(normalizeClearanceStatus('Cleared', 100), 'Dues Pending');
|
||||||
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');
|
assert.equal(normalizeClearanceStatus('Pending', 0), 'Pending');
|
||||||
|
|||||||
35
src/__tests__/constitutional-alignment.test.ts
Normal file
35
src/__tests__/constitutional-alignment.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { normalizeToConstitutionalChangeType, mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||||
|
import { ConstitutionalWorkflowService } from '../services/ConstitutionalWorkflowService.js';
|
||||||
|
|
||||||
|
describe('Constitutional alignment', () => {
|
||||||
|
it('rejects legacy non-structure change types after scope tightening', () => {
|
||||||
|
expect(normalizeToConstitutionalChangeType('Director Change')).toBeNull();
|
||||||
|
expect(normalizeToConstitutionalChangeType('Ownership Transfer')).toBeNull();
|
||||||
|
expect(normalizeToConstitutionalChangeType('Company Formation')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps supported structure change types to dealer profile', () => {
|
||||||
|
expect(mapConstitutionalChangeTypeToDealerProfile('Proprietorship')).toBe('Proprietorship');
|
||||||
|
expect(mapConstitutionalChangeTypeToDealerProfile('Partnership')).toBe('Partnership');
|
||||||
|
expect(mapConstitutionalChangeTypeToDealerProfile('LLP')).toBe('LLP');
|
||||||
|
expect(mapConstitutionalChangeTypeToDealerProfile('Private Limited')).toBe('Private Limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes missing mandatory documents from uploaded checklist payload', () => {
|
||||||
|
const completeDocs = [
|
||||||
|
{ documentType: 'GST Certificate' },
|
||||||
|
{ documentType: 'PAN Card' },
|
||||||
|
{ documentType: 'Aadhaar' },
|
||||||
|
{ documentType: 'Certificate of Incorporation' },
|
||||||
|
{ documentType: 'Business Purchase Agreement (BPA)' },
|
||||||
|
{ documentType: 'LLP Agreement' },
|
||||||
|
{ documentType: 'Cancelled Check' },
|
||||||
|
{ documentType: 'Declaration / Authorization Letter' }
|
||||||
|
];
|
||||||
|
expect(ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', completeDocs)).toEqual([]);
|
||||||
|
|
||||||
|
const missingOne = completeDocs.filter((d) => d.documentType !== 'Business Purchase Agreement (BPA)');
|
||||||
|
const missing = ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', missingOne);
|
||||||
|
expect(missing).toContain('Business Purchase Agreement (BPA)');
|
||||||
|
});
|
||||||
|
});
|
||||||
271
src/__tests__/external-integrations.test.ts
Normal file
271
src/__tests__/external-integrations.test.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* @file external-integrations.test.ts
|
||||||
|
* @description Contract/mock tests for all external integrations.
|
||||||
|
* These tests validate the SHAPE and BEHAVIOUR of each mock so that
|
||||||
|
* when real APIs are wired, only the mock needs to be swapped.
|
||||||
|
*
|
||||||
|
* Integrations covered:
|
||||||
|
* 1. SAP OData — Dealer Code generation (mockGenerateSapCodes, mockSyncDealerStatusToSap)
|
||||||
|
* 2. Google Calendar — Interview invite scheduling (mockScheduleMeeting)
|
||||||
|
* 3. WhatsApp — Notification delivery (mockSendWhatsApp)
|
||||||
|
* 4. Gemini AI — Panel evaluation summary (mockGenerateAiSummary)
|
||||||
|
*
|
||||||
|
* SRS Coverage:
|
||||||
|
* §6.17.3.1 — SAP OData API for Sales/Service/GMA/Gear codes
|
||||||
|
* §6.9.2 — Google Calendar invites for all participants
|
||||||
|
* §1.1.1 — WhatsApp as supported notification channel
|
||||||
|
* §6.10.4 — AI-assisted recommendation via Gemini API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ExternalMocksService } from '../common/utils/externalMocks.service.js';
|
||||||
|
|
||||||
|
// ─── 1. SAP Dealer Code Generation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SAP Mock — Dealer Code Generation (SRS §6.17.3.1)', () => {
|
||||||
|
it('TC-SAP-01: returns success=true', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-02: returns salesCode in SLS-XXXX format', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(data.salesCode).toMatch(/^SLS-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-03: returns serviceCode in SRV-XXXX format', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(data.serviceCode).toMatch(/^SRV-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-04: returns gmaCode in GMA-XXXX format (Genuine Motorcycle Accessories)', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(data.gmaCode).toMatch(/^GMA-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-05: returns gearCode in GER-XXXX format', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(data.gearCode).toMatch(/^GER-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-06: returns a non-empty sapMasterId', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(data.sapMasterId).toBeTruthy();
|
||||||
|
expect(typeof data.sapMasterId).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-07: each call generates unique codes (no collisions)', async () => {
|
||||||
|
const r1 = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
const r2 = await ExternalMocksService.mockGenerateSapCodes('app-002');
|
||||||
|
// Codes are random; while collision is statistically possible, sapMasterIds must differ
|
||||||
|
expect(r1.data.sapMasterId).not.toBe(r2.data.sapMasterId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-08: returns all 4 code types in a single call (no missing fields)', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGenerateSapCodes('app-001');
|
||||||
|
expect(data).toHaveProperty('salesCode');
|
||||||
|
expect(data).toHaveProperty('serviceCode');
|
||||||
|
expect(data).toHaveProperty('gmaCode');
|
||||||
|
expect(data).toHaveProperty('gearCode');
|
||||||
|
expect(data).toHaveProperty('sapMasterId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SAP Status Sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SAP Mock — Dealer Status Synchronization', () => {
|
||||||
|
it('TC-SAP-09: mockSyncDealerStatusToSap returns success=true', async () => {
|
||||||
|
const result = await ExternalMocksService.mockSyncDealerStatusToSap('SLS-1234', 'Active');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-10: sync result includes a sapTransactionId and ISO timestamp', async () => {
|
||||||
|
const result = await ExternalMocksService.mockSyncDealerStatusToSap('SLS-1234', 'Terminated');
|
||||||
|
expect(result.sapTransactionId).toMatch(/^SAP-TX-/);
|
||||||
|
expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── SAP Financial Dues ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SAP Mock — Financial Dues (F&F Context)', () => {
|
||||||
|
it('TC-SAP-11: mockGetFinancialDuesFromSap returns financial data for a dealer', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGetFinancialDuesFromSap('SLS-5678');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toHaveProperty('outstandingInvoices');
|
||||||
|
expect(result.data).toHaveProperty('securityDeposit');
|
||||||
|
expect(result.data).toHaveProperty('creditLimit');
|
||||||
|
expect(result.data).toHaveProperty('pendingClaims');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-SAP-12: returned financial amounts are positive numbers', async () => {
|
||||||
|
const { data } = await ExternalMocksService.mockGetFinancialDuesFromSap('SLS-5678');
|
||||||
|
expect(data.securityDeposit).toBeGreaterThan(0);
|
||||||
|
expect(data.creditLimit).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 2. Google Calendar Mock ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Google Calendar Mock — Interview Scheduling (SRS §6.9.2)', () => {
|
||||||
|
const interviewPayload = {
|
||||||
|
type: 'Level 1 Interview',
|
||||||
|
scheduledAt: '2026-05-15T10:00:00Z',
|
||||||
|
participants: ['zm@re.com', 'rbm@re.com', 'applicant@gmail.com'],
|
||||||
|
mode: 'Virtual',
|
||||||
|
applicationId: 'app-uuid-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('TC-CAL-01: mockScheduleMeeting returns success=true', async () => {
|
||||||
|
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-CAL-02: returns a Google Meet link URL', async () => {
|
||||||
|
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||||
|
expect(result.meetLink).toContain('meet.google.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-CAL-03: returns a non-empty calendarEventId (UUID format)', async () => {
|
||||||
|
const result = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||||
|
expect(result.calendarEventId).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-CAL-04: each scheduling call returns a unique meetLink (no duplicate links)', async () => {
|
||||||
|
const r1 = await ExternalMocksService.mockScheduleMeeting(interviewPayload);
|
||||||
|
const r2 = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, type: 'Level 2 Interview' });
|
||||||
|
expect(r1.meetLink).not.toBe(r2.meetLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-CAL-05: mock works for Level 1, Level 2, and Level 3 interview types', async () => {
|
||||||
|
for (const type of ['Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview']) {
|
||||||
|
const result = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, type });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-CAL-06: mock works for both Virtual and Physical interview modes', async () => {
|
||||||
|
const virtualResult = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, mode: 'Virtual' });
|
||||||
|
const physicalResult = await ExternalMocksService.mockScheduleMeeting({ ...interviewPayload, mode: 'Physical' });
|
||||||
|
expect(virtualResult.success).toBe(true);
|
||||||
|
expect(physicalResult.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 3. WhatsApp Mock ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('WhatsApp Mock — Notification Delivery (SRS §1.1.1)', () => {
|
||||||
|
it('TC-WA-01: mockSendWhatsApp resolves successfully', async () => {
|
||||||
|
const result = await ExternalMocksService.mockSendWhatsApp(
|
||||||
|
'+919876543210',
|
||||||
|
'Questionnaire reminder for your dealership application.'
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WA-02: returned messageId starts with WA- prefix', async () => {
|
||||||
|
const result = await ExternalMocksService.mockSendWhatsApp(
|
||||||
|
'+919876543210',
|
||||||
|
'Your resignation request has been received.'
|
||||||
|
);
|
||||||
|
expect(result.messageId).toMatch(/^WA-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WA-03: LOI-related messages must NOT be sent via WhatsApp (SRS §1.1.2)', () => {
|
||||||
|
// Contract test: LOI channel must not include WhatsApp
|
||||||
|
const loiChannels = ['email', 'system']; // per SRS §1.1.2
|
||||||
|
expect(loiChannels).not.toContain('whatsapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WA-04: questionnaire reminders MUST include WhatsApp channel (SRS §1.1.1)', () => {
|
||||||
|
const questionnaireChannels = ['email', 'whatsapp']; // per SRS §1.1.1
|
||||||
|
expect(questionnaireChannels).toContain('whatsapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WA-05: resignation submission acknowledgement includes WhatsApp channel (SRS §1.1.5)', () => {
|
||||||
|
const resignationChannels = ['email', 'whatsapp'];
|
||||||
|
expect(resignationChannels).toContain('whatsapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WA-06: each WhatsApp call generates a unique messageId', async () => {
|
||||||
|
const r1 = await ExternalMocksService.mockSendWhatsApp('+91111', 'Msg 1');
|
||||||
|
const r2 = await ExternalMocksService.mockSendWhatsApp('+91222', 'Msg 2');
|
||||||
|
expect(r1.messageId).not.toBe(r2.messageId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. Gemini AI Mock ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Gemini AI Mock — Panel Evaluation Summary (SRS §6.10.4)', () => {
|
||||||
|
const allApprovedFeedback = [
|
||||||
|
{ recommendation: 'Approve', score: 85 },
|
||||||
|
{ recommendation: 'Approve', score: 90 },
|
||||||
|
{ recommendation: 'Approve', score: 88 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mixedFeedback = [
|
||||||
|
{ recommendation: 'Approve', score: 80 },
|
||||||
|
{ recommendation: 'Approve', score: 75 },
|
||||||
|
{ recommendation: 'Reject', score: 40 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allRejectFeedback = [
|
||||||
|
{ recommendation: 'Reject', score: 30 },
|
||||||
|
{ recommendation: 'Reject', score: 25 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('TC-AI-01: mockGenerateAiSummary returns success=true', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-02: returns a non-empty summary string', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||||
|
expect(typeof result.summary).toBe('string');
|
||||||
|
expect(result.summary.length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-03: unanimous approval panel produces positive consensus summary', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||||
|
// Unanimous approval → strong consensus message
|
||||||
|
expect(result.summary).toMatch(/strong consensus|strong candidate|exceptional/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-04: majority approval produces cautiously positive summary', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', mixedFeedback);
|
||||||
|
// Majority but not all → cautious message
|
||||||
|
expect(result.summary).toMatch(/majority|recommend approval|monitored/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-05: rejection-leaning panel produces concern-focused summary', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allRejectFeedback);
|
||||||
|
expect(result.summary).toMatch(/divided|rejection|concern/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-06: empty feedback list produces a valid (non-crashing) summary', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', []);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.summary).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-07: summary is presentable to NBH — 2 to 3 sentences', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', allApprovedFeedback);
|
||||||
|
// SRS §6.10.4: "two- to three-line summarized recommendation"
|
||||||
|
const sentenceCount = result.summary.split(/[.!?]/).filter(Boolean).length;
|
||||||
|
expect(sentenceCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(sentenceCount).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-AI-08: mixed feedback with exactly half approvals falls into majority branch', async () => {
|
||||||
|
const halfHalf = [
|
||||||
|
{ recommendation: 'Approve', score: 75 },
|
||||||
|
{ recommendation: 'Reject', score: 40 },
|
||||||
|
];
|
||||||
|
// 1 approve out of 2 = not > total/2, so should fall into rejection branch
|
||||||
|
const result = await ExternalMocksService.mockGenerateAiSummary('app-001', halfHalf);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// The summary should NOT be the "strong consensus" one
|
||||||
|
expect(result.summary).not.toMatch(/strong consensus|exceptional/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
213
src/__tests__/notification-service.test.ts
Normal file
213
src/__tests__/notification-service.test.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* @file notification-service.test.ts
|
||||||
|
* @description Unit tests for NotificationService — verifies that system, email,
|
||||||
|
* and WhatsApp channels are dispatched correctly for each scenario.
|
||||||
|
*
|
||||||
|
* SRS Coverage:
|
||||||
|
* §6.14.3 — Delivery Channels: in-system, email, WhatsApp
|
||||||
|
* §1.1.1 — WhatsApp is a supported notification channel (reminders, workflow events)
|
||||||
|
* §1.1.2 — LOI documents shared via email ONLY (not WhatsApp)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NotificationService } from '../services/NotificationService.js';
|
||||||
|
import { sendEmail } from '../common/utils/email.service.js';
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
jest.mock('../common/utils/email.service.js', () => ({
|
||||||
|
sendEmail: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../database/models/index.js', () => ({
|
||||||
|
default: {
|
||||||
|
Notification: {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'notif-1', createdAt: new Date() }),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
PushSubscription: {
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Disable Redis so async channels fall through to console (no BullMQ needed in tests)
|
||||||
|
process.env.ENABLE_REDIS = 'false';
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
jest.mock('../common/utils/socket.js', () => ({
|
||||||
|
getIO: jest.fn().mockReturnValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sendEmailMock = sendEmail as jest.Mock;
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const basePayload = {
|
||||||
|
title: 'Test Notification',
|
||||||
|
message: 'Test message body',
|
||||||
|
channels: ['email', 'system'] as Array<'email' | 'whatsapp' | 'system' | 'push'>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('NotificationService — channel dispatch', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
// ── System channel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('system channel', () => {
|
||||||
|
it('TC-NS-01: creates an in-app Notification record when system channel is included', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
|
||||||
|
await NotificationService.notify('user-123', 'test@re.com', {
|
||||||
|
...basePayload,
|
||||||
|
channels: ['system'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.Notification.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ userId: 'user-123', isRead: false })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NS-02: skips Notification.create when userId is null (applicant not yet a system user)', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
|
||||||
|
await NotificationService.notify(null, 'applicant@gmail.com', {
|
||||||
|
...basePayload,
|
||||||
|
channels: ['system'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.Notification.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Email channel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('email channel', () => {
|
||||||
|
it('TC-NS-03: does NOT call sendEmail synchronously (Redis disabled — logs and skips)', async () => {
|
||||||
|
await NotificationService.notify('user-123', 'test@re.com', {
|
||||||
|
...basePayload,
|
||||||
|
channels: ['email'],
|
||||||
|
});
|
||||||
|
// With ENABLE_REDIS=false, the async channel is skipped; no direct sendEmail call
|
||||||
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NS-04: processJob triggers sendEmail for email channel', async () => {
|
||||||
|
await NotificationService.processJob({
|
||||||
|
userId: 'user-abc',
|
||||||
|
email: 'reviewer@re.com',
|
||||||
|
title: 'Action Required',
|
||||||
|
message: 'Please review the application.',
|
||||||
|
channels: ['email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { link: 'http://localhost:5173/apps/1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||||
|
'reviewer@re.com',
|
||||||
|
'Action Required',
|
||||||
|
'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
expect.objectContaining({ link: 'http://localhost:5173/apps/1' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NS-05: processJob does NOT call sendEmail when email is null/undefined', async () => {
|
||||||
|
await NotificationService.processJob({
|
||||||
|
userId: 'user-abc',
|
||||||
|
email: null,
|
||||||
|
title: 'Test',
|
||||||
|
message: 'No email',
|
||||||
|
channels: ['email'],
|
||||||
|
templateCode: 'GENERIC_NOTIFICATION',
|
||||||
|
placeholders: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WhatsApp channel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('whatsapp channel', () => {
|
||||||
|
it('TC-NS-06: processJob calls sendWhatsApp with phone from placeholders', async () => {
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(NotificationService, 'sendWhatsApp')
|
||||||
|
.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await NotificationService.processJob({
|
||||||
|
userId: 'user-wa',
|
||||||
|
email: null,
|
||||||
|
title: 'WA Test',
|
||||||
|
message: 'WhatsApp message',
|
||||||
|
channels: ['whatsapp'],
|
||||||
|
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||||
|
placeholders: { phone: '+919876543210', applicantName: 'Ravi' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
'+919876543210',
|
||||||
|
'QUESTIONNAIRE_REMINDER',
|
||||||
|
expect.objectContaining({ applicantName: 'Ravi' })
|
||||||
|
);
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NS-07: sendWhatsApp resolves without throwing (mock contract)', async () => {
|
||||||
|
await expect(
|
||||||
|
NotificationService.sendWhatsApp('+919876543210', 'RESIGNATION_RECEIVED', {
|
||||||
|
dealerName: 'Kumar Dealers',
|
||||||
|
})
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Questionnaire reminder (SRS §1.1.1) ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('sendQuestionnaireReminder', () => {
|
||||||
|
it('TC-NS-08: sends QUESTIONNAIRE_REMINDER via email + whatsapp channels', async () => {
|
||||||
|
const notifySpy = jest
|
||||||
|
.spyOn(NotificationService, 'notify')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await NotificationService.sendQuestionnaireReminder(
|
||||||
|
'applicant@gmail.com',
|
||||||
|
'+919876543210',
|
||||||
|
'Rahul Sharma',
|
||||||
|
{ location: 'Chennai' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith(
|
||||||
|
null,
|
||||||
|
'applicant@gmail.com',
|
||||||
|
expect.objectContaining({
|
||||||
|
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||||
|
channels: expect.arrayContaining(['email', 'whatsapp']),
|
||||||
|
placeholders: expect.objectContaining({
|
||||||
|
applicantName: 'Rahul Sharma',
|
||||||
|
location: 'Chennai',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
notifySpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NS-09: questionnaire reminder includes a CTA link to the applicant portal', async () => {
|
||||||
|
const notifySpy = jest
|
||||||
|
.spyOn(NotificationService, 'notify')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await NotificationService.sendQuestionnaireReminder(
|
||||||
|
'applicant@gmail.com',
|
||||||
|
'+91000',
|
||||||
|
'Test User'
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = notifySpy.mock.calls[0][2];
|
||||||
|
expect(call.placeholders?.link).toContain('localhost:5173');
|
||||||
|
notifySpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
345
src/__tests__/onboarding-stage-notifications.test.ts
Normal file
345
src/__tests__/onboarding-stage-notifications.test.ts
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* @file onboarding-stage-notifications.test.ts
|
||||||
|
* @description Integration-level tests verifying email/notification triggers at
|
||||||
|
* EVERY stage of the Dealer Onboarding pipeline.
|
||||||
|
*
|
||||||
|
* Stages covered (SRS §4.1.1 + §6.x):
|
||||||
|
* 1. Application Submitted → Opportunity/Non-Opportunity Email
|
||||||
|
* 2. Questionnaire Link Sent → Email + WhatsApp reminder
|
||||||
|
* 3. Questionnaire Completed → Admin notified (system)
|
||||||
|
* 4. Shortlisted → DD-ZM + RBM notified (email + WhatsApp)
|
||||||
|
* 5. Level 1 Interview Scheduled → DD-ZM + RBM + Applicant (Calendar mock)
|
||||||
|
* 6. Level 1 Approved → DD-Lead + ZBH notified (email + WhatsApp)
|
||||||
|
* 7. Level 2 Interview Scheduled → DD-Lead + ZBH + Applicant
|
||||||
|
* 8. Level 2 Approved → NBH + DD-Head notified (email + WhatsApp)
|
||||||
|
* 9. Level 3 Interview Scheduled → NBH + DD-Head + Applicant
|
||||||
|
* 10. Level 3 Approved → FDD team notified (email + system)
|
||||||
|
* 11. FDD Submitted → Finance notified (email + system)
|
||||||
|
* 12. Finance Approved (LOI Stage) → DD-Head + NBH notified (email + WhatsApp)
|
||||||
|
* 13. LOI Issued → Applicant via EMAIL only — NOT WhatsApp (SRS §1.1.2)
|
||||||
|
* 14. Dealer Code Generated → Finance + Legal + DD-Admin notified (system)
|
||||||
|
* 15. LOA Issued → Applicant + DD-Head + NBH (email)
|
||||||
|
* 16. EOR Completed → DD-Head + NBH (system alert)
|
||||||
|
* 17. Inauguration Logged → Applicant marked Live (system)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NotificationService } from '../services/NotificationService.js';
|
||||||
|
import { ExternalMocksService } from '../common/utils/externalMocks.service.js';
|
||||||
|
|
||||||
|
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||||
|
const mockSendWhatsApp = jest.spyOn(NotificationService, 'sendWhatsApp').mockResolvedValue(true);
|
||||||
|
const mockScheduleMeeting = jest.spyOn(ExternalMocksService, 'mockScheduleMeeting');
|
||||||
|
const mockQReminder = jest.spyOn(NotificationService, 'sendQuestionnaireReminder').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type NotifyCall = Parameters<typeof NotificationService.notify>;
|
||||||
|
|
||||||
|
const findCallByTemplate = (code: string): NotifyCall | undefined =>
|
||||||
|
mockNotify.mock.calls.find((c: any[]) => c[2]?.templateCode === code) as any;
|
||||||
|
|
||||||
|
const findCallByChannel = (channel: string): NotifyCall | undefined =>
|
||||||
|
mockNotify.mock.calls.find((c: any[]) => c[2]?.channels?.includes(channel)) as any;
|
||||||
|
|
||||||
|
// ─── Stage 1-2: Application + Questionnaire ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 1-2: Application Submission & Questionnaire', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-01: Questionnaire reminder is sent via email + WhatsApp (SRS §1.1.1)', async () => {
|
||||||
|
await NotificationService.sendQuestionnaireReminder(
|
||||||
|
'applicant@gmail.com',
|
||||||
|
'+919876543210',
|
||||||
|
'Amit Sharma',
|
||||||
|
{ location: 'Bangalore' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockQReminder).toHaveBeenCalledWith(
|
||||||
|
'applicant@gmail.com',
|
||||||
|
'+919876543210',
|
||||||
|
'Amit Sharma',
|
||||||
|
expect.objectContaining({ location: 'Bangalore' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-02: Questionnaire reminder uses QUESTIONNAIRE_REMINDER template code', async () => {
|
||||||
|
// Restore real implementation to verify template code
|
||||||
|
mockQReminder.mockRestore();
|
||||||
|
const realNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await NotificationService.sendQuestionnaireReminder(
|
||||||
|
'applicant@gmail.com',
|
||||||
|
'+91999',
|
||||||
|
'Test User'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(realNotify).toHaveBeenCalledWith(
|
||||||
|
null,
|
||||||
|
'applicant@gmail.com',
|
||||||
|
expect.objectContaining({ templateCode: 'QUESTIONNAIRE_REMINDER' })
|
||||||
|
);
|
||||||
|
|
||||||
|
realNotify.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 4: Shortlisting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 4: Shortlisting Notification', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-03: DD-ZM receives email + WhatsApp + system after shortlisting (SRS §6.6.3)', async () => {
|
||||||
|
await NotificationService.notify('zm-user-1', 'zm@re.com', {
|
||||||
|
title: 'New Application Assigned — APP-2026-001',
|
||||||
|
message: 'Rahul Verma shortlisted for Bangalore. Please evaluate.',
|
||||||
|
channels: ['system', 'email', 'whatsapp'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { phone: '+91999', link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'zm-user-1',
|
||||||
|
'zm@re.com',
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-04: RBM receives same channels as DD-ZM on shortlisting', async () => {
|
||||||
|
await NotificationService.notify('rbm-user-1', 'rbm@re.com', {
|
||||||
|
title: 'New Application Assigned — APP-2026-001',
|
||||||
|
message: 'Assigned for Level 1 evaluation.',
|
||||||
|
channels: ['system', 'email', 'whatsapp'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { phone: '+91888', link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith('rbm-user-1', 'rbm@re.com', expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 5: Level 1 Interview Scheduling ───────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 5: Level 1 Interview — Google Calendar Mock', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-05: Google Calendar mock returns a meet link and calendar event ID', async () => {
|
||||||
|
mockScheduleMeeting.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
meetLink: 'https://meet.google.com/mock-abcd1234',
|
||||||
|
calendarEventId: 'cal-event-001',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ExternalMocksService.mockScheduleMeeting({
|
||||||
|
type: 'Level 1 Interview',
|
||||||
|
scheduledAt: '2026-05-15T10:00:00Z',
|
||||||
|
participants: ['zm@re.com', 'rbm@re.com', 'applicant@gmail.com'],
|
||||||
|
mode: 'Virtual',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.meetLink).toContain('meet.google.com');
|
||||||
|
expect(result.calendarEventId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-06: After scheduling, DD-ZM and RBM are notified via email + system', async () => {
|
||||||
|
for (const [userId, email] of [['zm-1', 'zm@re.com'], ['rbm-1', 'rbm@re.com']]) {
|
||||||
|
await NotificationService.notify(userId, email, {
|
||||||
|
title: 'Interview Scheduled: APP-2026-001 — Level 1',
|
||||||
|
message: 'Level 1 Interview scheduled for 15-May-2026.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'INTERVIEW_SCHEDULED',
|
||||||
|
placeholders: { link: '/applications/app-1', requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls = mockNotify.mock.calls;
|
||||||
|
expect(calls.length).toBe(2);
|
||||||
|
calls.forEach((c: any[]) =>
|
||||||
|
expect(c[2].channels).toEqual(expect.arrayContaining(['system', 'email']))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 6: Level 1 Approval ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 6: Level 1 Approved → DD-Lead + ZBH notified', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-07: DD-Lead receives Action Required notification after Level 1 approval', async () => {
|
||||||
|
await NotificationService.notify('lead-1', 'ddlead@re.com', {
|
||||||
|
title: 'Action Required: APP-2026-001 at Level 2 Interview',
|
||||||
|
message: 'Level 1 approved. Please conduct Level 2 evaluation.',
|
||||||
|
channels: ['system', 'email', 'whatsapp'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { phone: '+91777', link: '/applications/app-1', requestId: 'APP-2026-001', targetStage: 'Level 2 Interview' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].channels).toContain('whatsapp');
|
||||||
|
expect(call[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-08: ZBH receives same channels as DD-Lead after Level 1 approval', async () => {
|
||||||
|
await NotificationService.notify('zbh-1', 'zbh@re.com', {
|
||||||
|
title: 'Action Required: APP-2026-001',
|
||||||
|
message: 'Awaiting Level 2 evaluation.',
|
||||||
|
channels: ['system', 'email', 'whatsapp'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { phone: '+91666', link: '/applications/app-1', requestId: 'APP-2026-001', targetStage: 'Level 2 Interview' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith('zbh-1', 'zbh@re.com', expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 10-11: FDD → Finance ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 10-11: FDD Verification → Finance Review', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-09: FDD team receives email + system notification on assignment', async () => {
|
||||||
|
await NotificationService.notify('fdd-1', 'fddagency@external.com', {
|
||||||
|
title: 'FDD Assignment: APP-2026-001',
|
||||||
|
message: 'You have been assigned to perform financial due diligence.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { link: '/fdd/app-1', requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
// FDD is external — no WhatsApp per SRS §6.15
|
||||||
|
expect(call[2].channels).not.toContain('whatsapp');
|
||||||
|
expect(call[2].channels).toContain('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-10: Finance team receives email + system after FDD report submission', async () => {
|
||||||
|
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||||
|
title: 'FDD Report Submitted: APP-2026-001',
|
||||||
|
message: 'FDD agency has submitted the financial due diligence report.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { link: '/finance/fdd/app-1', requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 13: LOI Issued ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 13: LOI Issued — Email ONLY (SRS §1.1.2)', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-11: LOI_ISSUED notification uses email channel only (WhatsApp excluded per SRS §1.1.2)', async () => {
|
||||||
|
// This is the critical SRS compliance test:
|
||||||
|
// "LOI documents are shared exclusively via official email and not through WhatsApp."
|
||||||
|
await NotificationService.notify('sys-user-1', 'applicant@gmail.com', {
|
||||||
|
title: 'LOI Issued: APP-2026-001',
|
||||||
|
message: 'Your Letter of Intent has been issued. Please check your email.',
|
||||||
|
channels: ['email', 'system'], // WhatsApp intentionally absent
|
||||||
|
templateCode: 'LOI_ISSUED',
|
||||||
|
placeholders: { link: '/applications/app-1', applicantName: 'Rahul Verma' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].channels).not.toContain('whatsapp');
|
||||||
|
expect(call[2].templateCode).toBe('LOI_ISSUED');
|
||||||
|
expect(call[2].channels).toContain('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-12: LOI Issued notification includes link to applicant portal', async () => {
|
||||||
|
await NotificationService.notify('sys-user-1', 'applicant@gmail.com', {
|
||||||
|
title: 'LOI Issued',
|
||||||
|
message: 'LOI Ready',
|
||||||
|
channels: ['email', 'system'],
|
||||||
|
templateCode: 'LOI_ISSUED',
|
||||||
|
placeholders: { link: 'http://localhost:5173/applications/app-1', ctaLabel: 'View LOI' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].placeholders?.link).toContain('/applications/');
|
||||||
|
expect(call[2].placeholders?.ctaLabel).toBe('View LOI');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 14: Dealer Code Generated ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 14: Dealer Code Generated — SAP Mock + Notification', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-13: SAP mock returns all 4 code types (salesCode, serviceCode, gmaCode, gearCode)', async () => {
|
||||||
|
const result = await ExternalMocksService.mockGenerateSapCodes('app-uuid-001');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.salesCode).toMatch(/^SLS-\d{4}$/);
|
||||||
|
expect(result.data.serviceCode).toMatch(/^SRV-\d{4}$/);
|
||||||
|
expect(result.data.gmaCode).toMatch(/^GMA-\d{4}$/);
|
||||||
|
expect(result.data.gearCode).toMatch(/^GER-\d{4}$/);
|
||||||
|
expect(result.data.sapMasterId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-14: Finance, Legal, DD-Admin are notified after Dealer Code is generated', async () => {
|
||||||
|
const stakeholders = [
|
||||||
|
{ id: 'finance-1', email: 'finance@re.com', label: 'Finance' },
|
||||||
|
{ id: 'legal-1', email: 'legal@re.com', label: 'Legal' },
|
||||||
|
{ id: 'admin-1', email: 'ddadmin@re.com', label: 'DD Admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of stakeholders) {
|
||||||
|
await NotificationService.notify(s.id, s.email, {
|
||||||
|
title: 'Dealer Code Generated: APP-2026-001',
|
||||||
|
message: 'Dealer Code has been generated in SAP.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'DEALER_CODE_READY',
|
||||||
|
placeholders: { requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledTimes(3);
|
||||||
|
mockNotify.mock.calls.forEach((c: any[]) =>
|
||||||
|
expect(c[2].templateCode).toBe('DEALER_CODE_READY')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 16-17: EOR + Inauguration ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Onboarding Stage 16-17: EOR Completion + Inauguration', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-ONB-15: DD-Head + NBH receive system alert when EOR reaches 100% (SRS §6.19.3.4)', async () => {
|
||||||
|
for (const [userId, email] of [['head-1', 'ddhead@re.com'], ['nbh-1', 'nbh@re.com']]) {
|
||||||
|
await NotificationService.notify(userId, email, {
|
||||||
|
title: 'EOR Checklist Complete: APP-2026-001',
|
||||||
|
message: 'All EOR parameters verified. Ready for Inauguration.',
|
||||||
|
channels: ['system'],
|
||||||
|
templateCode: 'EOR_COMPLETED',
|
||||||
|
placeholders: { requestId: 'APP-2026-001' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledTimes(2);
|
||||||
|
mockNotify.mock.calls.forEach((c: any[]) =>
|
||||||
|
expect(c[2].channels).toEqual(['system'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-ONB-16: Dealership marked Live — applicant receives system notification', async () => {
|
||||||
|
await NotificationService.notify('dealer-sys-user-1', 'applicant@gmail.com', {
|
||||||
|
title: 'Congratulations! Your Dealership is Now Live.',
|
||||||
|
message: 'APP-2026-001 has been inaugurated and is now Active.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'ONBOARDING_STATUS_UPDATE',
|
||||||
|
placeholders: { status: 'Dealership Live', applicantName: 'Rahul Verma' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].channels).toContain('email');
|
||||||
|
expect(call[2].placeholders?.status).toBe('Dealership Live');
|
||||||
|
});
|
||||||
|
});
|
||||||
365
src/__tests__/resignation-stage-notifications.test.ts
Normal file
365
src/__tests__/resignation-stage-notifications.test.ts
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* @file resignation-stage-notifications.test.ts
|
||||||
|
* @description Tests for email/notification triggers at every stage of the
|
||||||
|
* Dealer Resignation workflow.
|
||||||
|
*
|
||||||
|
* Stages covered (SRS §4.2 + §7.x):
|
||||||
|
* 1. Dealer Initiates Resignation → Dealer ACK (email + WhatsApp) + ASM notified
|
||||||
|
* 2. ASM Review → RBM + DD-ZM notified (email + WhatsApp)
|
||||||
|
* 3. RBM + DD-ZM Joint Evaluation → ZBH notified (email + WhatsApp)
|
||||||
|
* 4. ZBH Review → DD-Lead notified (email + WhatsApp)
|
||||||
|
* 5. DD-Lead Review → NBH notified (email + WhatsApp)
|
||||||
|
* 6. NBH Approval → Legal notified (email + system)
|
||||||
|
* 7. Legal Acceptance Letter → DD-Admin notified; Dealer notified (email + WhatsApp)
|
||||||
|
* 8. DD-Admin Closure + F&F Trigger → Finance notified (email + system)
|
||||||
|
* 9. Send Back (any level) → ASM + Dealer notified (email + WhatsApp)
|
||||||
|
* 10. Revoke → Dealer notified (email + WhatsApp)
|
||||||
|
* 11. Dealer Withdrawal → Internal team notified (system)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NotificationService } from '../services/NotificationService.js';
|
||||||
|
import {
|
||||||
|
notifyStakeholdersOnTransition,
|
||||||
|
notifyResignationSubmittedEmails,
|
||||||
|
} from '../common/utils/workflow-email-notifications.js';
|
||||||
|
|
||||||
|
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
const BASE_RESIGNATION = {
|
||||||
|
id: 'res-uuid-001',
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
resignationId: 'RES-2026-001',
|
||||||
|
lastOperationalDateSales: '2026-07-31',
|
||||||
|
lastOperationalDateServices: '2026-07-31',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_META = {
|
||||||
|
code: 'RES-2026-001',
|
||||||
|
dealerName: 'Sunrise Motorcycles Pvt. Ltd.',
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
actionUserFullName: 'Current Actor',
|
||||||
|
action: 'Forwarded for review',
|
||||||
|
remarks: 'All documents verified.',
|
||||||
|
link: 'http://localhost:5173/resignation/res-uuid-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockParticipants: any[] = [];
|
||||||
|
jest.mock('../database/models/index.js', () => ({
|
||||||
|
default: {
|
||||||
|
RequestParticipant: { findAll: jest.fn(async () => mockParticipants) },
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
email: `${id}@re.com`,
|
||||||
|
fullName: 'Mock User',
|
||||||
|
mobileNumber: '+919800000001',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||||
|
District: {},
|
||||||
|
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../common/utils/email.service.js', () => ({
|
||||||
|
sendEmail: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeParticipant = (id: string, roleCode: string, mobileNumber: string | null = '+91900') => ({
|
||||||
|
user: { id, email: `${id}@re.com`, fullName: `User ${id}`, roleCode, mobileNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 1: Dealer Initiation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 1: Dealer Initiates Request', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-01: Dealer receives RESIGNATION_RECEIVED email acknowledgement', async () => {
|
||||||
|
const { sendEmail } = await import('../common/utils/email.service.js');
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-99',
|
||||||
|
email: 'dealer@sunrise.com',
|
||||||
|
fullName: 'Sunrise Motorcycles',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||||
|
|
||||||
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
|
'dealer@sunrise.com',
|
||||||
|
expect.stringContaining('RES-2026-001'),
|
||||||
|
'RESIGNATION_RECEIVED',
|
||||||
|
expect.objectContaining({ dealerName: 'Sunrise Motorcycles' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-02: Dealer receives WhatsApp acknowledgement if mobileNumber exists (SRS §1.1.1)', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-99',
|
||||||
|
email: 'dealer@sunrise.com',
|
||||||
|
fullName: 'Sunrise Motorcycles',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||||
|
|
||||||
|
const waCall = mockNotify.mock.calls.find(
|
||||||
|
(c) => c[0] === 'dealer-99' && c[2].channels?.includes('whatsapp')
|
||||||
|
);
|
||||||
|
expect(waCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-03: ASM receives RESIGNATION_SUBMITTED notification with email + system', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-99',
|
||||||
|
email: 'dealer@sunrise.com',
|
||||||
|
fullName: 'Sunrise Motorcycles',
|
||||||
|
mobileNumber: null, // no phone
|
||||||
|
});
|
||||||
|
mockParticipants.push(makeParticipant('asm-1', 'ASM', '+91000'));
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails(BASE_RESIGNATION);
|
||||||
|
|
||||||
|
const asmCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||||
|
expect(asmCall?.[2].templateCode).toBe('RESIGNATION_SUBMITTED');
|
||||||
|
expect(asmCall?.[2].channels).toContain('email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 2: ASM → RBM + DD-ZM ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 2: ASM Review → RBM + DD-ZM Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-04: RBM receives Action Required (email + WhatsApp + system) after ASM review', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('rbm-1', 'RBM', '+91100'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'RBM Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'rbm-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||||
|
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-05: DD-ZM receives same channels as RBM for joint evaluation', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('zm-1', 'DD_ZM', '+91200'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ZM Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'zm-1');
|
||||||
|
expect(call?.[2].channels).toContain('whatsapp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 3: RBM/ZM → ZBH ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 3: RBM+ZM Evaluation → ZBH Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-06: ZBH receives Action Required notification after RBM+ZM approval', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('zbh-1', 'ZBH', '+91300'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ZBH Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'zbh-1');
|
||||||
|
expect(call?.[2].channels).toContain('email');
|
||||||
|
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 4: ZBH → DD-Lead ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 4: ZBH Review → DD-Lead Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-07: DD-Lead receives email + WhatsApp after ZBH forwards case', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('lead-1', 'DD_LEAD', '+91400'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'DD Lead Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['email', 'whatsapp', 'system']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 5: DD-Lead → NBH ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 5: DD-Lead Review → NBH Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-08: NBH receives Action Required (email + WhatsApp) for final approval', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('nbh-1', 'NBH', '+91500'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'NBH Approval', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'nbh-1');
|
||||||
|
expect(call?.[2].channels).toContain('whatsapp');
|
||||||
|
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 6: NBH → Legal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 6: NBH Approval → Legal Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-09: Legal team receives email + system notification after NBH approval', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('legal-1', 'LEGAL_ADMIN', null)); // no phone
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Legal Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||||
|
expect(call?.[2].channels).not.toContain('whatsapp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 7: Legal Acceptance Letter ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Stage 7: Legal Acceptance Letter — Dealer Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-10: Dealer receives email + WhatsApp when Legal Acceptance Letter is uploaded', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('dealer-99', 'Dealer', '+91987654321'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Completed', {
|
||||||
|
...BASE_META,
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
action: 'Legal Acceptance Letter issued',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||||
|
expect(dealerCall?.[2].channels).toEqual(
|
||||||
|
expect.arrayContaining(['system', 'email', 'whatsapp'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-11: DD-Admin receives in-app system notification for case closure', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('admin-1', 'DD_ADMIN', '+91600'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'DD Admin', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'admin-1');
|
||||||
|
expect(call?.[2].channels).toContain('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 9: Send Back actions ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Send Back — ASM notified with mandatory remarks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-12: Send Back by ZBH notifies ASM via email + WhatsApp + system (SRS §4.2.2.4)', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('asm-1', 'ASM', '+91700'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ASM Review', {
|
||||||
|
...BASE_META,
|
||||||
|
action: 'Sent back to ASM — insufficient documentation',
|
||||||
|
actionUserFullName: 'ZBH Actor', // ZBH acting, so ASM won't be skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||||
|
expect(call?.[2].placeholders?.remarks).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-13: Send Back includes remarks in notification placeholders (SRS §4.2.2.4)', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('asm-2', 'ASM', '+91800'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'ASM Review', {
|
||||||
|
...BASE_META,
|
||||||
|
action: 'Sent back to ASM',
|
||||||
|
remarks: 'MOM document missing. Please resubmit.',
|
||||||
|
actionUserFullName: 'DD Lead Actor',
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'asm-2');
|
||||||
|
expect(call?.[2].placeholders?.remarks).toBe('MOM document missing. Please resubmit.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 10: Revoke ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Resignation Revoke — Dealer notified on terminal event', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RES-14: Dealer receives email + WhatsApp when resignation is Revoked (SRS §4.2.2.6)', async () => {
|
||||||
|
mockParticipants.push(makeParticipant('dealer-99', 'Dealer', '+91987'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-uuid-001', 'resignation', 'Revoked', {
|
||||||
|
...BASE_META,
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
action: 'Resignation revoked by NBH',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||||
|
expect(dealerCall?.[2].channels).toEqual(
|
||||||
|
expect.arrayContaining(['system', 'email', 'whatsapp'])
|
||||||
|
);
|
||||||
|
expect(dealerCall?.[2].title).toContain('Revoked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── F&F Trigger (LWD) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('F&F Trigger — LWD-based gate (SRS §1.1.5 + §4.2.2.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
354
src/__tests__/termination-stage-notifications.test.ts
Normal file
354
src/__tests__/termination-stage-notifications.test.ts
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* @file termination-stage-notifications.test.ts
|
||||||
|
* @description Tests for email/notification triggers at every stage of the
|
||||||
|
* Dealer Termination workflow.
|
||||||
|
*
|
||||||
|
* Stages covered (SRS §4.3 + §8.x):
|
||||||
|
* 1. ASM Case Initiation → RBM + DD-ZM notified (email + WhatsApp)
|
||||||
|
* 2. RBM + DD-ZM Review → ZBH notified (email + WhatsApp)
|
||||||
|
* 3. ZBH Review → DD-Lead notified (email + WhatsApp)
|
||||||
|
* 4. DD-Lead Review + Legal Assignment → Legal + DD-Head notified
|
||||||
|
* 5. Legal Verification → DD-Lead notified after legal input
|
||||||
|
* 6. DD-Head → NBH → NBH notified (email + WhatsApp)
|
||||||
|
* 7. NBH → SCN Issuance → Legal triggered; DD-Admin + Dealer notified
|
||||||
|
* 8. SCN Response Evaluation → Joint panel notified (system)
|
||||||
|
* 9. NBH Final Decision → CEO + CCO notified (email + system)
|
||||||
|
* 10. CEO/CCO Authorization → Legal notified for Termination Letter
|
||||||
|
* 11. Termination Letter Issued → DD-Lead + DD-Admin + Finance notified
|
||||||
|
* 12. F&F Trigger on LWD → Finance notified (email + system)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NotificationService } from '../services/NotificationService.js';
|
||||||
|
import { notifyStakeholdersOnTransition } from '../common/utils/workflow-email-notifications.js';
|
||||||
|
|
||||||
|
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
const BASE_META = {
|
||||||
|
code: 'TERM-2026-001',
|
||||||
|
dealerName: 'ABC Motors Pvt. Ltd.',
|
||||||
|
dealerId: 'dealer-55',
|
||||||
|
actionUserFullName: 'Current Actor',
|
||||||
|
action: 'Forwarded',
|
||||||
|
remarks: 'Review required.',
|
||||||
|
link: 'http://localhost:5173/termination/term-uuid-001',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockParticipants: any[] = [];
|
||||||
|
jest.mock('../database/models/index.js', () => ({
|
||||||
|
default: {
|
||||||
|
RequestParticipant: { findAll: jest.fn(async () => mockParticipants) },
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
email: `${id}@re.com`,
|
||||||
|
fullName: 'Mock User',
|
||||||
|
mobileNumber: '+919000000001',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||||
|
District: {},
|
||||||
|
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../common/utils/email.service.js', () => ({
|
||||||
|
sendEmail: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeP = (id: string, roleCode: string, phone: string | null = '+91900') => ({
|
||||||
|
user: { id, email: `${id}@re.com`, fullName: `User ${id}`, roleCode, mobileNumber: phone },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 1: ASM Initiation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 1: ASM Initiates → RBM + DD-ZM Notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-01: RBM receives email + WhatsApp + system on termination case initiation', async () => {
|
||||||
|
mockParticipants.push(makeP('rbm-1', 'RBM', '+91100'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'RBM Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'rbm-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-02: DD-ZM receives same channels as RBM for joint evaluation', async () => {
|
||||||
|
mockParticipants.push(makeP('zm-1', 'DD_ZM', '+91200'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ZM Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'zm-1');
|
||||||
|
expect(call?.[2].channels).toContain('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-03: Dealer does NOT have portal access for termination (SRS §1.1.6)', () => {
|
||||||
|
// This is a documentation/contract test. Dealer should NOT appear in participants for termination.
|
||||||
|
const dealerInParticipants = mockParticipants.some(
|
||||||
|
(p) => p.user.roleCode === 'Dealer'
|
||||||
|
);
|
||||||
|
expect(dealerInParticipants).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 2-3: ZBH → DD-Lead ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 2-3: RBM+ZM → ZBH → DD-Lead', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-04: ZBH receives Action Required notification after RBM+ZM approval', async () => {
|
||||||
|
mockParticipants.push(makeP('zbh-1', 'ZBH', '+91300'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ZBH Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'zbh-1');
|
||||||
|
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-05: DD-Lead receives email + WhatsApp after ZBH forwards case', async () => {
|
||||||
|
mockParticipants.push(makeP('lead-1', 'DD_LEAD', '+91400'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'DD Lead Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||||
|
expect(call?.[2].channels).toContain('whatsapp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 4: DD-Lead → Legal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 4: DD-Lead → Legal Assignment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-06: Legal team receives email + system on DD-Lead assignment (SRS §4.3.2.4)', async () => {
|
||||||
|
mockParticipants.push(makeP('legal-1', 'LEGAL_ADMIN', null)); // no phone
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Legal Review', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email']));
|
||||||
|
expect(call?.[2].channels).not.toContain('whatsapp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 6: DD-Head → NBH ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 6: DD-Head → NBH Evaluation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-07: NBH receives email + WhatsApp for strategic review (SRS §4.3.2.7)', async () => {
|
||||||
|
mockParticipants.push(makeP('nbh-1', 'NBH', '+91500'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'NBH Evaluation', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'nbh-1');
|
||||||
|
expect(call?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||||
|
expect(call?.[2].templateCode).toBe('WORKFLOW_ACTION_REQUIRED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 7: SCN Issuance ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 7: Show Cause Notice Issuance', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-08: Legal receives notification to prepare SCN after NBH Go-Ahead (SRS §4.3.2.8)', async () => {
|
||||||
|
mockParticipants.push(makeP('legal-1', 'LEGAL_ADMIN', null));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Show Cause Notice', BASE_META);
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls.find((c) => c[0] === 'legal-1');
|
||||||
|
expect(call?.[2].channels).toContain('system');
|
||||||
|
expect(call?.[2].channels).toContain('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-09: DD-Admin is notified to share SCN with dealer (system + email)', async () => {
|
||||||
|
await NotificationService.notify('admin-1', 'ddadmin@re.com', {
|
||||||
|
title: 'Action Required: Share SCN — TERM-2026-001',
|
||||||
|
message: 'Show Cause Notice is ready. Please share with the dealer.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001', link: '/termination/term-uuid-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'admin-1',
|
||||||
|
'ddadmin@re.com',
|
||||||
|
expect.objectContaining({ templateCode: 'WORKFLOW_ACTION_REQUIRED' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 9: NBH Final Decision → CEO/CCO ───────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 9: NBH Final Decision → CEO + CCO Authorization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-10: CEO receives email + system notification for final authorization (SRS §4.3.2.11)', async () => {
|
||||||
|
await NotificationService.notify('ceo-1', 'ceo@re.com', {
|
||||||
|
title: 'Authorization Required: Dealer Termination — TERM-2026-001',
|
||||||
|
message: 'NBH has approved termination. CEO authorization required.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001', link: '/termination/term-uuid-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].channels).toContain('email');
|
||||||
|
expect(call[2].channels).toContain('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-11: CCO receives same notification as CEO for co-authorization', async () => {
|
||||||
|
await NotificationService.notify('cco-1', 'cco@re.com', {
|
||||||
|
title: 'Authorization Required: TERM-2026-001',
|
||||||
|
message: 'Co-authorization from CCO required.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[0]).toBe('cco-1');
|
||||||
|
expect(call[2].channels).toContain('email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stage 11: Termination Letter ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination Stage 11: Termination Letter — DD-Lead + DD-Admin + Finance Notified', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-TERM-12: DD-Lead is notified via system when Legal uploads Termination Letter (SRS §4.3.2.12)', async () => {
|
||||||
|
await NotificationService.notify('lead-1', 'ddlead@re.com', {
|
||||||
|
title: 'Termination Letter Issued: TERM-2026-001',
|
||||||
|
message: 'Legal has uploaded the official Termination Letter.',
|
||||||
|
channels: ['system'],
|
||||||
|
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].channels).toEqual(['system']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-13: DD-Admin is notified to communicate Termination Letter to dealer (email + system)', async () => {
|
||||||
|
await NotificationService.notify('admin-1', 'ddadmin@re.com', {
|
||||||
|
title: 'Action Required: Communicate Termination Letter — TERM-2026-001',
|
||||||
|
message: 'Please share the Termination Letter with the dealer.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith('admin-1', 'ddadmin@re.com', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-14: Finance is notified for F&F setup after termination letter (email + system)', async () => {
|
||||||
|
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||||
|
title: 'F&F Initiation Required: TERM-2026-001',
|
||||||
|
message: 'Termination complete. Please initiate F&F settlement on LWD.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'FNF_INITIATED',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = mockNotify.mock.calls[0];
|
||||||
|
expect(call[2].templateCode).toBe('FNF_INITIATED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── F&F Trigger (LWD) — Termination Context ──────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination: F&F Trigger Must Be on LWD (SRS §1.1.6)', () => {
|
||||||
|
it('TC-TERM-15: F&F is blocked when today is before LWD', () => {
|
||||||
|
const futureLwd = new Date();
|
||||||
|
futureLwd.setDate(futureLwd.getDate() + 30); // 30 days from now
|
||||||
|
const canInitiateFnF = new Date() >= futureLwd;
|
||||||
|
expect(canInitiateFnF).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-16: F&F is allowed when LWD has passed', () => {
|
||||||
|
const pastLwd = new Date('2025-01-01');
|
||||||
|
const canInitiateFnF = new Date() >= pastLwd;
|
||||||
|
expect(canInitiateFnF).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-17: Finance receives notification only when F&F is triggered on LWD', async () => {
|
||||||
|
const lwd = new Date('2025-12-01'); // past date — F&F allowed
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
if (today >= lwd) {
|
||||||
|
await NotificationService.notify('finance-1', 'finance@re.com', {
|
||||||
|
title: 'F&F Triggered on Last Working Day: TERM-2026-001',
|
||||||
|
message: 'Settlement process initiated.',
|
||||||
|
channels: ['system', 'email'],
|
||||||
|
templateCode: 'FNF_INITIATED',
|
||||||
|
placeholders: { requestId: 'TERM-2026-001' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'finance-1',
|
||||||
|
'finance@re.com',
|
||||||
|
expect.objectContaining({ templateCode: 'FNF_INITIATED' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Send Back in Termination ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Termination: Send Back / Revoke → ASM + DD-Lead notified', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-18: ZBH Send Back notifies ASM with remarks via email + WhatsApp (SRS §4.3.2.3)', async () => {
|
||||||
|
mockParticipants.push(makeP('asm-1', 'ASM', '+91700'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'ASM Review', {
|
||||||
|
...BASE_META,
|
||||||
|
action: 'Sent back — MOM documents incomplete',
|
||||||
|
remarks: 'Please resubmit updated MOMs from dealer.',
|
||||||
|
actionUserFullName: 'ZBH Actor',
|
||||||
|
});
|
||||||
|
|
||||||
|
const asmCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||||
|
expect(asmCall?.[2].channels).toEqual(expect.arrayContaining(['system', 'email', 'whatsapp']));
|
||||||
|
expect(asmCall?.[2].placeholders?.remarks).toBe('Please resubmit updated MOMs from dealer.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-TERM-19: DD-Lead Revoke action generates in-app notification for key observers', async () => {
|
||||||
|
mockParticipants.push(makeP('head-1', 'DD_HEAD', '+91800'));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('term-uuid-001', 'termination', 'Revoked', {
|
||||||
|
...BASE_META,
|
||||||
|
action: 'Revoked by DD Lead',
|
||||||
|
actionUserFullName: 'Another User',
|
||||||
|
});
|
||||||
|
|
||||||
|
const observerCall = mockNotify.mock.calls.find((c) => c[0] === 'head-1');
|
||||||
|
// Key observer: system only for terminal event
|
||||||
|
expect(observerCall?.[2].channels).toEqual(['system']);
|
||||||
|
});
|
||||||
|
});
|
||||||
405
src/__tests__/workflow-email-notifications.test.ts
Normal file
405
src/__tests__/workflow-email-notifications.test.ts
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* @file workflow-email-notifications.test.ts
|
||||||
|
* @description Tests for notifyStakeholdersOnTransition + resolveNextActors.
|
||||||
|
* Verifies that the correct personas receive the correct channels at each
|
||||||
|
* onboarding, resignation, and termination stage.
|
||||||
|
*
|
||||||
|
* SRS Coverage:
|
||||||
|
* §6.14.3 — Next actor gets email + WhatsApp + in-app
|
||||||
|
* §6.14.3 — Send Back notifies ASM via email + WhatsApp + in-app
|
||||||
|
* §6.12.3 — Rejection notifies applicant via email + WhatsApp
|
||||||
|
* §6.13 — Work Notes / observer roles get in-app only on terminal events
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
notifyStakeholdersOnTransition,
|
||||||
|
resolveNextActors,
|
||||||
|
notifyResignationSubmittedEmails,
|
||||||
|
notifyRelocationSubmittedEmails,
|
||||||
|
notifyConstitutionalSubmittedEmails,
|
||||||
|
} from '../common/utils/workflow-email-notifications.js';
|
||||||
|
import { NotificationService } from '../services/NotificationService.js';
|
||||||
|
import { sendEmail } from '../common/utils/email.service.js';
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
jest.mock('../common/utils/email.service.js', () => ({
|
||||||
|
sendEmail: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
// Helper: build a participant user object
|
||||||
|
const makeUser = (overrides: Partial<{
|
||||||
|
id: string; email: string; fullName: string; roleCode: string; mobileNumber: string | null;
|
||||||
|
}> = {}) => ({
|
||||||
|
id: overrides.id ?? 'user-1',
|
||||||
|
email: overrides.email ?? 'user@re.com',
|
||||||
|
fullName: overrides.fullName ?? 'Test User',
|
||||||
|
roleCode: overrides.roleCode ?? 'DD_ADMIN',
|
||||||
|
mobileNumber: overrides.mobileNumber ?? '+919800000001',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeParticipant = (user: ReturnType<typeof makeUser>) => ({ user });
|
||||||
|
|
||||||
|
// ─── Mock DB ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockParticipants: ReturnType<typeof makeParticipant>[] = [];
|
||||||
|
|
||||||
|
jest.mock('../database/models/index.js', () => ({
|
||||||
|
default: {
|
||||||
|
RequestParticipant: {
|
||||||
|
findAll: jest.fn(async () => mockParticipants),
|
||||||
|
},
|
||||||
|
User: {
|
||||||
|
findByPk: jest.fn(async (id: string) => ({
|
||||||
|
id,
|
||||||
|
fullName: 'System User',
|
||||||
|
email: 'sysuser@re.com',
|
||||||
|
mobileNumber: '+910000000000',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
Outlet: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||||
|
District: {},
|
||||||
|
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
// ─── Shared metadata ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const baseMeta = {
|
||||||
|
code: 'RES-2026-0001',
|
||||||
|
dealerName: 'Sunrise Motorcycles',
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
actionUserFullName: 'Ravi Kumar (ASM)',
|
||||||
|
action: 'Forwarded to RBM',
|
||||||
|
remarks: 'Documents verified.',
|
||||||
|
link: 'http://localhost:5173/resignation/abc123',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── resolveNextActors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveNextActors — stage-to-role mapping', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-01: Level 1 Interview resolves to DD_ZM and RBM', async () => {
|
||||||
|
const zmUser = makeUser({ id: 'zm-1', roleCode: 'DD_ZM' });
|
||||||
|
const rbmUser = makeUser({ id: 'rbm-1', roleCode: 'RBM' });
|
||||||
|
mockParticipants.push(makeParticipant(zmUser), makeParticipant(rbmUser));
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('app-1', 'application', 'Level 1 Interview');
|
||||||
|
expect(actors).toContain('zm-1');
|
||||||
|
expect(actors).toContain('rbm-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-02: Level 2 Interview resolves to ZBH and DD_LEAD', async () => {
|
||||||
|
const zbhUser = makeUser({ id: 'zbh-1', roleCode: 'ZBH' });
|
||||||
|
const leadUser = makeUser({ id: 'lead-1', roleCode: 'DD_LEAD' });
|
||||||
|
mockParticipants.push(makeParticipant(zbhUser), makeParticipant(leadUser));
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('app-1', 'application', 'Level 2 Interview');
|
||||||
|
expect(actors).toContain('zbh-1');
|
||||||
|
expect(actors).toContain('lead-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-03: Level 3 Interview resolves to NBH and DD_HEAD', async () => {
|
||||||
|
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||||
|
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||||
|
mockParticipants.push(makeParticipant(nbhUser), makeParticipant(headUser));
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('app-1', 'application', 'Level 3 Interview');
|
||||||
|
expect(actors).toContain('nbh-1');
|
||||||
|
expect(actors).toContain('head-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-04: LOI Approval resolves to DD_HEAD (DD Head has not yet approved)', async () => {
|
||||||
|
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||||
|
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||||
|
mockParticipants.push(makeParticipant(headUser), makeParticipant(nbhUser));
|
||||||
|
|
||||||
|
// StageApprovalAction returns empty (no approvals yet)
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('app-1', 'application', 'LOI Approval');
|
||||||
|
// Sequential: DD Head first
|
||||||
|
expect(actors).toContain('head-1');
|
||||||
|
expect(actors).not.toContain('nbh-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-05: LOI Approval resolves to NBH after DD Head has approved', async () => {
|
||||||
|
const headUser = makeUser({ id: 'head-1', roleCode: 'DD_HEAD' });
|
||||||
|
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||||
|
mockParticipants.push(makeParticipant(headUser), makeParticipant(nbhUser));
|
||||||
|
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||||
|
{ actorRole: 'DD Head' }, // DD Head already approved
|
||||||
|
]);
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('app-1', 'application', 'LOI Approval');
|
||||||
|
expect(actors).toContain('nbh-1');
|
||||||
|
expect(actors).not.toContain('head-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-06: NBH Approval stage resolves to NBH only', async () => {
|
||||||
|
const nbhUser = makeUser({ id: 'nbh-1', roleCode: 'NBH' });
|
||||||
|
mockParticipants.push(makeParticipant(nbhUser));
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('res-1', 'resignation', 'NBH Approval');
|
||||||
|
expect(actors).toEqual(['nbh-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-07: Legal Review stage resolves to LEGAL_ADMIN', async () => {
|
||||||
|
const legalUser = makeUser({ id: 'legal-1', roleCode: 'LEGAL_ADMIN' });
|
||||||
|
mockParticipants.push(makeParticipant(legalUser));
|
||||||
|
|
||||||
|
const actors = await resolveNextActors('res-1', 'resignation', 'Legal Review');
|
||||||
|
expect(actors).toContain('legal-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-RNA-08: Unknown stage returns empty array (no crash)', async () => {
|
||||||
|
const actors = await resolveNextActors('app-1', 'application', 'NonExistentStage');
|
||||||
|
expect(actors).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── notifyStakeholdersOnTransition ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('notifyStakeholdersOnTransition — channel selection per persona', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-01: Next actor receives system + email + whatsapp channels', async () => {
|
||||||
|
const rbmUser = makeUser({ id: 'rbm-1', roleCode: 'RBM', mobileNumber: '+919800000001' });
|
||||||
|
mockParticipants.push(makeParticipant(rbmUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('app-1', 'application', 'RBM Review', baseMeta);
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'rbm-1',
|
||||||
|
'user@re.com',
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-02: Next actor without phone gets system + email only (no WhatsApp)', async () => {
|
||||||
|
const zbhUser = makeUser({ id: 'zbh-1', roleCode: 'ZBH', mobileNumber: null });
|
||||||
|
mockParticipants.push(makeParticipant(zbhUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('app-1', 'application', 'ZBH Review', baseMeta);
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'zbh-1',
|
||||||
|
'user@re.com',
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: expect.not.arrayContaining(['whatsapp']),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-03: Send Back action notifies ASM via system + email + whatsapp', async () => {
|
||||||
|
const asmUser = makeUser({ id: 'asm-1', roleCode: 'ASM', mobileNumber: '+91900' });
|
||||||
|
mockParticipants.push(makeParticipant(asmUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-1', 'resignation', 'ASM Review', {
|
||||||
|
...baseMeta,
|
||||||
|
action: 'Sent Back to ASM for clarification',
|
||||||
|
actionUserFullName: 'DD Lead User', // different from ASM — won't be skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'asm-1',
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||||
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-04: Dealer receives system + email + whatsapp on Rejected terminal event', async () => {
|
||||||
|
const dealerUser = makeUser({
|
||||||
|
id: 'dealer-99',
|
||||||
|
roleCode: 'Dealer',
|
||||||
|
mobileNumber: '+91987654321',
|
||||||
|
});
|
||||||
|
mockParticipants.push(makeParticipant(dealerUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-1', 'resignation', 'Rejected', {
|
||||||
|
...baseMeta,
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
'dealer-99',
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: expect.arrayContaining(['system', 'email', 'whatsapp']),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-05: Dealer receives in-app only on non-terminal interim stage', async () => {
|
||||||
|
const dealerUser = makeUser({
|
||||||
|
id: 'dealer-99',
|
||||||
|
roleCode: 'Dealer',
|
||||||
|
mobileNumber: '+91987654321',
|
||||||
|
});
|
||||||
|
mockParticipants.push(makeParticipant(dealerUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-1', 'resignation', 'ZBH Review', {
|
||||||
|
...baseMeta,
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dealerCall = mockNotify.mock.calls.find((c) => c[0] === 'dealer-99');
|
||||||
|
expect(dealerCall?.[2].channels).toEqual(['system']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-06: Key observers (DD_LEAD, DD_HEAD, NBH) receive in-app only on terminal events', async () => {
|
||||||
|
const leadUser = makeUser({ id: 'lead-1', roleCode: 'DD_LEAD', mobileNumber: '+91900' });
|
||||||
|
mockParticipants.push(makeParticipant(leadUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('res-1', 'resignation', 'Completed', {
|
||||||
|
...baseMeta,
|
||||||
|
dealerId: 'dealer-99',
|
||||||
|
actionUserFullName: 'DD Lead User', // acting user – but leadUser will still match key observer
|
||||||
|
});
|
||||||
|
|
||||||
|
const observerCall = mockNotify.mock.calls.find((c) => c[0] === 'lead-1');
|
||||||
|
// Key observer: only in-app (system)
|
||||||
|
expect(observerCall?.[2].channels).toEqual(['system']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NST-07: Acting user is skipped to avoid self-notification', async () => {
|
||||||
|
const actingUser = makeUser({
|
||||||
|
id: 'acting-1',
|
||||||
|
roleCode: 'ZBH',
|
||||||
|
fullName: 'Ravi Kumar (ASM)', // same as baseMeta.actionUserFullName
|
||||||
|
});
|
||||||
|
// actingUser is NOT the next actor (no role match for ZBH Review mapping)
|
||||||
|
mockParticipants.push(makeParticipant(actingUser));
|
||||||
|
|
||||||
|
await notifyStakeholdersOnTransition('app-1', 'application', 'ZBH Review', baseMeta);
|
||||||
|
|
||||||
|
// Should not notify the acting user (they are the one who just acted)
|
||||||
|
const actingCall = mockNotify.mock.calls.find((c) => c[0] === 'acting-1');
|
||||||
|
expect(actingCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── notifyResignationSubmittedEmails ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('notifyResignationSubmittedEmails — channels on dealer submission', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockParticipants.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NRSE-01: sends RESIGNATION_RECEIVED email to dealer on submission', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-1',
|
||||||
|
email: 'dealer@example.com',
|
||||||
|
fullName: 'Sunrise Dealers',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendEmailMock = sendEmail as jest.Mock;
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails({
|
||||||
|
id: 'res-uuid-1',
|
||||||
|
dealerId: 'dealer-1',
|
||||||
|
resignationId: 'RES-2026-0001',
|
||||||
|
lastOperationalDateSales: '2026-06-30',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendEmailMock).toHaveBeenCalledWith(
|
||||||
|
'dealer@example.com',
|
||||||
|
expect.stringContaining('RES-2026-0001'),
|
||||||
|
'RESIGNATION_RECEIVED',
|
||||||
|
expect.objectContaining({ dealerName: 'Sunrise Dealers' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NRSE-02: sends WhatsApp to dealer when mobileNumber is present', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-1',
|
||||||
|
email: 'dealer@example.com',
|
||||||
|
fullName: 'Sunrise Dealers',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails({
|
||||||
|
id: 'res-uuid-1',
|
||||||
|
dealerId: 'dealer-1',
|
||||||
|
resignationId: 'RES-2026-0001',
|
||||||
|
lastOperationalDateSales: '2026-06-30',
|
||||||
|
});
|
||||||
|
|
||||||
|
const whatsappCall = mockNotify.mock.calls.find(
|
||||||
|
(c) => c[2].channels?.includes('whatsapp')
|
||||||
|
);
|
||||||
|
expect(whatsappCall).toBeDefined();
|
||||||
|
expect(whatsappCall?.[2].templateCode).toBe('RESIGNATION_RECEIVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NRSE-03: notifies internal participants via email + system + whatsapp', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-1',
|
||||||
|
email: 'dealer@example.com',
|
||||||
|
fullName: 'Sunrise Dealers',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
});
|
||||||
|
|
||||||
|
const internalUser = makeUser({ id: 'asm-1', roleCode: 'ASM', mobileNumber: '+91111' });
|
||||||
|
mockParticipants.push(makeParticipant(internalUser));
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails({
|
||||||
|
id: 'res-uuid-1',
|
||||||
|
dealerId: 'dealer-1',
|
||||||
|
resignationId: 'RES-2026-0001',
|
||||||
|
});
|
||||||
|
|
||||||
|
const internalCall = mockNotify.mock.calls.find((c) => c[0] === 'asm-1');
|
||||||
|
expect(internalCall?.[2].channels).toEqual(
|
||||||
|
expect.arrayContaining(['email', 'system', 'whatsapp'])
|
||||||
|
);
|
||||||
|
expect(internalCall?.[2].templateCode).toBe('RESIGNATION_SUBMITTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-NRSE-04: skips WhatsApp if dealer has no mobileNumber', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findByPk.mockResolvedValueOnce({
|
||||||
|
id: 'dealer-2',
|
||||||
|
email: 'dealer2@example.com',
|
||||||
|
fullName: 'No Phone Dealer',
|
||||||
|
mobileNumber: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyResignationSubmittedEmails({
|
||||||
|
id: 'res-uuid-2',
|
||||||
|
dealerId: 'dealer-2',
|
||||||
|
resignationId: 'RES-2026-0002',
|
||||||
|
});
|
||||||
|
|
||||||
|
const whatsappCall = mockNotify.mock.calls.find(
|
||||||
|
(c) => c[0] === 'dealer-2' && c[2].channels?.includes('whatsapp')
|
||||||
|
);
|
||||||
|
expect(whatsappCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
298
src/__tests__/workflow-service.test.ts
Normal file
298
src/__tests__/workflow-service.test.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* @file workflow-service.test.ts
|
||||||
|
* @description Tests for WorkflowService.transitionApplication — verifies that
|
||||||
|
* each status transition triggers the correct notifications, audit log entries,
|
||||||
|
* and SLA tracking calls.
|
||||||
|
*
|
||||||
|
* SRS Coverage:
|
||||||
|
* §6.22 — Every workflow event is auto-logged in Audit Trail
|
||||||
|
* §6.14.3 — Status transitions trigger in-system + email + WhatsApp for applicant
|
||||||
|
* §6.16 — LOI Issued triggers LOI_ISSUED email template (not WhatsApp per SRS §1.1.2)
|
||||||
|
* §9.4 — SLA tracking starts/stops on each stage transition
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WorkflowService } from '../services/WorkflowService.js';
|
||||||
|
import { NotificationService } from '../services/NotificationService.js';
|
||||||
|
import { SLAService } from '../services/SLAService.js';
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
jest.mock('../database/models/index.js', () => {
|
||||||
|
const mockUpdate = jest.fn().mockResolvedValue(true);
|
||||||
|
const mockCreate = jest.fn().mockResolvedValue({ id: 'hist-1' });
|
||||||
|
const mockFindOne = jest.fn().mockResolvedValue(null);
|
||||||
|
const mockFindByPk = jest.fn().mockResolvedValue({ fullName: 'Test Actor' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
Application: {},
|
||||||
|
ApplicationStatusHistory: { create: mockCreate },
|
||||||
|
User: { findOne: mockFindOne, findByPk: mockFindByPk },
|
||||||
|
Dealer: {},
|
||||||
|
StageApprovalPolicy: { findOne: jest.fn().mockResolvedValue(null) },
|
||||||
|
StageApprovalAction: { findAll: jest.fn().mockResolvedValue([]) },
|
||||||
|
},
|
||||||
|
__mocks__: { mockUpdate, mockCreate, mockFindOne },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../common/utils/progress.js', () => ({
|
||||||
|
syncApplicationProgress: jest.fn().mockResolvedValue(undefined),
|
||||||
|
PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../common/config/constants.js', () => ({
|
||||||
|
AUDIT_ACTIONS: { UPDATED: 'UPDATED' },
|
||||||
|
APPLICATION_STAGES: { SHORTLISTED: 'Shortlisted', LOI_ISSUED: 'LOI Issued' },
|
||||||
|
OVERALL_STATUS_TO_DB_CURRENT_STAGE: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../common/utils/workflow-email-notifications.js', () => ({
|
||||||
|
notifyStakeholdersOnTransition: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../services/applicationAuditLog.service.js', () => ({
|
||||||
|
pickApplicationAuditContext: jest.fn().mockReturnValue({}),
|
||||||
|
safeAuditLogCreate: jest.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSLAStop = jest.spyOn(SLAService, 'stopTrack').mockResolvedValue(undefined as any);
|
||||||
|
const mockSLAStart = jest.spyOn(SLAService, 'startTrack').mockResolvedValue(undefined as any);
|
||||||
|
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:5173';
|
||||||
|
|
||||||
|
// ─── Application fixture ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const makeApp = (overrides: Record<string, any> = {}) => {
|
||||||
|
const app: any = {
|
||||||
|
id: 'app-uuid-001',
|
||||||
|
applicationId: 'APP-2026-001',
|
||||||
|
overallStatus: 'Shortlisted',
|
||||||
|
currentStage: 'Shortlisted',
|
||||||
|
progressPercentage: 20,
|
||||||
|
email: 'applicant@gmail.com',
|
||||||
|
applicantName: 'Rahul Verma',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
update: jest.fn().mockResolvedValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('WorkflowService.transitionApplication — status transitions', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-WS-01: updates application overallStatus to the target status', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
||||||
|
expect(app.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ overallStatus: 'Level 1 Interview Pending' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-02: creates an ApplicationStatusHistory record on each transition', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'Level 2 Interview Pending', 'user-2');
|
||||||
|
expect(db.ApplicationStatusHistory.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
applicationId: 'app-uuid-001',
|
||||||
|
previousStatus: 'Shortlisted',
|
||||||
|
newStatus: 'Level 2 Interview Pending',
|
||||||
|
changedBy: 'user-2',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-03: skips redundant status history when status is already at target', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
const app = makeApp({ overallStatus: 'Level 1 Interview Pending' });
|
||||||
|
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-1');
|
||||||
|
expect(db.ApplicationStatusHistory.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-04: calls safeAuditLogCreate with oldData and newData on each transition', async () => {
|
||||||
|
const { safeAuditLogCreate } = await import('../services/applicationAuditLog.service.js');
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-fdd');
|
||||||
|
expect(safeAuditLogCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'UPDATED',
|
||||||
|
entityType: 'application',
|
||||||
|
entityId: 'app-uuid-001',
|
||||||
|
oldData: expect.objectContaining({ status: 'Shortlisted' }),
|
||||||
|
newData: expect.objectContaining({ status: 'FDD Verification' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-05: stops SLA tracking for the previous stage and starts for the new stage', async () => {
|
||||||
|
const app = makeApp({ currentStage: 'Level 1 Interview Pending' });
|
||||||
|
await WorkflowService.transitionApplication(app, 'FDD Verification', 'user-1');
|
||||||
|
expect(mockSLAStop).toHaveBeenCalledWith('app-uuid-001', 'Level 1 Interview Pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-06: triggers NotificationService.notify for the applicant on any status transition', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'Level 1 Interview Pending', 'user-admin');
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
'applicant@gmail.com',
|
||||||
|
expect.objectContaining({
|
||||||
|
title: expect.stringContaining('Onboarding Update'),
|
||||||
|
channels: expect.arrayContaining(['email', 'system']),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-07: uses LOI_ISSUED template when target status is "LOI Issued"', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'LOI Issued', 'admin-1');
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'applicant@gmail.com',
|
||||||
|
expect.objectContaining({ templateCode: 'LOI_ISSUED' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-08: uses LOA_ISSUED template when target status is "LOA Issued"', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: '+91123' });
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'LOA Issued', 'admin-1');
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'applicant@gmail.com',
|
||||||
|
expect.objectContaining({ templateCode: 'LOA_ISSUED' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-09: uses DEALER_CODE_READY template when target status is "Dealer Code Generated"', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findOne.mockResolvedValueOnce({ id: 'sys-user-1', mobileNumber: null });
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'Dealer Code Generated', 'admin-1');
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'applicant@gmail.com',
|
||||||
|
expect.objectContaining({ templateCode: 'DEALER_CODE_READY' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-10: notifyStakeholdersOnTransition is called for every transition', async () => {
|
||||||
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'ZBH Review', 'user-zbh');
|
||||||
|
expect(notifyStakeholdersOnTransition).toHaveBeenCalledWith(
|
||||||
|
'app-uuid-001',
|
||||||
|
'application',
|
||||||
|
'ZBH Review',
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'APP-2026-001',
|
||||||
|
dealerName: 'Rahul Verma',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-11: skips applicant notification when skipNotification metadata is true', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'Shortlisted', 'admin-1', {
|
||||||
|
skipNotification: true,
|
||||||
|
forceLog: true,
|
||||||
|
});
|
||||||
|
expect(mockNotify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WS-12: includes applicant mobileNumber in WhatsApp placeholders', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.User.findOne.mockResolvedValueOnce({
|
||||||
|
id: 'sys-user-1',
|
||||||
|
mobileNumber: '+919876543210',
|
||||||
|
});
|
||||||
|
const app = makeApp();
|
||||||
|
await WorkflowService.transitionApplication(app, 'FDD Verification', 'admin-1');
|
||||||
|
const notifyCall = mockNotify.mock.calls.find(
|
||||||
|
(c) => c[1] === 'applicant@gmail.com'
|
||||||
|
);
|
||||||
|
expect(notifyCall?.[2].placeholders?.phone).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── WorkflowService.evaluateStagePolicy ─────────────────────────────────────
|
||||||
|
|
||||||
|
describe('WorkflowService.evaluateStagePolicy — multi-role gate logic', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('TC-WESP-01: returns policyMet=true when no active policy exists for the stage', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce(null);
|
||||||
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'SOME_STAGE');
|
||||||
|
expect(result.policyMet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WESP-02: Super Admin bypass — always returns policyMet=true', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||||
|
requiredRoles: ['DD_ZM', 'RBM'],
|
||||||
|
approvalMode: 'ALL',
|
||||||
|
minApprovals: 2,
|
||||||
|
});
|
||||||
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||||
|
{ actorUserId: 'su-1', actorRole: 'Super Admin', decision: 'Approved' },
|
||||||
|
]);
|
||||||
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||||
|
expect(result.policyMet).toBe(true);
|
||||||
|
expect(result.overriddenBy).toBe('Super Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WESP-03: MIN_N mode — policyMet=true when minApprovals threshold is reached', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||||
|
requiredRoles: ['DD_ZM', 'RBM'],
|
||||||
|
approvalMode: 'MIN_N',
|
||||||
|
minApprovals: 1,
|
||||||
|
});
|
||||||
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||||
|
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||||
|
]);
|
||||||
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||||
|
expect(result.policyMet).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WESP-04: ALL mode — policyMet=false when only one of two required roles responded', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||||
|
requiredRoles: ['DD_ZM', 'RBM'],
|
||||||
|
approvalMode: 'ALL',
|
||||||
|
minApprovals: 2,
|
||||||
|
});
|
||||||
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||||
|
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||||
|
// RBM has NOT responded yet
|
||||||
|
]);
|
||||||
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||||
|
expect(result.policyMet).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC-WESP-05: ALL mode — policyMet=true when both RBM and DD_ZM have responded', async () => {
|
||||||
|
const db = (await import('../database/models/index.js')).default as any;
|
||||||
|
db.StageApprovalPolicy.findOne.mockResolvedValueOnce({
|
||||||
|
requiredRoles: ['DD_ZM', 'RBM'],
|
||||||
|
approvalMode: 'ALL',
|
||||||
|
minApprovals: 2,
|
||||||
|
});
|
||||||
|
db.StageApprovalAction.findAll.mockResolvedValueOnce([
|
||||||
|
{ actorUserId: 'zm-1', actorRole: 'DD_ZM', decision: 'Approved' },
|
||||||
|
{ actorUserId: 'rbm-1', actorRole: 'RBM', decision: 'Approved' },
|
||||||
|
]);
|
||||||
|
const result = await WorkflowService.evaluateStagePolicy('app-1', 'LEVEL_1');
|
||||||
|
expect(result.policyMet).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -176,14 +176,14 @@ export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
|
|||||||
// Termination Stages
|
// Termination Stages
|
||||||
export const TERMINATION_STAGES = {
|
export const TERMINATION_STAGES = {
|
||||||
SUBMITTED: 'Submitted',
|
SUBMITTED: 'Submitted',
|
||||||
RBM_REVIEW: 'RBM Review',
|
RBM_REVIEW: 'RBM + DD-ZM Review',
|
||||||
ZBH_REVIEW: 'ZBH Review',
|
ZBH_REVIEW: 'ZBH Review',
|
||||||
DD_LEAD_REVIEW: 'DD Lead Review',
|
DD_LEAD_REVIEW: 'DD Lead Review',
|
||||||
LEGAL_VERIFICATION: 'Legal Verification',
|
LEGAL_VERIFICATION: 'Legal Verification',
|
||||||
DD_HEAD_REVIEW: 'DD Head Review',
|
DD_HEAD_REVIEW: 'DD Head Review',
|
||||||
NBH_EVALUATION: 'NBH Evaluation',
|
NBH_EVALUATION: 'NBH Evaluation',
|
||||||
SCN_ISSUED: 'Show Cause Notice',
|
SCN_ISSUED: 'Show Cause Notice (SCN)',
|
||||||
PERSONAL_HEARING: 'Personal Hearing',
|
PERSONAL_HEARING: 'Evaluation of Dealer SCN Response',
|
||||||
NBH_FINAL_APPROVAL: 'NBH Final Approval',
|
NBH_FINAL_APPROVAL: 'NBH Final Approval',
|
||||||
CCO_APPROVAL: 'CCO Approval',
|
CCO_APPROVAL: 'CCO Approval',
|
||||||
CEO_APPROVAL: 'CEO Final Approval',
|
CEO_APPROVAL: 'CEO Final Approval',
|
||||||
@ -195,7 +195,7 @@ export const TERMINATION_STAGES = {
|
|||||||
// Resignation Stages
|
// Resignation Stages
|
||||||
export const RESIGNATION_STAGES = {
|
export const RESIGNATION_STAGES = {
|
||||||
ASM: 'ASM',
|
ASM: 'ASM',
|
||||||
RBM: 'RBM',
|
RBM: 'RBM + DD-ZM Review',
|
||||||
ZBH: 'ZBH',
|
ZBH: 'ZBH',
|
||||||
DD_LEAD: 'DD Lead',
|
DD_LEAD: 'DD Lead',
|
||||||
NBH: 'NBH',
|
NBH: 'NBH',
|
||||||
@ -223,13 +223,8 @@ export const RESIGNATION_TYPES = {
|
|||||||
export const CONSTITUTIONAL_CHANGE_TYPES = {
|
export const CONSTITUTIONAL_CHANGE_TYPES = {
|
||||||
PROPRIETORSHIP: 'Proprietorship',
|
PROPRIETORSHIP: 'Proprietorship',
|
||||||
PARTNERSHIP: 'Partnership',
|
PARTNERSHIP: 'Partnership',
|
||||||
LLP_CONVERSION: 'LLP Conversion',
|
|
||||||
LLP: 'LLP',
|
LLP: 'LLP',
|
||||||
PRIVATE_LIMITED: 'Private Limited',
|
PRIVATE_LIMITED: 'Private Limited'
|
||||||
COMPANY_FORMATION: 'Company Formation',
|
|
||||||
OWNERSHIP_TRANSFER: 'Ownership Transfer',
|
|
||||||
PARTNERSHIP_CHANGE: 'Partnership Change',
|
|
||||||
DIRECTOR_CHANGE: 'Director Change'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
|
/** 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',
|
DD_ZM_REVIEW: 'DD ZM Review',
|
||||||
ZBH_REVIEW: 'ZBH Review',
|
ZBH_REVIEW: 'ZBH Review',
|
||||||
DD_LEAD_REVIEW: 'DD Lead Review',
|
DD_LEAD_REVIEW: 'DD Lead Review',
|
||||||
DD_HEAD_APPROVAL: 'DD Head Approval',
|
|
||||||
NBH_APPROVAL: 'NBH Approval',
|
NBH_APPROVAL: 'NBH Approval',
|
||||||
LEGAL_CLEARANCE: 'Legal Clearance',
|
LEGAL_CLEARANCE: 'Legal Clearance',
|
||||||
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
|
|
||||||
COMPLETED: 'Completed',
|
COMPLETED: 'Completed',
|
||||||
REJECTED: 'Rejected'
|
REJECTED: 'Rejected'
|
||||||
} as const;
|
} as const;
|
||||||
@ -493,13 +486,15 @@ export const RESIGNATION_DOCUMENT_TYPES = [
|
|||||||
'Resignation Letter',
|
'Resignation Letter',
|
||||||
'Dealer Undertaking',
|
'Dealer Undertaking',
|
||||||
'Approval Note',
|
'Approval Note',
|
||||||
'Legal Communication',
|
'Resignation Acceptance Letter',
|
||||||
'Handover Document',
|
'Handover Document',
|
||||||
'Settlement Supporting Document',
|
'Settlement Supporting Document',
|
||||||
|
'PPT Presentation',
|
||||||
'Other'
|
'Other'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const RESIGNATION_DOCUMENT_STAGES = [
|
export const RESIGNATION_DOCUMENT_STAGES = [
|
||||||
|
'Initiation',
|
||||||
'ASM',
|
'ASM',
|
||||||
'RBM',
|
'RBM',
|
||||||
'ZBH',
|
'ZBH',
|
||||||
@ -523,7 +518,7 @@ export const TERMINATION_DOCUMENT_TYPES = [
|
|||||||
|
|
||||||
export const TERMINATION_DOCUMENT_STAGES = [
|
export const TERMINATION_DOCUMENT_STAGES = [
|
||||||
'Submitted',
|
'Submitted',
|
||||||
'RBM Review',
|
'RBM + DD-ZM Review',
|
||||||
'ZBH Review',
|
'ZBH Review',
|
||||||
'DD Lead Review',
|
'DD Lead Review',
|
||||||
'Legal Verification',
|
'Legal Verification',
|
||||||
@ -557,7 +552,8 @@ export const OFFBOARDING_ACTIONS = {
|
|||||||
PUSH_FNF: 'pushfnf',
|
PUSH_FNF: 'pushfnf',
|
||||||
RECONSIDER: 'reconsider',
|
RECONSIDER: 'reconsider',
|
||||||
ISSUE_SCN: 'issueSCN',
|
ISSUE_SCN: 'issueSCN',
|
||||||
SCN_RESPONSE: 'scnResponse'
|
SCN_RESPONSE: 'scnResponse',
|
||||||
|
HOLD: 'hold'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Module List for Document Management
|
// 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)
|
// Process Stages per Module (Source of Truth for Checklists)
|
||||||
export const STAGES_MAP = {
|
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'],
|
'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'],
|
'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
|
||||||
'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'],
|
'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'],
|
'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;
|
} as const;
|
||||||
|
|||||||
@ -48,7 +48,9 @@ const fileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallb
|
|||||||
'application/msword',
|
'application/msword',
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
'application/vnd.ms-excel',
|
'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)) {
|
if (allowedTypes.includes(file.mimetype)) {
|
||||||
|
|||||||
@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
|
|||||||
) {
|
) {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
|
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
|
||||||
}
|
}
|
||||||
if (compact.includes('llp') && compact.includes('conversion')) {
|
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION;
|
|
||||||
}
|
|
||||||
if (compact.includes('llp')) {
|
if (compact.includes('llp')) {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||||
}
|
}
|
||||||
if (compact.includes('partnership') && compact.includes('change')) {
|
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE;
|
|
||||||
}
|
|
||||||
if (compact.includes('partnership')) {
|
if (compact.includes('partnership')) {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
|
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
|
||||||
}
|
}
|
||||||
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
|
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.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());
|
const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
|
||||||
return exact || null;
|
return exact || null;
|
||||||
}
|
}
|
||||||
@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
|||||||
const t = String(changeType || '').trim();
|
const t = String(changeType || '').trim();
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
|
|
||||||
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
|
||||||
|
|
||||||
const structureTargets = [
|
const structureTargets = [
|
||||||
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
||||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
||||||
@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
|||||||
];
|
];
|
||||||
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,8 @@ export function registerEmailPartials(h: typeof handlebars = handlebars): void {
|
|||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
email_header: 'email_header.html',
|
email_header: 'email_header.html',
|
||||||
email_footer: 'email_footer.html',
|
email_footer: 'email_footer.html',
|
||||||
primary_cta: 'primary_cta.html'
|
primary_cta: 'primary_cta.html',
|
||||||
|
cta_button: 'primary_cta.html'
|
||||||
};
|
};
|
||||||
|
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
|
|||||||
@ -22,8 +22,9 @@ export const normalizeFnFStatus = (status: string | null | undefined): string =>
|
|||||||
|
|
||||||
export const getResignationStatusForStage = (stage: string): string => {
|
export const getResignationStatusForStage = (stage: string): string => {
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case RESIGNATION_STAGES.ASM:
|
|
||||||
case RESIGNATION_STAGES.RBM:
|
case RESIGNATION_STAGES.RBM:
|
||||||
|
return RESIGNATION_STAGES.RBM; // It already contains "Review"
|
||||||
|
case RESIGNATION_STAGES.ASM:
|
||||||
case RESIGNATION_STAGES.ZBH:
|
case RESIGNATION_STAGES.ZBH:
|
||||||
case RESIGNATION_STAGES.DD_LEAD:
|
case RESIGNATION_STAGES.DD_LEAD:
|
||||||
case RESIGNATION_STAGES.NBH:
|
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 => {
|
export const normalizeClearanceStatus = (status: string | null | undefined, amount: number): string => {
|
||||||
const normalizedAmount = Math.abs(Number(amount) || 0);
|
const normalizedAmount = Math.abs(Number(amount) || 0);
|
||||||
const value = (status || '').toLowerCase();
|
const value = (status || '').toLowerCase();
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
|
|||||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||||
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
[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.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION,
|
||||||
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
||||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING,
|
[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.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_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_LEAD_REVIEW,
|
||||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
[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;
|
return flow[currentStage] || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
|||||||
// Statuses that imply the CURRENT stage (single or both parallel) is finished
|
// Statuses that imply the CURRENT stage (single or both parallel) is finished
|
||||||
const completionStatuses = [
|
const completionStatuses = [
|
||||||
'Submitted', 'Questionnaire Completed', 'Shortlisted', 'Level 1 Approved',
|
'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'
|
'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -164,7 +164,7 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
|||||||
const application = await db.Application.findByPk(applicationId);
|
const application = await db.Application.findByPk(applicationId);
|
||||||
|
|
||||||
// Robust Sync: Prepare ALL stages for batch processing
|
// Robust Sync: Prepare ALL stages for batch processing
|
||||||
const upsertData = [];
|
const upsertData: any[] = [];
|
||||||
for (const stage of ONBOARDING_STAGES) {
|
for (const stage of ONBOARDING_STAGES) {
|
||||||
let status: 'pending' | 'active' | 'completed' = 'pending';
|
let status: 'pending' | 'active' | 'completed' = 'pending';
|
||||||
let percentage = 0;
|
let percentage = 0;
|
||||||
@ -199,10 +199,36 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use bulkCreate with updateOnDuplicate to perform an efficient batch upsert
|
// DB Duplication Prevention without Schema Changes (Healing corrupted data loops)
|
||||||
await ApplicationProgress.bulkCreate(upsertData, {
|
const existingRecords = await ApplicationProgress.findAll({ where: { applicationId } });
|
||||||
updateOnDuplicate: ['status', 'completionPercentage', 'stageStartedAt', 'stageCompletedAt']
|
const seenStages = new Set<string>();
|
||||||
|
|
||||||
|
// Purge any ghost duplicates created by old logic
|
||||||
|
for (const record of existingRecords) {
|
||||||
|
if (seenStages.has(record.stageName)) {
|
||||||
|
await record.destroy();
|
||||||
|
} else {
|
||||||
|
seenStages.add(record.stageName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform single row updates/inserts to enforce exact 1:1 mapping safely
|
||||||
|
const cleanedRecords = await ApplicationProgress.findAll({ where: { applicationId } });
|
||||||
|
|
||||||
|
for (const data of upsertData) {
|
||||||
|
const existing = cleanedRecords.find((r: any) => r.stageName === data.stageName);
|
||||||
|
if (existing) {
|
||||||
|
await existing.update({
|
||||||
|
stageOrder: data.stageOrder,
|
||||||
|
status: data.status,
|
||||||
|
completionPercentage: data.completionPercentage,
|
||||||
|
stageStartedAt: data.stageStartedAt || existing.stageStartedAt,
|
||||||
|
stageCompletedAt: data.stageCompletedAt || existing.stageCompletedAt
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
await ApplicationProgress.create(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
61
src/common/utils/terminationJointReviewRound.util.ts
Normal file
61
src/common/utils/terminationJointReviewRound.util.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Op } from 'sequelize';
|
||||||
|
import { TERMINATION_STAGES } from '../config/constants.js';
|
||||||
|
|
||||||
|
const norm = (s: string | undefined | null) =>
|
||||||
|
String(s || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
export const isScnResponseJointTargetStage = (targetStage: string | undefined | null): boolean => {
|
||||||
|
const n = norm(targetStage);
|
||||||
|
if (!n) return false;
|
||||||
|
if (n === norm(TERMINATION_STAGES.PERSONAL_HEARING)) return true;
|
||||||
|
if (n.includes('evaluation') && n.includes('scn') && n.includes('response')) return true;
|
||||||
|
if (n.includes('personal hearing')) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRbmJointTargetStage = (targetStage: string | undefined | null): boolean => {
|
||||||
|
const n = norm(targetStage);
|
||||||
|
return n.includes('rbm') && (n.includes('dd-zm') || n.includes('dd zm'));
|
||||||
|
};
|
||||||
|
|
||||||
|
function isSendBackOrReconsiderTimelineAction(action: string | undefined | null): boolean {
|
||||||
|
const a = norm(action);
|
||||||
|
return (
|
||||||
|
a.includes('sent back') ||
|
||||||
|
a.includes('send back') ||
|
||||||
|
a.includes('reconsider') ||
|
||||||
|
a.includes('reconsideration')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JointRoundTimelineMode = 'scn_response_eval' | 'rbm_review';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a case is sent back / reconsidered to a joint stage, earlier PARTIAL_APPROVE rows must be ignored.
|
||||||
|
* Uses workflow timeline entries (written on transition) — newest matching event wins.
|
||||||
|
*/
|
||||||
|
export function getJointRoundCutoffMsFromTimeline(
|
||||||
|
timeline: unknown,
|
||||||
|
mode: JointRoundTimelineMode
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(timeline) || timeline.length === 0) return null;
|
||||||
|
const matcher = mode === 'scn_response_eval' ? isScnResponseJointTargetStage : isRbmJointTargetStage;
|
||||||
|
const arr = timeline as any[];
|
||||||
|
for (let i = arr.length - 1; i >= 0; i--) {
|
||||||
|
const e = arr[i];
|
||||||
|
if (!isSendBackOrReconsiderTimelineAction(e?.action)) continue;
|
||||||
|
if (!matcher(e?.targetStage)) continue;
|
||||||
|
const t = e?.timestamp != null ? new Date(e.timestamp).getTime() : NaN;
|
||||||
|
if (!Number.isNaN(t)) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only audit rows created at/after send-back / reconsider to this joint stage count for the current round. */
|
||||||
|
export function buildJointRoundCreatedAtFilter(cutoffMs: number | null): { createdAt?: { [Op.gte]: Date } } {
|
||||||
|
if (cutoffMs == null) return {};
|
||||||
|
return { createdAt: { [Op.gte]: new Date(cutoffMs) } };
|
||||||
|
}
|
||||||
@ -2,9 +2,16 @@ import db from '../../database/models/index.js';
|
|||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import { sendEmail } from './email.service.js';
|
import { sendEmail } from './email.service.js';
|
||||||
import { NotificationService } from '../../services/NotificationService.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';
|
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
@ -134,7 +141,8 @@ export async function notifyRelocationSubmittedEmails(
|
|||||||
dealerName,
|
dealerName,
|
||||||
requestId: code,
|
requestId: code,
|
||||||
link: `${base}/relocation-requests/${request.id}`,
|
link: `${base}/relocation-requests/${request.id}`,
|
||||||
ctaLabel: 'View request'
|
ctaLabel: 'View request',
|
||||||
|
distance: request.distance || '0'
|
||||||
}
|
}
|
||||||
).catch((err) => console.error('[notifyRelocationSubmittedEmails] dealer:', err));
|
).catch((err) => console.error('[notifyRelocationSubmittedEmails] dealer:', err));
|
||||||
}
|
}
|
||||||
@ -142,7 +150,12 @@ export async function notifyRelocationSubmittedEmails(
|
|||||||
const outlet = await Outlet.findByPk(request.outletId, {
|
const outlet = await Outlet.findByPk(request.outletId, {
|
||||||
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
|
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;
|
if (!asmId) return;
|
||||||
|
|
||||||
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
||||||
@ -157,9 +170,10 @@ export async function notifyRelocationSubmittedEmails(
|
|||||||
placeholders: {
|
placeholders: {
|
||||||
dealerName,
|
dealerName,
|
||||||
requestId: code,
|
requestId: code,
|
||||||
outletCode: outlet?.code || '',
|
outletCode: outlet?.code || 'N/A',
|
||||||
link: `${base}/relocation-requests/${request.id}`,
|
link: `${base}/relocation-requests/${request.id}`,
|
||||||
ctaLabel: 'Review relocation',
|
ctaLabel: 'Review request',
|
||||||
|
distance: request.distance || '0',
|
||||||
phone: asmPhone || ''
|
phone: asmPhone || ''
|
||||||
}
|
}
|
||||||
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
|
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
|
||||||
@ -184,7 +198,7 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
|||||||
'ASM': [ROLES.ASM],
|
'ASM': [ROLES.ASM],
|
||||||
'ASM Review': [ROLES.ASM],
|
'ASM Review': [ROLES.ASM],
|
||||||
'RBM': [ROLES.RBM],
|
'RBM': [ROLES.RBM],
|
||||||
'RBM Review': [ROLES.RBM],
|
'RBM + DD-ZM Review': [ROLES.RBM, ROLES.DD_ZM],
|
||||||
'ZM Review': [ROLES.DD_ZM],
|
'ZM Review': [ROLES.DD_ZM],
|
||||||
'DD ZM Review': [ROLES.DD_ZM],
|
'DD ZM Review': [ROLES.DD_ZM],
|
||||||
'ZBH': [ROLES.ZBH],
|
'ZBH': [ROLES.ZBH],
|
||||||
@ -193,7 +207,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
|||||||
'DD Lead Review': [ROLES.DD_LEAD],
|
'DD Lead Review': [ROLES.DD_LEAD],
|
||||||
'DD Head': [ROLES.DD_HEAD],
|
'DD Head': [ROLES.DD_HEAD],
|
||||||
'DD Head Review': [ROLES.DD_HEAD],
|
'DD Head Review': [ROLES.DD_HEAD],
|
||||||
'DD Head Approval': [ROLES.DD_HEAD],
|
|
||||||
'NBH': [ROLES.NBH],
|
'NBH': [ROLES.NBH],
|
||||||
'NBH Approval': [ROLES.NBH],
|
'NBH Approval': [ROLES.NBH],
|
||||||
'NBH Evaluation': [ROLES.NBH],
|
'NBH Evaluation': [ROLES.NBH],
|
||||||
@ -229,7 +242,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
|||||||
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
||||||
|
|
||||||
// --- Relocation/Constitutional Specific ---
|
// --- Relocation/Constitutional Specific ---
|
||||||
'NBH Clearance with EOR': [ROLES.NBH],
|
|
||||||
'Submitted': [ROLES.ASM],
|
'Submitted': [ROLES.ASM],
|
||||||
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
|
'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],
|
'Spares Clearance': [ROLES.SPARES_MANAGER],
|
||||||
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
'Service Clearance': [ROLES.SERVICE_MANAGER],
|
||||||
'Accounts Clearance': [ROLES.ACCOUNTS_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 ---
|
// --- Termination Specific ---
|
||||||
'Show Cause Notice': [ROLES.NBH],
|
'Show Cause Notice': [ROLES.LEGAL_ADMIN],
|
||||||
'Personal Hearing': [ROLES.NBH],
|
'Personal Hearing': [ROLES.NBH, ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD],
|
||||||
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN]
|
'Legal - Termination Letter': [ROLES.LEGAL_ADMIN]
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -304,6 +318,7 @@ export async function notifyStakeholdersOnTransition(
|
|||||||
action: string;
|
action: string;
|
||||||
remarks: string;
|
remarks: string;
|
||||||
link: string;
|
link: string;
|
||||||
|
changeType?: string;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -329,8 +344,12 @@ export async function notifyStakeholdersOnTransition(
|
|||||||
const isDealer = u.id === metadata.dealerId;
|
const isDealer = u.id === metadata.dealerId;
|
||||||
const isActingUser = u.fullName === metadata.actionUserFullName;
|
const isActingUser = u.fullName === metadata.actionUserFullName;
|
||||||
|
|
||||||
// Roles that should receive observer alerts on terminal events
|
// 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'].includes(u.roleCode || '');
|
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';
|
const isASM = (u.roleCode || '').toUpperCase() === 'ASM';
|
||||||
|
|
||||||
// Phone for WhatsApp — directly on include'd user object
|
// Phone for WhatsApp — directly on include'd user object
|
||||||
@ -376,39 +395,67 @@ export async function notifyStakeholdersOnTransition(
|
|||||||
|
|
||||||
} else if (isDealer) {
|
} else if (isDealer) {
|
||||||
// ── Dealer: in-app always; email + WhatsApp only on terminal events ──
|
// ── 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'];
|
const terminalChannels: Array<'system' | 'email' | 'whatsapp'> = ['system', 'email'];
|
||||||
if (phone) terminalChannels.push('whatsapp');
|
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, {
|
await NotificationService.notify(u.id, u.email, {
|
||||||
title: isTerminalEvent
|
title: isTerminalEvent
|
||||||
? `Application ${isRejected ? 'Rejected' : isRevoked ? 'Revoked' : 'Completed'}: ${metadata.code}`
|
? `Application ${isRejected ? 'Rejected' : isRevoked ? 'Revoked' : 'Completed'}: ${metadata.code}`
|
||||||
: `Application Update: ${metadata.code}`,
|
: `Application Update: ${metadata.code}`,
|
||||||
message: `Your request is now at "${targetStage}". ${metadata.action}`,
|
message: `Your request is now at "${targetStage}". ${metadata.action}`,
|
||||||
channels: isTerminalEvent ? terminalChannels : ['system'],
|
channels: isTerminalEvent ? terminalChannels : ['system'],
|
||||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
templateCode,
|
||||||
placeholders: {
|
placeholders
|
||||||
requestId: metadata.code,
|
|
||||||
link: metadata.link,
|
|
||||||
targetStage,
|
|
||||||
dealerName: metadata.dealerName,
|
|
||||||
phone: phone || ''
|
|
||||||
}
|
|
||||||
}).catch(e => console.error('[notifyStakeholders] dealer:', e));
|
}).catch(e => console.error('[notifyStakeholders] dealer:', e));
|
||||||
|
|
||||||
} else if (isTerminalEvent && isKeyObserverRole) {
|
} 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, {
|
await NotificationService.notify(u.id, u.email, {
|
||||||
title: `Case Closed: ${metadata.code}`,
|
title: `Case Closed: ${metadata.code}`,
|
||||||
message: `${metadata.code} has been ${isRejected ? 'rejected' : isRevoked ? 'revoked' : 'completed'} at stage "${targetStage}".`,
|
message: `${metadata.code} has been ${isRejected ? 'rejected' : isRevoked ? 'revoked' : 'completed'} at stage "${targetStage}".`,
|
||||||
channels: ['system'],
|
channels: ['system', 'email'], // Internal teams get email too on closure
|
||||||
templateCode: 'WORKFLOW_STATUS_UPDATE_DEALER',
|
templateCode,
|
||||||
placeholders: {
|
placeholders
|
||||||
requestId: metadata.code,
|
|
||||||
link: metadata.link,
|
|
||||||
targetStage
|
|
||||||
}
|
|
||||||
}).catch(e => console.error('[notifyStakeholders] observer:', e));
|
}).catch(e => console.error('[notifyStakeholders] observer:', e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,35 @@
|
|||||||
*/
|
*/
|
||||||
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
||||||
'APPLICANT_SHORTLISTED',
|
'APPLICANT_SHORTLISTED',
|
||||||
|
'APPLICANT_REJECTED',
|
||||||
'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
||||||
|
'CONSTITUTIONAL_CHANGE_APPROVED',
|
||||||
'CONSTITUTIONAL_CHANGE_UPDATE',
|
'CONSTITUTIONAL_CHANGE_UPDATE',
|
||||||
'DEALER_CODE_READY',
|
'DEALER_CODE_READY',
|
||||||
|
'EOR_COMPLETED',
|
||||||
|
'FNF_INITIATED',
|
||||||
|
'FNF_SUMMARY_PREPARED',
|
||||||
|
'FNF_SETTLEMENT_APPROVED',
|
||||||
'GENERIC_NOTIFICATION',
|
'GENERIC_NOTIFICATION',
|
||||||
|
'INAUGURATION_COMPLETED',
|
||||||
'INTERVIEW_SCHEDULED',
|
'INTERVIEW_SCHEDULED',
|
||||||
|
'INTERVIEW_SCHEDULED_APPLICANT',
|
||||||
|
'INTERVIEW_SCHEDULED_PANELIST',
|
||||||
|
'INTERVIEW_RESCHEDULED_APPLICANT',
|
||||||
|
'INTERVIEW_RESCHEDULED_PANELIST',
|
||||||
|
'INTERVIEW_CANCELLED_APPLICANT',
|
||||||
|
'INTERVIEW_CANCELLED_PANELIST',
|
||||||
'LOA_ISSUED',
|
'LOA_ISSUED',
|
||||||
'LOI_ISSUED',
|
'LOI_ISSUED',
|
||||||
'NON_OPPORTUNITY',
|
'NON_OPPORTUNITY',
|
||||||
|
'ONBOARDING_PAYMENT_VERIFIED',
|
||||||
'ONBOARDING_STATUS_UPDATE',
|
'ONBOARDING_STATUS_UPDATE',
|
||||||
'OPPORTUNITY',
|
'OPPORTUNITY',
|
||||||
'QUESTIONNAIRE_REMINDER',
|
'QUESTIONNAIRE_REMINDER',
|
||||||
'QUESTIONNAIRE_SUBMITTED',
|
'QUESTIONNAIRE_SUBMITTED',
|
||||||
'RELOCATION_RECEIVED',
|
'RELOCATION_RECEIVED',
|
||||||
'RELOCATION_SUBMITTED',
|
'RELOCATION_SUBMITTED',
|
||||||
|
'RELOCATION_APPROVED',
|
||||||
'RELOCATION_UPDATE',
|
'RELOCATION_UPDATE',
|
||||||
'RESIGNATION_APPROVED',
|
'RESIGNATION_APPROVED',
|
||||||
'RESIGNATION_RECEIVED',
|
'RESIGNATION_RECEIVED',
|
||||||
@ -26,10 +41,15 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
|||||||
'SLA_REMINDER',
|
'SLA_REMINDER',
|
||||||
'SLA_BREACH',
|
'SLA_BREACH',
|
||||||
'SLA_ESCALATION',
|
'SLA_ESCALATION',
|
||||||
|
'TERMINATION_INITIATED',
|
||||||
'TERMINATION_SCN_ISSUED',
|
'TERMINATION_SCN_ISSUED',
|
||||||
|
'TERMINATION_LETTER_ISSUED',
|
||||||
|
'TERMINATION_FINAL_CLOSURE_DEALER',
|
||||||
'TERMINATION_UPDATE',
|
'TERMINATION_UPDATE',
|
||||||
'USER_ASSIGNED',
|
'USER_ASSIGNED',
|
||||||
'WORKNOTE_NOTIFICATION'
|
'WORKNOTE_NOTIFICATION',
|
||||||
|
'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
'WORKFLOW_STATUS_UPDATE_DEALER',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];
|
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];
|
||||||
|
|||||||
@ -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';
|
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
|
||||||
|
|
||||||
export interface ConstitutionalChangeAttributes {
|
export interface ConstitutionalChangeAttributes {
|
||||||
@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => {
|
|||||||
{ fields: ['requestId'] },
|
{ fields: ['requestId'] },
|
||||||
{ fields: ['outletId'] },
|
{ fields: ['outletId'] },
|
||||||
{ fields: ['dealerId'] },
|
{ fields: ['dealerId'] },
|
||||||
{ fields: ['currentStage'] }
|
{ fields: ['currentStage'] },
|
||||||
|
/** SRS §12.2 — at most one non-terminal request per dealer (PostgreSQL partial unique index). */
|
||||||
|
{
|
||||||
|
name: 'uq_constitutional_open_per_dealer',
|
||||||
|
unique: true,
|
||||||
|
fields: ['dealerId'],
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
[Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
34
src/emailtemplates/applicant_rejected.html
Normal file
34
src/emailtemplates/applicant_rejected.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{{> email_header}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Application Rejected — {{applicationId}}</h2>
|
||||||
|
<p>Dear {{applicantName}},</p>
|
||||||
|
<p>
|
||||||
|
We regret to inform you that your Royal Enfield Dealership Application
|
||||||
|
(<strong>{{applicationId}}</strong>) for location <strong>{{location}}</strong>
|
||||||
|
has been <strong>rejected</strong> after careful evaluation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{#if rejectionReason}}
|
||||||
|
<div class="highlight-box" style="background:#fff3f3; border-left:4px solid #e53935;">
|
||||||
|
<strong>Reason for Rejection:</strong><br />
|
||||||
|
{{rejectionReason}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We appreciate your interest in partnering with Royal Enfield.
|
||||||
|
You may reapply in the future when opportunities are available in your area.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
For any queries, please contact your local RE representative or reach us at
|
||||||
|
<a href="mailto:dealer-support@royalenfield.com">dealer-support@royalenfield.com</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>We wish you all the best in your endeavours.</p>
|
||||||
|
|
||||||
|
<p>Best Regards,<br /><strong>Royal Enfield Dealer Development Team</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> email_footer}}
|
||||||
31
src/emailtemplates/constitutional_change_approved.html
Normal file
31
src/emailtemplates/constitutional_change_approved.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{{> email_header}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 style="color:#2e7d32;">Constitutional Change Approved — {{requestId}}</h2>
|
||||||
|
<p>Dear {{dealerName}},</p>
|
||||||
|
<p>
|
||||||
|
We are pleased to inform you that your request for a **Change in Constitution** (Request ID: <strong>{{requestId}}</strong>)
|
||||||
|
has been officially approved by the Royal Enfield management.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
|
||||||
|
<strong>New Constitution:</strong> {{proposedConstitution}}<br/>
|
||||||
|
<strong>Status:</strong> Approved & Updated
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The system records have been updated to reflect this change. You can now proceed with the legally compliant transition
|
||||||
|
as per the approved structure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin: 24px 0;">
|
||||||
|
<a href="{{link}}" class="btn">View Request Details</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:#888; font-size:12px;">
|
||||||
|
Best Regards,<br/>
|
||||||
|
<strong>Royal Enfield Dealer Development Team</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> email_footer}}
|
||||||
38
src/emailtemplates/eor_completed.html
Normal file
38
src/emailtemplates/eor_completed.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{{> email_header}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>EOR Checklist Complete — {{requestId}}</h2>
|
||||||
|
<p>Dear {{recipientName}},</p>
|
||||||
|
<p>
|
||||||
|
All <strong>Essential Operating Requirements (EOR)</strong> for dealer application
|
||||||
|
<strong>{{requestId}}</strong> (Applicant: <strong>{{applicantName}}</strong>) have been
|
||||||
|
marked as completed and verified.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="highlight-box" style="background:#e8f5e9; border-left:4px solid #43a047;">
|
||||||
|
<strong>EOR Status: 100% Complete</strong><br />
|
||||||
|
The dealership outlet is now ready for Inauguration review.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="detail-table">
|
||||||
|
<tr><td><strong>Application ID</strong></td><td>{{requestId}}</td></tr>
|
||||||
|
<tr><td><strong>Applicant Name</strong></td><td>{{applicantName}}</td></tr>
|
||||||
|
<tr><td><strong>Location</strong></td><td>{{location}}</td></tr>
|
||||||
|
<tr><td><strong>Completed On</strong></td><td>{{completedOn}}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please review the EOR checklist and, if all criteria are met, authorize the
|
||||||
|
<strong>Inauguration</strong> stage to mark this dealership as live.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin: 24px 0;">
|
||||||
|
{{> cta_button}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:#888; font-size:12px;">
|
||||||
|
This is an automated alert from the Royal Enfield Dealer Onboarding System.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> email_footer}}
|
||||||
35
src/emailtemplates/fnf_initiated.html
Normal file
35
src/emailtemplates/fnf_initiated.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{{> email_header}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Full & Final Settlement Initiated — {{requestId}}</h2>
|
||||||
|
<p>Dear {{recipientName}},</p>
|
||||||
|
<p>
|
||||||
|
The Full & Final (F&F) settlement process has been initiated for dealer
|
||||||
|
<strong>{{dealerName}}</strong> (Request: <strong>{{requestId}}</strong>)
|
||||||
|
effective from the Last Working Day.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="detail-table">
|
||||||
|
<tr><td><strong>Request ID</strong></td><td>{{requestId}}</td></tr>
|
||||||
|
<tr><td><strong>Dealer Name</strong></td><td>{{dealerName}}</td></tr>
|
||||||
|
<tr><td><strong>Initiated By</strong></td><td>{{initiatedBy}}</td></tr>
|
||||||
|
<tr><td><strong>Last Working Day</strong></td><td>{{lwd}}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
All department clearances (NOC, Payables, Receivables, etc.) must be submitted
|
||||||
|
within the stipulated timeline. Please log in and update your department's
|
||||||
|
clearance status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin: 24px 0;">
|
||||||
|
{{> cta_button}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:#888; font-size:12px;">
|
||||||
|
This is a system-generated notification. F&F settlement can only be
|
||||||
|
initiated on or after the Last Working Day as per Royal Enfield policy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> email_footer}}
|
||||||
29
src/emailtemplates/fnf_settlement_approved.html
Normal file
29
src/emailtemplates/fnf_settlement_approved.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{{> email_header}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 style="color:#2e7d32;">F&F Settlement Approved — {{fnfId}}</h2>
|
||||||
|
<p>Dear Team,</p>
|
||||||
|
<p>
|
||||||
|
The final Full & Final (F&F) settlement for dealer <strong>{{dealerName}}</strong>
|
||||||
|
(F&F ID: <strong>{{fnfId}}</strong>) has been <strong>Approved</strong> by Finance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background:#e8f5e9; border-left:4px solid #2e7d32; padding:12px 16px; margin: 16px 0;">
|
||||||
|
<strong>Settlement Amount:</strong> ₹{{settlementAmount}}<br/>
|
||||||
|
<strong>Status:</strong> Approved & Closed
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The DD-Admin and Legal teams are requested to update their records and proceed with final account closure.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin: 24px 0;">
|
||||||
|
{{> cta_button}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:#888; font-size:12px;">
|
||||||
|
All financial transactions related to this dealership exit are now finalized.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> email_footer}}
|
||||||
29
src/emailtemplates/fnf_summary_prepared.html
Normal file
29
src/emailtemplates/fnf_summary_prepared.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{{> email_header}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>F&F Settlement Summary Prepared — {{fnfId}}</h2>
|
||||||
|
<p>Dear Finance Team,</p>
|
||||||
|
<p>
|
||||||
|
The initial Full & Final (F&F) settlement summary has been prepared for
|
||||||
|
<strong>{{dealerName}}</strong> (F&F ID: <strong>{{fnfId}}</strong>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background:#f5f5f5; border-left:4px solid #333; padding:12px 16px; margin: 16px 0;">
|
||||||
|
<strong>Calculated Net Amount:</strong> ₹{{netAmount}}<br/>
|
||||||
|
<strong>Status:</strong> Pending Final Approval
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please review the consolidated departmental responses and the settlement summary to proceed with final approval and payment processing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="text-align:center; margin: 24px 0;">
|
||||||
|
{{> cta_button}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color:#888; font-size:12px;">
|
||||||
|
Confidential: This summary contains sensitive financial data. Review only via authorized portal access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> email_footer}}
|
||||||
22
src/emailtemplates/inauguration_completed.html
Normal file
22
src/emailtemplates/inauguration_completed.html
Normal 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}}
|
||||||
29
src/emailtemplates/onboarding_payment_verified.html
Normal file
29
src/emailtemplates/onboarding_payment_verified.html
Normal 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}}
|
||||||
31
src/emailtemplates/relocation_approved.html
Normal file
31
src/emailtemplates/relocation_approved.html
Normal 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}}
|
||||||
@ -1,6 +1,33 @@
|
|||||||
{{> email_header}}
|
{{> email_header}}
|
||||||
<h2>Hi {{dealerName}},</h2>
|
|
||||||
<p>Your outlet relocation request <strong>{{requestId}}</strong> has been received.</p>
|
<div class="section">
|
||||||
<p>You will receive email updates as the request moves through approvals.</p>
|
<h2>Relocation Request Received — {{requestId}}</h2>
|
||||||
{{> primary_cta}}
|
<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}}
|
{{> email_footer}}
|
||||||
|
|||||||
@ -1,7 +1,31 @@
|
|||||||
{{> email_header}}
|
{{> email_header}}
|
||||||
<h2>New relocation request</h2>
|
|
||||||
<p>A dealer has submitted an outlet relocation request.</p>
|
<div class="section">
|
||||||
<p><strong>Request ID:</strong> {{requestId}}<br><strong>Outlet:</strong> {{outletCode}}</p>
|
<h2 style="color:#e31837;">New Relocation Request: {{requestId}}</h2>
|
||||||
<p>Please review in the Dealer Development portal.</p>
|
<p>Dear Team,</p>
|
||||||
{{> primary_cta}}
|
<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}}
|
{{> email_footer}}
|
||||||
|
|||||||
30
src/emailtemplates/termination_final_closure.html
Normal file
30
src/emailtemplates/termination_final_closure.html
Normal 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}}
|
||||||
41
src/emailtemplates/termination_initiated.html
Normal file
41
src/emailtemplates/termination_initiated.html
Normal 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}}
|
||||||
25
src/emailtemplates/termination_letter_issued.html
Normal file
25
src/emailtemplates/termination_letter_issued.html
Normal 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}}
|
||||||
30
src/emailtemplates/workflow_action_required.html
Normal file
30
src/emailtemplates/workflow_action_required.html
Normal 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}}
|
||||||
28
src/emailtemplates/workflow_status_update_dealer.html
Normal file
28
src/emailtemplates/workflow_status_update_dealer.html
Normal 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}}
|
||||||
@ -20,6 +20,18 @@ const getLocationAncestors = async (locationId: string): Promise<string[]> => {
|
|||||||
|
|
||||||
const interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`;
|
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) =>
|
const normalizeRoleCode = (value: unknown) =>
|
||||||
String(value || '')
|
String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@ -567,8 +579,9 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
applicantName: application.applicantName,
|
applicantName: application.applicantName,
|
||||||
applicationId: application.applicationId,
|
applicationId: application.applicationId,
|
||||||
type,
|
type,
|
||||||
scheduledAt: scheduledAtIso,
|
scheduledAt: formatIST(scheduledDateObj),
|
||||||
link: meetLink,
|
meetLink,
|
||||||
|
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
|
||||||
phone: applicantPhone,
|
phone: applicantPhone,
|
||||||
ctaLabel: 'View Schedule'
|
ctaLabel: 'View Schedule'
|
||||||
}
|
}
|
||||||
@ -682,8 +695,9 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
applicantName: application?.applicantName || 'Applicant',
|
applicantName: application?.applicantName || 'Applicant',
|
||||||
applicationId: application?.applicationId || '',
|
applicationId: application?.applicationId || '',
|
||||||
type,
|
type,
|
||||||
scheduledAt: scheduledAtIso,
|
scheduledAt: formatIST(scheduledDateObj),
|
||||||
link: meetLink,
|
meetLink,
|
||||||
|
appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/onboarding/application/${application.id}`,
|
||||||
phone: pPhone || '',
|
phone: pPhone || '',
|
||||||
ctaLabel: 'Open Assessment'
|
ctaLabel: 'Open Assessment'
|
||||||
}
|
}
|
||||||
@ -749,11 +763,81 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await interview.update(updatePayload);
|
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 isCancelled = String(status || '').toLowerCase() === 'cancelled' && String(oldStatus || '').toLowerCase() !== 'cancelled';
|
||||||
const isRescheduled = typeof scheduledAt !== 'undefined' && String(status || '').toLowerCase() !== 'cancelled';
|
const isRescheduled = typeof scheduledAt !== 'undefined' && String(status || '').toLowerCase() !== 'cancelled';
|
||||||
const eventType = isCancelled ? 'interview_cancelled' : (isRescheduled ? 'interview_rescheduled' : 'interview_updated');
|
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({
|
await db.AuditLog.create({
|
||||||
userId: req.user?.id || null,
|
userId: req.user?.id || null,
|
||||||
action: AUDIT_ACTIONS.INTERVIEW_UPDATED,
|
action: AUDIT_ACTIONS.INTERVIEW_UPDATED,
|
||||||
|
|||||||
@ -208,9 +208,11 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s
|
|||||||
(payload?.context as any)?.currentStage ||
|
(payload?.context as any)?.currentStage ||
|
||||||
null,
|
null,
|
||||||
actor: {
|
actor: {
|
||||||
|
id: logData.userId || logData.actorId || logData.user?.id || null,
|
||||||
name: actorName,
|
name: actorName,
|
||||||
email: logData.user?.email || logData.userEmail || null
|
email: logData.user?.email || logData.userEmail || null
|
||||||
},
|
},
|
||||||
|
actorId: logData.userId || logData.actorId || logData.user?.id || null,
|
||||||
userName: actorName,
|
userName: actorName,
|
||||||
userEmail: logData.user?.email || logData.userEmail || null,
|
userEmail: logData.user?.email || logData.userEmail || null,
|
||||||
remarks: logData.remarks || payload?.remarks || '',
|
remarks: logData.remarks || payload?.remarks || '',
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { Op } from 'sequelize';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db;
|
const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
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. */
|
/** Default EOR rows for relocation (SRS 12.2.8) — must stay aligned with relocation required-doc labels. */
|
||||||
export const RELOCATION_EOR_DEFAULT_ITEMS = [
|
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');
|
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
|
||||||
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
|
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
|
||||||
await updateApplicationProgress(checklist.applicationId, 'Inauguration', 'active', 50);
|
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) {
|
} else if (checklist.relocationId) {
|
||||||
await db.RelocationRequest.update({
|
await db.RelocationRequest.update({
|
||||||
status: 'Completed',
|
status: 'Completed',
|
||||||
@ -345,7 +368,22 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
|
|||||||
currentStage: 'Completed'
|
currentStage: 'Completed'
|
||||||
}, { where: { id: checklist.relocationId } });
|
}, { 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { Op } from 'sequelize';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { FddAssignment, FddReport, Application } = db;
|
const { FddAssignment, FddReport, Application } = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
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 { WorkflowService } from '../../services/WorkflowService.js';
|
||||||
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||||
|
import { NotificationService } from '../../services/NotificationService.js';
|
||||||
|
|
||||||
export const getAssignment = async (req: Request, res: Response) => {
|
export const getAssignment = async (req: Request, res: Response) => {
|
||||||
try {
|
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 });
|
res.status(201).json({ success: true, message: 'FDD Agency assigned', data: assignment });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Assign FDD agency error:', 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.',
|
reason: 'FDD Report uploaded. Pending review to proceed to LOI stage.',
|
||||||
forceLog: true // Log even if status is same
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { Request, Response } from 'express';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
|
const { LoaRequest, LoaApproval, LoaDocumentGenerated, SecurityDeposit, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
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 { WorkflowService } from '../../services/WorkflowService.js';
|
||||||
|
import { NotificationService } from '../../services/NotificationService.js';
|
||||||
|
|
||||||
const LOA_STAGE_CODE = 'LOA_APPROVAL';
|
const LOA_STAGE_CODE = 'LOA_APPROVAL';
|
||||||
|
|
||||||
@ -86,6 +87,25 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
progressPercentage: 92
|
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 });
|
res.status(201).json({ success: true, message: 'LOA Request initiated with DD Head approval', data: request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create LOA request error:', error);
|
console.error('Create LOA request error:', error);
|
||||||
@ -207,6 +227,28 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
progressPercentage: 97
|
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' });
|
res.json({ success: true, message: 'LOA fully approved and issued' });
|
||||||
} else {
|
} else {
|
||||||
// SEQUENTIAL APPROVAL BRIDGE:
|
// 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}`);
|
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({
|
res.json({
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { Request, Response } from 'express';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
|
const { LoiRequest, LoiApproval, LoiDocumentGenerated, LoiAcknowledgement, AuditLog, StageApprovalPolicy, StageApprovalAction, User } = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
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 { 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';
|
const LOI_STAGE_CODE = 'LOI_APPROVAL';
|
||||||
|
|
||||||
@ -125,6 +127,25 @@ export const createRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
progressPercentage: 75
|
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 });
|
res.status(201).json({ success: true, message: 'LOI Request initiated for DD Head approval', data: request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create LOI request error:', 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' });
|
res.json({ success: true, message: 'LOI Request fully approved and document generated' });
|
||||||
} else {
|
} 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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Approval recorded. Waiting for remaining required approvers.',
|
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.',
|
reason: 'LOI Document issued. Proceeding to Dealer Code Generation.',
|
||||||
progressPercentage: 85
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityServic
|
|||||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||||
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||||
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
|
import { isAutoAssignmentEnabled } from '../../services/AutoAssignmentConfigService.js';
|
||||||
|
import { NotificationService } from '../../services/NotificationService.js';
|
||||||
|
|
||||||
const { DocumentStageConfig } = db;
|
const { DocumentStageConfig } = db;
|
||||||
|
|
||||||
@ -195,6 +196,27 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
|
|||||||
entityId: application.id
|
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({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Application submitted successfully',
|
message: 'Application submitted successfully',
|
||||||
@ -256,17 +278,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
// Apply Filters
|
// Apply Filters
|
||||||
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
|
const { fromDate, toDate, search, status, location, state, isShortlisted, ddLeadShortlisted, assignedTo } = req.query;
|
||||||
|
|
||||||
|
// 1. Date Filters (createdAt range)
|
||||||
if (fromDate || toDate) {
|
if (fromDate || toDate) {
|
||||||
whereClause.createdAt = {};
|
const dateClause: any = {};
|
||||||
if (fromDate) {
|
if (fromDate && fromDate !== 'undefined') {
|
||||||
const start = new Date(fromDate as string);
|
const start = new Date(fromDate as string);
|
||||||
|
if (!isNaN(start.getTime())) {
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
whereClause.createdAt[Op.gte] = start;
|
dateClause[Op.gte] = start;
|
||||||
}
|
}
|
||||||
if (toDate) {
|
}
|
||||||
|
if (toDate && toDate !== 'undefined') {
|
||||||
const end = new Date(toDate as string);
|
const end = new Date(toDate as string);
|
||||||
|
if (!isNaN(end.getTime())) {
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
whereClause.createdAt[Op.lte] = end;
|
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
|
// Pipeline Logic - Forced strict filtering by lifecycle stage
|
||||||
|
// 3. Status Grouping Logic (Prospects vs Leads vs Workflow)
|
||||||
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
|
const isShortlistedStr = String(isShortlisted ?? '').toLowerCase();
|
||||||
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
|
const ddLeadShortlistedStr = String(ddLeadShortlisted ?? '').toLowerCase();
|
||||||
|
|
||||||
|
// Use a conditions array to prevent Op.or overwrites
|
||||||
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (isShortlistedStr === 'false') {
|
if (isShortlistedStr === 'false') {
|
||||||
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
|
// Non-Opportunities (New Leads) MUST be 'Submitted', NOT shortlisted, and NOT linked to an opportunity
|
||||||
whereClause.overallStatus = 'Submitted';
|
whereClause.overallStatus = 'Submitted';
|
||||||
@ -302,10 +336,12 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
whereClause.opportunityId = null; // Strictly lead-gen records only
|
whereClause.opportunityId = null; // Strictly lead-gen records only
|
||||||
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
||||||
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
|
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
|
||||||
whereClause[Op.or] = [
|
conditions.push({
|
||||||
|
[Op.or]: [
|
||||||
{ isShortlisted: true },
|
{ isShortlisted: true },
|
||||||
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
|
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
|
||||||
];
|
]
|
||||||
|
});
|
||||||
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
|
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
|
||||||
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||||
|
|
||||||
@ -323,6 +359,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
applyStatusFilter(status);
|
applyStatusFilter(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
whereClause[Op.and] = [...(whereClause[Op.and] || []), ...conditions];
|
||||||
|
}
|
||||||
|
|
||||||
if (location && location !== 'all') {
|
if (location && location !== 'all') {
|
||||||
whereClause.preferredLocation = location;
|
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' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
|
assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes,
|
||||||
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
retriggerEvaluators, getDocumentConfigs, getDocumentConfigMetadata,
|
||||||
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
createDocumentConfig, updateDocumentConfig, deleteDocumentConfig, updateApplication,
|
||||||
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity, sendBulkReminders
|
||||||
} from './onboarding.controller.js';
|
} from './onboarding.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
import { checkRevocation } from '../../common/middleware/checkRevocation.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/metadata', getDocumentConfigMetadata);
|
||||||
router.get('/document-configs', getDocumentConfigs);
|
router.get('/document-configs', getDocumentConfigs);
|
||||||
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
|
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.get('/applications/:id', checkRevocation as any, getApplicationById);
|
||||||
router.put('/applications/:id', checkRevocation as any, updateApplication);
|
router.put('/applications/:id', checkRevocation as any, updateApplication);
|
||||||
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
|
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow
|
|||||||
const STRUCTURE_TARGET_VALUES = new Set<string>(
|
const STRUCTURE_TARGET_VALUES = new Set<string>(
|
||||||
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as 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 resolveConstitutionalUuid = async (id: string) => {
|
||||||
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional');
|
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 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 = {
|
const metadata = {
|
||||||
newPartnersDetails,
|
newPartnersDetails,
|
||||||
shareholdingPattern,
|
shareholdingPattern,
|
||||||
@ -215,6 +240,15 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
await ParticipantService.assignConstitutionalParticipants(request.id);
|
await ParticipantService.assignConstitutionalParticipants(request.id);
|
||||||
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
|
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
|
||||||
@ -222,12 +256,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error assigning participants or sending constitutional submit emails:', 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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit constitutional change error:', error);
|
console.error('Submit constitutional change error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error submitting request' });
|
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 (!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 });
|
res.json({ success: true, request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -423,6 +455,9 @@ const actionSuccessMessage = (raw: string): string => {
|
|||||||
export const takeAction = async (req: AuthRequest, res: Response) => {
|
export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
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 { id } = req.params;
|
||||||
const rawAction = String(req.body.action || '').trim();
|
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 isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK);
|
||||||
const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE);
|
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) {
|
if (isReject) {
|
||||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
|
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.' });
|
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;
|
const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW;
|
||||||
if (isZmRbmJointStage && isApprove) {
|
if (isZmRbmJointStage && isApprove) {
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Response } from 'express';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
|
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.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 { 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 { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.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 });
|
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 6: DD Head (national)
|
// Stage 6: NBH Approval (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)
|
|
||||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||||
if (nbh) {
|
if (nbh) {
|
||||||
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
|
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' } });
|
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
|
||||||
if (legal) {
|
if (legal) {
|
||||||
evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
|
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`);
|
console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`);
|
||||||
|
|
||||||
const evaluatorInfo = evaluators.map(e => ({
|
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}`
|
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;
|
const roleCode = req.user.roleCode as string;
|
||||||
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) {
|
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({
|
const openExisting = await RelocationRequest.findOne({
|
||||||
where: {
|
where: {
|
||||||
outletId,
|
outletId,
|
||||||
status: { [Op.notIn]: ['Completed', 'Rejected'] }
|
status: { [Op.notIn]: ['Completed', 'Rejected', 'Revoked'] }
|
||||||
},
|
},
|
||||||
attributes: ['id', 'requestId', 'status', 'currentStage']
|
attributes: ['id', 'requestId', 'status', 'currentStage']
|
||||||
});
|
});
|
||||||
@ -441,7 +445,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
attributes: ['fullName']
|
attributes: ['fullName'],
|
||||||
|
include: [
|
||||||
|
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
@ -483,12 +490,14 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
return true;
|
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 =
|
const isAssigned =
|
||||||
district.asmId === userId || // ASM
|
outletAsmId === userId ||
|
||||||
region?.rbmId === userId || // RBM
|
district.asmId === userId ||
|
||||||
district.zmId === userId || // DD-ZM
|
region?.rbmId === userId ||
|
||||||
zone?.zbhId === userId; // ZBH
|
district.zmId === userId ||
|
||||||
|
zone?.zbhId === userId;
|
||||||
|
|
||||||
return isAssigned;
|
return isAssigned;
|
||||||
});
|
});
|
||||||
@ -553,7 +562,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
attributes: ['fullName', 'email']
|
attributes: ['fullName', 'email'],
|
||||||
|
include: [
|
||||||
|
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Worknote,
|
model: Worknote,
|
||||||
@ -575,8 +587,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
|||||||
const region = district.region;
|
const region = district.region;
|
||||||
const zone = district.zone;
|
const zone = district.zone;
|
||||||
|
|
||||||
|
const outletLevelAsmId = (request as any).dealer?.dealerProfile?.asmId ?? null;
|
||||||
|
const asmReviewerId = outletLevelAsmId || district.asmId;
|
||||||
const evaluatorRoles: any[] = [
|
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: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW },
|
||||||
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
|
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
|
||||||
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_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 });
|
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
|
// Get NBH (national) - Approval Stage
|
||||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||||
if (nbh) {
|
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' } });
|
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 });
|
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
|
// Fetch user details for each evaluator
|
||||||
for (const evaluator of evaluatorRoles) {
|
for (const evaluator of evaluatorRoles) {
|
||||||
if (evaluator.id) {
|
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.RBM_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_LEAD_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.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
|
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR,
|
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED
|
||||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const reverseStageFlow: Record<string, string> = {
|
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.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_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_LEAD_REVIEW,
|
||||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
[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). */
|
/** 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.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.NBH_APPROVAL,
|
RELOCATION_STAGES.NBH_APPROVAL,
|
||||||
RELOCATION_STAGES.LEGAL_CLEARANCE,
|
RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||||
RELOCATION_STAGES.NBH_CLEARANCE_EOR
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */
|
/** ~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
|
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
|
// 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
|
||||||
const shouldWriteWorknote =
|
const shouldWriteWorknote =
|
||||||
Boolean(String(reviewComments).trim()) &&
|
Boolean(String(reviewComments).trim()) &&
|
||||||
|
|||||||
@ -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 db from '../../database/models/index.js';
|
||||||
import logger from '../../common/utils/logger.js';
|
import logger from '../../common/utils/logger.js';
|
||||||
import {
|
import {
|
||||||
@ -17,11 +17,12 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js';
|
|||||||
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
|
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||||
import { ParticipantService } from '../../services/ParticipantService.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 { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||||
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||||
import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js';
|
import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js';
|
||||||
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
||||||
|
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||||
|
|
||||||
// Removed generateResignationId and moved to NomenclatureService
|
// Removed generateResignationId and moved to NomenclatureService
|
||||||
const resolveResignationUuid = async (id: string) => {
|
const resolveResignationUuid = async (id: string) => {
|
||||||
@ -35,16 +36,32 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
if (!req.user) throw new Error('Unauthorized');
|
||||||
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
|
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 } });
|
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) {
|
if (!outlet) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
|
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingResignation = await db.Resignation.findOne({
|
const existingResignation = await db.Resignation.findOne({
|
||||||
where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected'] } }
|
where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected', 'Withdrawn', 'Revoked'] } }
|
||||||
});
|
});
|
||||||
if (existingResignation) {
|
if (existingResignation) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -74,10 +91,11 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
documents: [],
|
documents: [],
|
||||||
departmentalClearances: initialClearances,
|
departmentalClearances: initialClearances,
|
||||||
timeline: [{
|
timeline: [{
|
||||||
stage: 'Submitted',
|
stage: 'Request Submitted',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
action: 'Resignation request submitted'
|
action: isInternalInitiator ? 'Resignation initiated by ASM' : 'Resignation request submitted by dealer',
|
||||||
|
remarks: reason || ''
|
||||||
}]
|
}]
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
@ -86,7 +104,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
action: AUDIT_ACTIONS.CREATED,
|
action: AUDIT_ACTIONS.CREATED,
|
||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
remarks: 'Dealer submitted resignation request'
|
remarks: isInternalInitiator ? 'ASM initiated resignation request' : 'Dealer submitted resignation request'
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -308,6 +326,30 @@ export const uploadResignationDocument = async (req: AuthRequest, res: Response,
|
|||||||
await resignation.update({ timeline }, { transaction });
|
await resignation.update({ timeline }, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
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 });
|
res.status(201).json({ success: true, message: 'Document uploaded successfully', document });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
@ -353,11 +395,9 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
|
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
|
||||||
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.DD_LEAD,
|
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.DD_LEAD,
|
||||||
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH,
|
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH,
|
||||||
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN,
|
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.LEGAL,
|
||||||
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
|
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN,
|
||||||
// Legal approval should complete only the Legal stage.
|
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.FNF_INITIATED, // DD Admin approval moves to F&F initiation
|
||||||
// F&F initiation is explicitly triggered via `pushfnf` action (with LWD/force gates).
|
|
||||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.LEGAL,
|
|
||||||
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
|
[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' });
|
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) {
|
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||||
const { force } = req.body;
|
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();
|
await transaction.rollback();
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
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
|
canForce: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
shouldTriggerFnF = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequence guard: resignation can be marked completed only after F&F settlement is complete.
|
// 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;
|
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
|
// Transition via Workflow Service
|
||||||
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
|
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
|
||||||
remarks,
|
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));
|
.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 });
|
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
|
||||||
let fnfId = existingFnF?.id;
|
|
||||||
|
|
||||||
if (!existingFnF) {
|
if (!existingFnF) {
|
||||||
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
const fnf = await ResignationWorkflowService.initiateFnF(resignation, req.user.id, transaction);
|
||||||
|
// Assign/sync Participants for F&F (Sub-application chat) to ensure robustness
|
||||||
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
|
await ParticipantService.assignFnFParticipants(fnf.id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -977,14 +1057,15 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
const hasLegalStageDocument = await db.ResignationDocument.findOne({
|
const hasLegalStageDocument = await db.ResignationDocument.findOne({
|
||||||
where: {
|
where: {
|
||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
stage: RESIGNATION_STAGES.LEGAL
|
stage: RESIGNATION_STAGES.LEGAL,
|
||||||
|
documentType: 'Resignation Acceptance Letter'
|
||||||
},
|
},
|
||||||
attributes: ['id']
|
attributes: ['id']
|
||||||
});
|
});
|
||||||
if (!hasLegalStageDocument) {
|
if (!hasLegalStageDocument) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
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.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,9 @@ import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.j
|
|||||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||||
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.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 = {
|
const LINE_ITEM_DESCRIPTION_PREFIX = {
|
||||||
DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]',
|
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 });
|
res.json({ success: true, message: 'Payment updated successfully', data: updatedPayment });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update payment error:', 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 });
|
res.json({ success: true, message: 'F&F settlement updated successfully', data: fnf });
|
||||||
|
|||||||
@ -16,10 +16,16 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js';
|
|||||||
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||||
import { ParticipantService } from '../../services/ParticipantService.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 { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
||||||
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
||||||
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.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 resolveTerminationUuid = async (id: string) => {
|
||||||
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'termination');
|
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();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
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 { dealerId, category, reason, proposedLwd, comments } = req.body;
|
||||||
|
|
||||||
const requestId = await NomenclatureService.generateTerminationId();
|
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({
|
const termination = await db.TerminationRequest.create({
|
||||||
requestId,
|
requestId,
|
||||||
dealerId,
|
dealerId,
|
||||||
@ -42,15 +60,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
|||||||
proposedLwd,
|
proposedLwd,
|
||||||
comments,
|
comments,
|
||||||
initiatedBy: req.user.id,
|
initiatedBy: req.user.id,
|
||||||
currentStage: TERMINATION_STAGES.RBM_REVIEW,
|
currentStage: startStage,
|
||||||
status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW),
|
status: getTerminationStatusForStage(startStage),
|
||||||
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW),
|
progressPercentage: TerminationWorkflowService.calculateProgress(startStage),
|
||||||
timeline: [{
|
timeline: [{
|
||||||
stage: 'Submitted',
|
stage: 'Submitted',
|
||||||
targetStage: TERMINATION_STAGES.RBM_REVIEW,
|
targetStage: startStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
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
|
remarks: comments
|
||||||
}]
|
}]
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
@ -68,6 +86,29 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
|||||||
ParticipantService.assignTerminationParticipants(termination.id)
|
ParticipantService.assignTerminationParticipants(termination.id)
|
||||||
.catch(err => logger.error('Error assigning participants to termination:', err));
|
.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 });
|
res.status(201).json({ success: true, message: 'Termination request created', termination });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (transaction) await transaction.rollback();
|
if (transaction) await transaction.rollback();
|
||||||
@ -193,6 +234,14 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next:
|
|||||||
if (!termination) {
|
if (!termination) {
|
||||||
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
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 });
|
res.json({ success: true, termination });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching termination:', error);
|
logger.error('Error fetching termination:', error);
|
||||||
@ -257,11 +306,11 @@ export const uploadTerminationDocument = async (req: AuthRequest, res: Response,
|
|||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
const timeline = [...(termination.timeline || []), {
|
const timeline = [...(termination.timeline || []), {
|
||||||
stage: termination.currentStage,
|
stage: stage || termination.currentStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
action: `Document uploaded: ${documentType}`,
|
action: `Document uploaded: ${documentType}`,
|
||||||
remarks: req.file.originalname
|
remarks: `Attachment: ${req.file.originalname}`
|
||||||
}];
|
}];
|
||||||
await termination.update({ timeline }, { transaction });
|
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' });
|
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;
|
const fromStage = termination.currentStage;
|
||||||
let approvedToStage: string | null = null;
|
let approvedToStage: string | null = null;
|
||||||
|
|
||||||
@ -311,6 +371,27 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
status: 'Rejected',
|
status: 'Rejected',
|
||||||
remarks
|
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) {
|
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
||||||
// Validation: Remarks mandatory for Revoke
|
// Validation: Remarks mandatory for Revoke
|
||||||
const validation = validateOffboardingAction(action, remarks);
|
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
|
[TERMINATION_STAGES.LEGAL_LETTER]: TERMINATION_STAGES.TERMINATED
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStage = stageFlow[termination.currentStage];
|
const sourceStage = termination.currentStage;
|
||||||
logger.info(`[TerminationController] transitioning from ${termination.currentStage} to ${nextStage}`);
|
const nextStage = stageFlow[sourceStage];
|
||||||
|
logger.info(`[TerminationController] attempting transition from ${sourceStage} to ${nextStage}`);
|
||||||
|
|
||||||
if (!nextStage) {
|
if (!nextStage) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
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;
|
approvedToStage = nextStage;
|
||||||
|
|
||||||
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
||||||
remarks,
|
remarks: remarks || `Jointly approved by RBM & DD-ZM`,
|
||||||
status: getTerminationStatusForStage(nextStage)
|
status: getTerminationStatusForStage(nextStage),
|
||||||
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
// If Terminated, trigger F&F initiation via Workflow Service
|
// 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();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
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 { terminationRequestId, responseBody, documents } = req.body;
|
||||||
|
|
||||||
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
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',
|
status: 'Show Cause Notice',
|
||||||
remarks: remarks || 'Show Cause Notice issued'
|
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();
|
await transaction.commit();
|
||||||
@ -488,6 +752,11 @@ export const uploadScnResponse = async (req: AuthRequest, res: Response, next: N
|
|||||||
const transaction: Transaction = await db.sequelize.transaction();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
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 { id } = req.params;
|
||||||
const { remarks } = req.body;
|
const { remarks } = req.body;
|
||||||
const resolvedId = await resolveTerminationUuid(String(id));
|
const resolvedId = await resolveTerminationUuid(String(id));
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const checkInterviews = async () => {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`--- Interviews ---`);
|
// console.log(`--- Interviews ---`);
|
||||||
interviews.forEach((i: any) => {
|
interviews.forEach((i: any) => {
|
||||||
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
|
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
|
||||||
i.participants?.forEach((p: any) => {
|
i.participants?.forEach((p: any) => {
|
||||||
|
|||||||
@ -14,13 +14,16 @@ const seedInterviewTemplates = async () => {
|
|||||||
<h2>Dear {{applicantName}},</h2>
|
<h2>Dear {{applicantName}},</h2>
|
||||||
<p>Your <strong>{{type}}</strong> for Royal Enfield Dealership Application ({{applicationId}}) has been scheduled.</p>
|
<p>Your <strong>{{type}}</strong> for Royal Enfield Dealership Application ({{applicationId}}) has been scheduled.</p>
|
||||||
<p><strong>Scheduled Time:</strong> {{scheduledAt}}</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>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>
|
<p>Best Regards,<br>Royal Enfield Onboarding Team</p>
|
||||||
</body></html>
|
</body></html>
|
||||||
`,
|
`,
|
||||||
placeholders: ['applicantName', 'applicationId', 'type', 'scheduledAt', 'link']
|
placeholders: ['applicantName', 'applicationId', 'type', 'scheduledAt', 'meetLink', 'appLink']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
templateCode: 'INTERVIEW_SCHEDULED_PANELIST',
|
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>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>Application ID:</strong> {{applicationId}}</p>
|
||||||
<p><strong>Scheduled Time:</strong> {{scheduledAt}}</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><a href="{{link}}">Open Assessment Dashboard</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;">Open Assessment Dashboard</a>
|
||||||
|
</div>
|
||||||
<p>Please review the applicant's profile before the session.</p>
|
<p>Please review the applicant's profile before the session.</p>
|
||||||
<p>Regards,<br>System Administrator</p>
|
<p>Regards,<br>System Administrator</p>
|
||||||
</body></html>
|
</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',
|
templateCode: 'WORKFLOW_ACTION_REQUIRED',
|
||||||
|
|||||||
@ -87,6 +87,13 @@ const seedTemplates = async () => {
|
|||||||
fileName: 'onboarding_status_update.html',
|
fileName: 'onboarding_status_update.html',
|
||||||
placeholders: ['applicantName', 'applicationId', 'status', 'reason', 'salesCode', 'serviceCode']
|
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',
|
templateCode: 'RESIGNATION_SUBMITTED',
|
||||||
description: 'Notification for new Resignation submission',
|
description: 'Notification for new Resignation submission',
|
||||||
@ -101,12 +108,47 @@ const seedTemplates = async () => {
|
|||||||
fileName: 'resignation_approved.html',
|
fileName: 'resignation_approved.html',
|
||||||
placeholders: ['dealerName', 'resignationId', 'lwd']
|
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',
|
templateCode: 'TERMINATION_SCN_ISSUED',
|
||||||
description: 'Notification for Show Cause Notice issuance',
|
description: 'Notification for Show Cause Notice issuance',
|
||||||
subject: 'URGENT: Show Cause Notice Issued: {{terminationId}}',
|
subject: 'URGENT: Show Cause Notice Issued: {{terminationId}}',
|
||||||
fileName: 'termination_scn.html',
|
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',
|
templateCode: 'WORKNOTE_NOTIFICATION',
|
||||||
@ -143,6 +185,20 @@ const seedTemplates = async () => {
|
|||||||
fileName: 'resignation_update.html',
|
fileName: 'resignation_update.html',
|
||||||
placeholders: ['dealerName', 'status', 'remarks']
|
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',
|
templateCode: 'TERMINATION_UPDATE',
|
||||||
description: 'General status update for Termination',
|
description: 'General status update for Termination',
|
||||||
@ -150,6 +206,20 @@ const seedTemplates = async () => {
|
|||||||
fileName: 'termination_update.html',
|
fileName: 'termination_update.html',
|
||||||
placeholders: ['dealerName', 'status', 'remarks']
|
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',
|
templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE',
|
||||||
description: 'General status update for Constitutional Change',
|
description: 'General status update for Constitutional Change',
|
||||||
@ -178,6 +248,13 @@ const seedTemplates = async () => {
|
|||||||
fileName: 'relocation_submitted.html',
|
fileName: 'relocation_submitted.html',
|
||||||
placeholders: ['dealerName', 'requestId', 'outletCode']
|
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',
|
templateCode: 'RELOCATION_UPDATE',
|
||||||
description: 'Dealer-visible status updates during relocation workflow',
|
description: 'Dealer-visible status updates during relocation workflow',
|
||||||
@ -212,6 +289,20 @@ const seedTemplates = async () => {
|
|||||||
subject: 'SLA ESCALATION [L{{level}}]: {{applicationId}} — {{stageName}}',
|
subject: 'SLA ESCALATION [L{{level}}]: {{applicationId}} — {{stageName}}',
|
||||||
fileName: 'sla_escalation.html',
|
fileName: 'sla_escalation.html',
|
||||||
placeholders: ['applicationId', 'stageName', 'level', 'timeValue', 'timeUnit', 'link']
|
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']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
316
src/scripts/seed-missing-templates.ts
Normal file
316
src/scripts/seed-missing-templates.ts
Normal 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 & 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 & Final (F&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&F Settlement
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#888;font-size:12px;">
|
||||||
|
Per Royal Enfield policy, F&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();
|
||||||
@ -6,6 +6,55 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
|
|||||||
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||||
|
|
||||||
export class ConstitutionalWorkflowService {
|
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
|
* Transitions a constitutional change request to a new stage
|
||||||
*/
|
*/
|
||||||
@ -118,7 +167,8 @@ export class ConstitutionalWorkflowService {
|
|||||||
actionUserFullName: userFullName || 'System',
|
actionUserFullName: userFullName || 'System',
|
||||||
action: action || `Moved to ${targetStage}`,
|
action: action || `Moved to ${targetStage}`,
|
||||||
remarks: remarkText,
|
remarks: remarkText,
|
||||||
link: `${portalBase}/constitutional-change/${request.id}`
|
link: `${portalBase}/constitutional-change/${request.id}`,
|
||||||
|
changeType: request.changeType
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
const asyncChannels = channels.filter(c => c !== 'system');
|
||||||
if (asyncChannels.length > 0) {
|
if (asyncChannels.length > 0) {
|
||||||
if (process.env.ENABLE_REDIS === 'true') {
|
if (process.env.ENABLE_REDIS === 'true') {
|
||||||
@ -67,7 +67,18 @@ export class NotificationService {
|
|||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
} else {
|
} 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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -129,7 +129,6 @@ export class ParticipantService {
|
|||||||
// 2. National roles
|
// 2. National roles
|
||||||
const nationalRoles = [
|
const nationalRoles = [
|
||||||
ROLES.DD_LEAD,
|
ROLES.DD_LEAD,
|
||||||
ROLES.DD_HEAD,
|
|
||||||
ROLES.NBH,
|
ROLES.NBH,
|
||||||
ROLES.CCO,
|
ROLES.CCO,
|
||||||
ROLES.CEO,
|
ROLES.CEO,
|
||||||
@ -347,6 +346,9 @@ export class ParticipantService {
|
|||||||
const outlet = (relocation as any).outlet;
|
const outlet = (relocation as any).outlet;
|
||||||
if (outlet && outlet.district) {
|
if (outlet && outlet.district) {
|
||||||
const district = 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) {
|
if (relocation.dealerId) {
|
||||||
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
|
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
|
||||||
if (dealerUser?.dealerId) {
|
if (dealerUser?.dealerId) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import db from '../database/models/index.js';
|
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 { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.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.DD_ZM_REVIEW]: ROLES.DD_ZM,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
|
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
|
||||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
|
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
|
||||||
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: ROLES.DD_HEAD,
|
|
||||||
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
|
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN,
|
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN
|
||||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: ROLES.NBH
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requiredRole = stageMapping[request.currentStage];
|
const requiredRole = stageMapping[request.currentStage];
|
||||||
@ -143,10 +141,65 @@ export class RelocationWorkflowService {
|
|||||||
// Role-based check
|
// Role-based check
|
||||||
if (user.roleCode !== requiredRole) return false;
|
if (user.roleCode !== requiredRole) return false;
|
||||||
|
|
||||||
// Optional: Hierarchy check
|
// Stage-specific participant assignment enforcement: actor must be mapped on this request.
|
||||||
// We could verify if the user is the SPECIFIC person assigned in participants
|
const participant = await RequestParticipant.findOne({
|
||||||
// but for now, any user with the correct role can act (consistent with simple RBAC)
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
const { User } = db;
|
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 { getResignationStatusForStage } from '../common/utils/offboardingStatus.js';
|
||||||
import { NotificationService } from './NotificationService.js';
|
import { NotificationService } from './NotificationService.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op, Transaction } from 'sequelize';
|
||||||
import logger from '../common/utils/logger.js';
|
import logger from '../common/utils/logger.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
|
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||||
|
|
||||||
|
|
||||||
export class ResignationWorkflowService {
|
export class ResignationWorkflowService {
|
||||||
@ -131,9 +132,9 @@ export class ResignationWorkflowService {
|
|||||||
[RESIGNATION_STAGES.RBM]: 30,
|
[RESIGNATION_STAGES.RBM]: 30,
|
||||||
[RESIGNATION_STAGES.ZBH]: 40,
|
[RESIGNATION_STAGES.ZBH]: 40,
|
||||||
[RESIGNATION_STAGES.DD_LEAD]: 50,
|
[RESIGNATION_STAGES.DD_LEAD]: 50,
|
||||||
[RESIGNATION_STAGES.NBH]: 60,
|
[RESIGNATION_STAGES.NBH]: 65,
|
||||||
[RESIGNATION_STAGES.DD_ADMIN]: 75,
|
[RESIGNATION_STAGES.LEGAL]: 80,
|
||||||
[RESIGNATION_STAGES.LEGAL]: 85,
|
[RESIGNATION_STAGES.DD_ADMIN]: 90,
|
||||||
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
|
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
|
||||||
[RESIGNATION_STAGES.COMPLETED]: 100,
|
[RESIGNATION_STAGES.COMPLETED]: 100,
|
||||||
[RESIGNATION_STAGES.REJECTED]: 100
|
[RESIGNATION_STAGES.REJECTED]: 100
|
||||||
@ -148,18 +149,83 @@ export class ResignationWorkflowService {
|
|||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
|
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.ASM]: ROLES.ASM,
|
||||||
[RESIGNATION_STAGES.RBM]: ROLES.RBM,
|
[RESIGNATION_STAGES.RBM]: [ROLES.RBM, ROLES.DD_ZM],
|
||||||
[RESIGNATION_STAGES.ZBH]: ROLES.ZBH,
|
[RESIGNATION_STAGES.ZBH]: ROLES.ZBH,
|
||||||
[RESIGNATION_STAGES.DD_LEAD]: ROLES.DD_LEAD,
|
[RESIGNATION_STAGES.DD_LEAD]: ROLES.DD_LEAD,
|
||||||
[RESIGNATION_STAGES.NBH]: ROLES.NBH,
|
[RESIGNATION_STAGES.NBH]: ROLES.NBH,
|
||||||
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
|
|
||||||
[RESIGNATION_STAGES.LEGAL]: ROLES.LEGAL_ADMIN,
|
[RESIGNATION_STAGES.LEGAL]: ROLES.LEGAL_ADMIN,
|
||||||
|
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
|
||||||
[RESIGNATION_STAGES.FNF_INITIATED]: ROLES.DD_ADMIN
|
[RESIGNATION_STAGES.FNF_INITIATED]: ROLES.DD_ADMIN
|
||||||
};
|
};
|
||||||
|
|
||||||
const requiredRole = stageToRole[resignation.currentStage];
|
const requiredRole = stageToRole[resignation.currentStage];
|
||||||
|
if (Array.isArray(requiredRole)) {
|
||||||
|
return requiredRole.includes(user.roleCode);
|
||||||
|
}
|
||||||
return user.roleCode === requiredRole;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export class TerminationWorkflowService {
|
|||||||
* Standardized method to transition a termination request status
|
* Standardized method to transition a termination request status
|
||||||
*/
|
*/
|
||||||
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
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 sourceStage = termination.currentStage;
|
||||||
|
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
@ -35,6 +35,7 @@ export class TerminationWorkflowService {
|
|||||||
targetStage: targetStage,
|
targetStage: targetStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: actor ? actor.fullName : 'System',
|
user: actor ? actor.fullName : 'System',
|
||||||
|
role: actor ? actor.roleCode : null,
|
||||||
action: action || `Approved to ${targetStage}`,
|
action: action || `Approved to ${targetStage}`,
|
||||||
remarks: remarks || ''
|
remarks: remarks || ''
|
||||||
};
|
};
|
||||||
@ -45,7 +46,7 @@ export class TerminationWorkflowService {
|
|||||||
await termination.update({
|
await termination.update({
|
||||||
...updateData,
|
...updateData,
|
||||||
timeline: updatedTimeline
|
timeline: updatedTimeline
|
||||||
});
|
}, transaction ? { transaction } : undefined);
|
||||||
|
|
||||||
// 4. Create Audit Log using standardized mapper
|
// 4. Create Audit Log using standardized mapper
|
||||||
const { actionType } = metadata;
|
const { actionType } = metadata;
|
||||||
@ -57,7 +58,7 @@ export class TerminationWorkflowService {
|
|||||||
action: formatOffboardingAction(auditAction),
|
action: formatOffboardingAction(auditAction),
|
||||||
remarks: remarks || '',
|
remarks: remarks || '',
|
||||||
details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) }
|
details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) }
|
||||||
});
|
}, transaction ? { transaction } : undefined);
|
||||||
|
|
||||||
// 5. Create Worknote for standardized communication trail
|
// 5. Create Worknote for standardized communication trail
|
||||||
if (remarks && userId) {
|
if (remarks && userId) {
|
||||||
@ -227,7 +228,7 @@ export class TerminationWorkflowService {
|
|||||||
placeholders: {
|
placeholders: {
|
||||||
dealerName: dealerUser?.fullName || 'Dealer',
|
dealerName: dealerUser?.fullName || 'Dealer',
|
||||||
requestId: termination.requestId,
|
requestId: termination.requestId,
|
||||||
link: `${portalBase}/fnf-settlements/${fnf.id}`,
|
link: `${portalBase}/fnf/${fnf.id}`,
|
||||||
phone: phone || ''
|
phone: phone || ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -279,7 +280,7 @@ export class TerminationWorkflowService {
|
|||||||
|
|
||||||
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
||||||
action: 'SCN_SUBMITTED',
|
action: 'SCN_SUBMITTED',
|
||||||
status: 'Personal Hearing Pending',
|
status: 'SCN Response Evaluation Pending',
|
||||||
remarks: 'Dealer response submitted'
|
remarks: 'Dealer response submitted'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -300,7 +301,7 @@ export class TerminationWorkflowService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL;
|
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, {
|
return this.transitionTermination(termination, nextStage, userId, {
|
||||||
action: `Hearing Recorded - ${recommendation}`,
|
action: `Hearing Recorded - ${recommendation}`,
|
||||||
@ -308,4 +309,39 @@ export class TerminationWorkflowService {
|
|||||||
remarks: summary
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const EMAILS = {
|
|||||||
DEALER: args.dealerEmail,
|
DEALER: args.dealerEmail,
|
||||||
ASM: 'abhishek@royalenfield.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
RBM: 'manish@royalenfield.com',
|
RBM: 'manish@royalenfield.com',
|
||||||
|
DD_ZM: 'piyush@royalenfield.com',
|
||||||
ZBH: 'manav@royalenfield.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'jaya@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
NBH: 'yashwin@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
@ -158,13 +159,14 @@ async function run() {
|
|||||||
await delay();
|
await delay();
|
||||||
|
|
||||||
const approvals = [
|
const approvals = [
|
||||||
{ name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' },
|
{ stage: 'ASM', 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.' },
|
{ stage: 'RBM + DD-ZM Review', 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.' },
|
{ stage: 'RBM + DD-ZM Review', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' },
|
||||||
{ name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
|
{ stage: 'ZBH', name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' },
|
||||||
{ name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
|
{ stage: 'DD Lead', name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
|
||||||
{ name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' },
|
{ stage: 'NBH', name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
|
||||||
{ name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' }
|
{ 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
|
// Fetch resignation data to determine current stage for skipping
|
||||||
@ -174,16 +176,40 @@ async function run() {
|
|||||||
console.log(`Current Stage: ${currentStage}`);
|
console.log(`Current Stage: ${currentStage}`);
|
||||||
|
|
||||||
const stageOrder = [
|
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;
|
let currentStep = 2;
|
||||||
for (let i = startIndex; i < approvals.length; i++) {
|
for (let i = startApproveIndex; i < approvals.length; i++) {
|
||||||
const actor = approvals[i];
|
const actor = approvals[i];
|
||||||
log(currentStep, `${actor.name} (${actor.email}) approving...`);
|
log(currentStep, `${actor.name} (${actor.email}) approving...`);
|
||||||
const token = await login(actor.email);
|
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', {
|
const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
|
||||||
remarks: actor.remarks,
|
remarks: actor.remarks,
|
||||||
force: true
|
force: true
|
||||||
|
|||||||
@ -12,8 +12,10 @@ const EMAILS = {
|
|||||||
DD_ADMIN: 'lince@royalenfield.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
ASM: 'abhishek@royalenfield.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
RBM: 'manish@royalenfield.com',
|
RBM: 'manish@royalenfield.com',
|
||||||
|
DD_ZM: 'piyush@royalenfield.com',
|
||||||
ZBH: 'manav@royalenfield.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'jaya@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
|
DD_HEAD: 'ganesh@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
NBH: 'yashwin@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
CCO: 'admin@royalenfield.com',
|
CCO: 'admin@royalenfield.com',
|
||||||
@ -72,8 +74,10 @@ async function run() {
|
|||||||
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
||||||
|
|
||||||
let terminationId = args.terminationId;
|
let terminationId = args.terminationId;
|
||||||
|
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
||||||
|
|
||||||
if (!terminationId) {
|
if (!terminationId) {
|
||||||
console.log('[STEP 1] ASM Initiating Termination...');
|
console.log('[STEP 1] Initiating Termination...');
|
||||||
const asmToken = await login(EMAILS.ASM);
|
const asmToken = await login(EMAILS.ASM);
|
||||||
const createRes = await apiRequest('/termination', 'POST', {
|
const createRes = await apiRequest('/termination', 'POST', {
|
||||||
dealerId: targetDealer.id,
|
dealerId: targetDealer.id,
|
||||||
@ -83,7 +87,7 @@ async function run() {
|
|||||||
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
||||||
}, asmToken);
|
}, asmToken);
|
||||||
terminationId = createRes.termination.id;
|
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 {
|
} else {
|
||||||
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
||||||
}
|
}
|
||||||
@ -93,40 +97,49 @@ async function run() {
|
|||||||
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
||||||
|
|
||||||
const approvals = [
|
const approvals = [
|
||||||
{ name: 'RBM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' },
|
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
|
||||||
{ name: 'ZBH Review', email: EMAILS.ZBH, remarks: 'Strategic decision aligned with regional growth targets. Approved.' },
|
{ stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] },
|
||||||
{ name: 'DD Lead Review', email: EMAILS.DD_LEAD, remarks: 'Contractual breaches documented. Verified.' },
|
{ stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] },
|
||||||
{ name: 'Legal Verification', email: EMAILS.LEGAL, remarks: 'Legal audit complete. Case is legally sound.' },
|
{ stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] },
|
||||||
{ name: 'DD Head Review', email: EMAILS.NBH, remarks: 'Strategic impact assessed. Proceeding with SCN approval.' },
|
{ stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] },
|
||||||
{ name: 'NBH Evaluation', email: EMAILS.NBH, remarks: 'Functional teams aligned. SCN to be issued.' },
|
{ stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] },
|
||||||
{ name: 'SCN Issued', email: EMAILS.NBH, remarks: 'Show Cause Notice formally dispatched.' },
|
{ stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] },
|
||||||
{ name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD, remarks: 'Hearing completed. Dealer defense not sufficient.' },
|
{ stage: 'Personal Hearing', actors: [
|
||||||
{ name: 'NBH Final Approval', email: EMAILS.NBH, remarks: 'Final recommendation for termination sent to CEO.' },
|
{ email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' },
|
||||||
{ name: 'CCO Approval', email: EMAILS.CCO, remarks: 'Commercial impact assessed. Approved.' },
|
{ email: EMAILS.ZBH, remarks: 'Review recorded.' },
|
||||||
{ name: 'CEO Final Approval', email: EMAILS.CEO, remarks: 'Final authorization granted. Issue termination letter.' },
|
{ email: EMAILS.RBM, remarks: 'Review recorded.' },
|
||||||
{ name: 'Legal Termination Letter', email: EMAILS.LEGAL, remarks: 'Termination letter shared via registered mail.' },
|
{ email: EMAILS.DD_HEAD, remarks: 'Review recorded.' }
|
||||||
{ name: 'Final Terminated Status', email: EMAILS.DD_ADMIN, remarks: 'Closure completed.' }
|
] },
|
||||||
|
{ 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 = [
|
const stageOrder = [
|
||||||
'Submitted', 'RBM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
|
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
|
||||||
'NBH Evaluation', 'Show Cause Notice', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
|
'NBH Evaluation', 'Show Cause Notice (SCN)', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
|
||||||
'CEO Final Approval', 'Legal - Termination Letter', 'Terminated'
|
'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++) {
|
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
|
||||||
const actor = approvals[i];
|
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
|
||||||
log(currentStep, `${actor.name} (${actor.email}) processing approval...`);
|
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);
|
const token = await login(actor.email);
|
||||||
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
||||||
action: 'approve',
|
action: 'approve',
|
||||||
remarks: actor.remarks
|
remarks: actor.remarks
|
||||||
}, token);
|
}, token);
|
||||||
log(currentStep, `${actor.name} Result: SUCCESS`);
|
log(currentStep, `Actor ${actor.email} Result: SUCCESS`);
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
currentStep++;
|
currentStep++;
|
||||||
await delay();
|
await delay();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,7 +123,7 @@ async function prospectLogin(phone) {
|
|||||||
|
|
||||||
async function mockUploadDocument(appId, token, docType) {
|
async function mockUploadDocument(appId, token, docType) {
|
||||||
const formData = new FormData();
|
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' });
|
const blob = new Blob([fileBuffer], { type: 'image/png' });
|
||||||
formData.append('file', blob, 'screenshot.png');
|
formData.append('file', blob, 'screenshot.png');
|
||||||
formData.append('documentType', docType);
|
formData.append('documentType', docType);
|
||||||
@ -264,312 +264,312 @@ async function triggerWorkflow() {
|
|||||||
}, leadToken);
|
}, leadToken);
|
||||||
const interviewId2 = intv2Response.data.id;
|
const interviewId2 = intv2Response.data.id;
|
||||||
|
|
||||||
// log(5.1, 'DD-Lead Giving Feedback...');
|
log(5.1, 'DD-Lead Giving Feedback...');
|
||||||
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||||
// interviewId: interviewId2,
|
interviewId: interviewId2,
|
||||||
// overallScore: 9.5,
|
overallScore: 9.5,
|
||||||
// feedbackItems: [
|
feedbackItems: [
|
||||||
// { type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
|
{ type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
|
||||||
// { type: 'Management Capabilities', comments: 'Strong team leadership.' },
|
{ type: 'Management Capabilities', comments: 'Strong team leadership.' },
|
||||||
// { type: 'Operational Understanding', comments: 'Knows the local market well.' }
|
{ type: 'Operational Understanding', comments: 'Knows the local market well.' }
|
||||||
// ],
|
],
|
||||||
// recommendation: 'Selected'
|
recommendation: 'Selected'
|
||||||
// }, leadToken);
|
}, leadToken);
|
||||||
|
|
||||||
// log(5.15, 'ZBH Giving Feedback...');
|
log(5.15, 'ZBH Giving Feedback...');
|
||||||
// const zbhToken = await login(zbhUser.email);
|
const zbhToken = await login(zbhUser.email);
|
||||||
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||||
// interviewId: interviewId2,
|
interviewId: interviewId2,
|
||||||
// overallScore: 9.0,
|
overallScore: 9.0,
|
||||||
// feedbackItems: [
|
feedbackItems: [
|
||||||
// { type: 'Strategic Vision', comments: 'Good alignment with brand.' },
|
{ type: 'Strategic Vision', comments: 'Good alignment with brand.' },
|
||||||
// { type: 'Key Strengths', comments: 'Great location proposed.' },
|
{ type: 'Key Strengths', comments: 'Great location proposed.' },
|
||||||
// { type: 'Areas of Concern', comments: 'None at this time.' }
|
{ type: 'Areas of Concern', comments: 'None at this time.' }
|
||||||
// ],
|
],
|
||||||
// recommendation: 'Selected'
|
recommendation: 'Selected'
|
||||||
// }, zbhToken);
|
}, zbhToken);
|
||||||
|
|
||||||
// log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
|
log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
|
||||||
// await apiRequest('/assessment/decision', 'POST', {
|
await apiRequest('/assessment/decision', 'POST', {
|
||||||
// interviewId: interviewId2,
|
interviewId: interviewId2,
|
||||||
// decision: 'Approved',
|
decision: 'Approved',
|
||||||
// remarks: 'Cleared Level 2'
|
remarks: 'Cleared Level 2'
|
||||||
// }, leadToken);
|
}, leadToken);
|
||||||
// log(5, 'Level 2 Complete.');
|
log(5, 'Level 2 Complete.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 6. LEVEL-3 INTERVIEW
|
// 6. LEVEL-3 INTERVIEW
|
||||||
// log(6, 'Scheduling Level 3 Interview...');
|
log(6, 'Scheduling Level 3 Interview...');
|
||||||
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
||||||
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
||||||
|
|
||||||
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
||||||
// applicationId: applicationUUID,
|
applicationId: applicationUUID,
|
||||||
// level: 3,
|
level: 3,
|
||||||
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
||||||
// type: 'In-Person',
|
type: 'In-Person',
|
||||||
// location: 'HO',
|
location: 'HO',
|
||||||
// participants: [headUser.id, nbhUser.id]
|
participants: [headUser.id, nbhUser.id]
|
||||||
// }, leadToken);
|
}, leadToken);
|
||||||
// const interviewId3 = intv3Response.data.id;
|
const interviewId3 = intv3Response.data.id;
|
||||||
|
|
||||||
// log(6.1, 'NBH Giving Feedback...');
|
log(6.1, 'NBH Giving Feedback...');
|
||||||
// const nbhToken = await login(EMAILS.NBH);
|
const nbhToken = await login(EMAILS.NBH);
|
||||||
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||||
// interviewId: interviewId3,
|
interviewId: interviewId3,
|
||||||
// overallScore: 10,
|
overallScore: 10,
|
||||||
// feedbackItems: [
|
feedbackItems: [
|
||||||
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
|
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
|
||||||
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
|
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
|
||||||
// ],
|
],
|
||||||
// recommendation: 'Selected'
|
recommendation: 'Selected'
|
||||||
// }, nbhToken);
|
}, nbhToken);
|
||||||
|
|
||||||
// log(6.15, 'DD-Head Giving Feedback...');
|
log(6.15, 'DD-Head Giving Feedback...');
|
||||||
// const headToken = await login(EMAILS.DD_HEAD);
|
const headToken = await login(EMAILS.DD_HEAD);
|
||||||
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||||
// interviewId: interviewId3,
|
interviewId: interviewId3,
|
||||||
// overallScore: 9.5,
|
overallScore: 9.5,
|
||||||
// feedbackItems: [
|
feedbackItems: [
|
||||||
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
|
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
|
||||||
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
|
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
|
||||||
// ],
|
],
|
||||||
// recommendation: 'Selected'
|
recommendation: 'Selected'
|
||||||
// }, headToken);
|
}, headToken);
|
||||||
|
|
||||||
// log(6.2, 'Head Finalizing Level 3 Decision...');
|
log(6.2, 'Head Finalizing Level 3 Decision...');
|
||||||
// await apiRequest('/assessment/decision', 'POST', {
|
await apiRequest('/assessment/decision', 'POST', {
|
||||||
// interviewId: interviewId3,
|
interviewId: interviewId3,
|
||||||
// decision: 'Approved',
|
decision: 'Approved',
|
||||||
// remarks: 'Cleared Level 3. Moving to FDD.'
|
remarks: 'Cleared Level 3. Moving to FDD.'
|
||||||
// }, headToken);
|
}, headToken);
|
||||||
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 6.3 FDD ASSIGNMENT
|
// 6.3 FDD ASSIGNMENT
|
||||||
// log(6.3, 'Admin Assigning Application to FDD Agency...');
|
log(6.3, 'Admin Assigning Application to FDD Agency...');
|
||||||
// const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
||||||
// await apiRequest('/fdd/assign', 'POST', {
|
await apiRequest('/fdd/assign', 'POST', {
|
||||||
// applicationId: applicationUUID,
|
applicationId: applicationUUID,
|
||||||
// assignedToAgency: fddUser.id
|
assignedToAgency: fddUser.id
|
||||||
// }, adminToken);
|
}, adminToken);
|
||||||
// log(6.3, 'FDD Agency assigned successfully.');
|
log(6.3, 'FDD Agency assigned successfully.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 7. FDD MILESTONE
|
// 7. FDD MILESTONE
|
||||||
// log(7, 'FDD Agency Discovery & Report Upload...');
|
log(7, 'FDD Agency Discovery & Report Upload...');
|
||||||
// const fddToken = await login(EMAILS.FDD);
|
const fddToken = await login(EMAILS.FDD);
|
||||||
|
|
||||||
// // FETCH ASSIGNMENT ID
|
// FETCH ASSIGNMENT ID
|
||||||
// const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
||||||
// const assignmentId = assignmentRes.data.id;
|
const assignmentId = assignmentRes.data.id;
|
||||||
// log(7, `Found Assignment ID: ${assignmentId}`);
|
log(7, `Found Assignment ID: ${assignmentId}`);
|
||||||
|
|
||||||
// await apiRequest('/fdd/report', 'POST', {
|
await apiRequest('/fdd/report', 'POST', {
|
||||||
// assignmentId,
|
assignmentId,
|
||||||
// findings: 'Finance records clean.',
|
findings: 'Finance records clean.',
|
||||||
// recommendation: 'Approved'
|
recommendation: 'Approved'
|
||||||
// }, fddToken);
|
}, fddToken);
|
||||||
|
|
||||||
// log(7.1, 'Admin Approving FDD Final Stage...');
|
log(7.1, 'Admin Approving FDD Final Stage...');
|
||||||
// await apiRequest('/assessment/stage-decision', 'POST', {
|
await apiRequest('/assessment/stage-decision', 'POST', {
|
||||||
// applicationId: applicationUUID,
|
applicationId: applicationUUID,
|
||||||
// stageCode: 'FDD_VERIFICATION',
|
stageCode: 'FDD_VERIFICATION',
|
||||||
// decision: 'Approved',
|
decision: 'Approved',
|
||||||
// remarks: 'FDD documents verified.'
|
remarks: 'FDD documents verified.'
|
||||||
// }, adminToken);
|
}, adminToken);
|
||||||
// log(7, 'FDD Milestone Complete.');
|
log(7, 'FDD Milestone Complete.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// log(7.4, 'Uploading mandatory documents prior to LOI generation...');
|
log(7.4, 'Uploading mandatory documents prior to LOI generation...');
|
||||||
// const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
|
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
|
||||||
// for (const doc of requiredDocs) {
|
for (const doc of requiredDocs) {
|
||||||
// await mockUploadDocument(applicationUUID, adminToken, doc);
|
await mockUploadDocument(applicationUUID, adminToken, doc);
|
||||||
// }
|
}
|
||||||
// await delay(1000);
|
await delay(1000);
|
||||||
|
|
||||||
// // 7.5 LOI APPROVAL
|
// 7.5 LOI APPROVAL
|
||||||
// log(7.5, 'LOI Generation & Approval...');
|
log(7.5, 'LOI Generation & Approval...');
|
||||||
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
// const loiRequestId = loiRes.data.id;
|
const loiRequestId = loiRes.data.id;
|
||||||
|
|
||||||
// // Head Approval
|
// Head Approval
|
||||||
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||||
// action: 'Approved',
|
action: 'Approved',
|
||||||
// remarks: 'Head Authorization for LOI'
|
remarks: 'Head Authorization for LOI'
|
||||||
// }, headToken);
|
}, headToken);
|
||||||
|
|
||||||
// // NBH Approval
|
// NBH Approval
|
||||||
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||||
// action: 'Approved',
|
action: 'Approved',
|
||||||
// remarks: 'NBH Authorization for LOI'
|
remarks: 'NBH Authorization for LOI'
|
||||||
// }, nbhToken);
|
}, nbhToken);
|
||||||
|
|
||||||
// log(7.5, 'LOI Milestone Complete.');
|
log(7.5, 'LOI Milestone Complete.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
||||||
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
||||||
// const financeToken = await login(EMAILS.FINANCE);
|
const financeToken = await login(EMAILS.FINANCE);
|
||||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
// applicationId: applicationUUID,
|
applicationId: applicationUUID,
|
||||||
// amount: 500000,
|
amount: 500000,
|
||||||
// paymentReference: 'PAY-888999',
|
paymentReference: 'PAY-888999',
|
||||||
// depositType: 'SECURITY_DEPOSIT',
|
depositType: 'SECURITY_DEPOSIT',
|
||||||
// status: 'Verified'
|
status: 'Verified'
|
||||||
// }, financeToken);
|
}, financeToken);
|
||||||
// log(8, 'Security Deposit Verified.')
|
log(8, 'Security Deposit Verified.')
|
||||||
// // 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
|
// 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
|
||||||
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||||
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
||||||
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
||||||
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
||||||
// await delay(300);
|
await delay(300);
|
||||||
|
|
||||||
// if (statusBeforeCodeGen === 'Security Details') {
|
if (statusBeforeCodeGen === 'Security Details') {
|
||||||
// log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
|
log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
|
||||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
// applicationId: applicationUUID,
|
applicationId: applicationUUID,
|
||||||
// amount: 500000,
|
amount: 500000,
|
||||||
// paymentReference: `PAY-RETRY-${Date.now()}`,
|
paymentReference: `PAY-RETRY-${Date.now()}`,
|
||||||
// depositType: 'SECURITY_DEPOSIT',
|
depositType: 'SECURITY_DEPOSIT',
|
||||||
// status: 'Verified'
|
status: 'Verified'
|
||||||
// }, financeToken);
|
}, financeToken);
|
||||||
// await delay();
|
await delay();
|
||||||
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||||
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// // Current backend flow keeps app at "Security Details" until explicit admin transition.
|
// Current backend flow keeps app at "Security Details" until explicit admin transition.
|
||||||
// if (statusBeforeCodeGen === 'Security Details') {
|
if (statusBeforeCodeGen === 'Security Details') {
|
||||||
// log(9, 'Applying admin transition from Security Details -> LOI Issued...');
|
log(9, 'Applying admin transition from Security Details -> LOI Issued...');
|
||||||
// await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
||||||
// status: 'LOI Issued',
|
status: 'LOI Issued',
|
||||||
// stage: 'LOI',
|
stage: 'LOI',
|
||||||
// reason: 'E2E script alignment: unlock dealer code generation after Security Details checks.'
|
reason: 'E2E script alignment: unlock dealer code generation after Security Details checks.'
|
||||||
// }, adminToken);
|
}, adminToken);
|
||||||
// await delay();
|
await delay();
|
||||||
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||||
// log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
|
log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
|
if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
|
||||||
// throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
|
throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// log(9, 'Admin Generating SAP Dealer Codes...');
|
log(9, 'Admin Generating SAP Dealer Codes...');
|
||||||
// await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
||||||
// log(9, 'Dealer Codes Generated.');
|
log(9, 'Dealer Codes Generated.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 10. FIRST FILL (POST CODE-GENERATION)
|
// 10. FIRST FILL (POST CODE-GENERATION)
|
||||||
// log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
||||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
// applicationId: applicationUUID,
|
applicationId: applicationUUID,
|
||||||
// amount: 1500000,
|
amount: 1500000,
|
||||||
// paymentReference: 'PAY-FIN-999',
|
paymentReference: 'PAY-FIN-999',
|
||||||
// depositType: 'FIRST_FILL',
|
depositType: 'FIRST_FILL',
|
||||||
// status: 'Verified'
|
status: 'Verified'
|
||||||
// }, financeToken);
|
}, financeToken);
|
||||||
// log(10, 'Final Security Deposit Verified.');
|
log(10, 'Final Security Deposit Verified.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||||
// log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||||
// await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||||
// accountHolderName: 'Ramesh Automobiles Private Limited',
|
accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||||
// panNumber: 'ABCDE1234F',
|
panNumber: 'ABCDE1234F',
|
||||||
// gstNumber: '07ABCDE1234F1Z5',
|
gstNumber: '07ABCDE1234F1Z5',
|
||||||
// bankName: 'HDFC Bank',
|
bankName: 'HDFC Bank',
|
||||||
// accountNumber: '50100223344556',
|
accountNumber: '50100223344556',
|
||||||
// ifscCode: 'HDFC0001234'
|
ifscCode: 'HDFC0001234'
|
||||||
// }, adminToken);
|
}, adminToken);
|
||||||
// log(11, 'Statutory & Bank details updated.');
|
log(11, 'Statutory & Bank details updated.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 12. FINAL LOA APPROVAL
|
// 12. FINAL LOA APPROVAL
|
||||||
// log(12, 'NBH & Head Approving Final LOA...');
|
log(12, 'NBH & Head Approving Final LOA...');
|
||||||
// const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||||
// const finalLoaRequestId = loaRes.data.id;
|
const finalLoaRequestId = loaRes.data.id;
|
||||||
|
|
||||||
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||||
// action: 'Approved',
|
action: 'Approved',
|
||||||
// remarks: 'Head Authorization (Level 1)'
|
remarks: 'Head Authorization (Level 1)'
|
||||||
// }, headToken);
|
}, headToken);
|
||||||
|
|
||||||
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||||
// action: 'Approved',
|
action: 'Approved',
|
||||||
// remarks: 'NBH Approval (Level 2)'
|
remarks: 'NBH Approval (Level 2)'
|
||||||
// }, nbhToken);
|
}, nbhToken);
|
||||||
// log(12, 'LOA Fully Approved.');
|
log(12, 'LOA Fully Approved.');
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||||
// log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||||
// const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
// const checklistId = eorInit.data.id;
|
const checklistId = eorInit.data.id;
|
||||||
// log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
||||||
|
|
||||||
// log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||||
// const eorItems = [
|
const eorItems = [
|
||||||
// { itemType: 'Sales', description: 'Sales Standards' },
|
{ itemType: 'Sales', description: 'Sales Standards' },
|
||||||
// { itemType: 'Service', description: 'Service & Spares' },
|
{ itemType: 'Service', description: 'Service & Spares' },
|
||||||
// { itemType: 'IT', description: 'DMS infra' },
|
{ itemType: 'IT', description: 'DMS infra' },
|
||||||
// { itemType: 'Training', description: 'Manpower Training' },
|
{ itemType: 'Training', description: 'Manpower Training' },
|
||||||
// { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||||
// { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||||
// { itemType: 'Finance', description: 'Inventory Funding' },
|
{ itemType: 'Finance', description: 'Inventory Funding' },
|
||||||
// { itemType: 'IT', description: 'Virtual code availability' },
|
{ itemType: 'IT', description: 'Virtual code availability' },
|
||||||
// { itemType: 'Finance', description: 'Vendor payments' },
|
{ itemType: 'Finance', description: 'Vendor payments' },
|
||||||
// { itemType: 'Marketing', description: 'Details for website submission' },
|
{ itemType: 'Marketing', description: 'Details for website submission' },
|
||||||
// { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||||
// { itemType: 'IT', description: 'Auto ordering' }
|
{ itemType: 'IT', description: 'Auto ordering' }
|
||||||
// ];
|
];
|
||||||
|
|
||||||
// for (const item of eorItems) {
|
for (const item of eorItems) {
|
||||||
// process.stdout.write(`.`); // Visual progress
|
process.stdout.write(`.`); // Visual progress
|
||||||
// await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||||
// ...item,
|
...item,
|
||||||
// isCompliant: true,
|
isCompliant: true,
|
||||||
// remarks: 'Verified by Auditor - Compliant'
|
remarks: 'Verified by Auditor - Compliant'
|
||||||
// }, adminToken);
|
}, adminToken);
|
||||||
// }
|
}
|
||||||
// console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
||||||
|
|
||||||
// log(13.2, 'Auditor Submitting Final EOR Audit...');
|
log(13.2, 'Auditor Submitting Final EOR Audit...');
|
||||||
// await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||||
// status: 'Completed',
|
status: 'Completed',
|
||||||
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||||
// }, adminToken);
|
}, adminToken);
|
||||||
|
|
||||||
// // Status check
|
// Status check
|
||||||
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||||
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 14. FINAL ONBOARDING
|
// 14. FINAL ONBOARDING
|
||||||
// log(14, 'Admin Finalizing Dealer Onboarding...');
|
log(14, 'Admin Finalizing Dealer Onboarding...');
|
||||||
// await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
// await delay();
|
await delay();
|
||||||
|
|
||||||
// // 15. VERIFICATION
|
// 15. VERIFICATION
|
||||||
// log(15, 'Verifying Dealer Record Creation...');
|
log(15, 'Verifying Dealer Record Creation...');
|
||||||
// const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||||
// if (!dealerRes.success || !dealerRes.data) {
|
if (!dealerRes.success || !dealerRes.data) {
|
||||||
// throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||||
// }
|
}
|
||||||
// log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||||
|
|
||||||
// log(15.1, 'Verifying User Account Role Update...');
|
log(15.1, 'Verifying User Account Role Update...');
|
||||||
// const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||||
// const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||||
// if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||||
// throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
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, `User role confirmed: ${dealerUser.roleCode}`);
|
||||||
|
|
||||||
// log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
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, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -63,12 +63,7 @@ async function run() {
|
|||||||
console.log(`Created Test Request: ${request.requestId}`);
|
console.log(`Created Test Request: ${request.requestId}`);
|
||||||
|
|
||||||
// Now call the logic that calculates participants (similar to getRequestById)
|
// 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.
|
// We'll just look at the DB for now to see if NBH exists for the approval stage.
|
||||||
|
|
||||||
// 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'}`);
|
|
||||||
|
|
||||||
// Check NBH
|
// Check NBH
|
||||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||||
console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`);
|
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)
|
// Verify Evaluator Assignment Logic (Re-running a piece of it)
|
||||||
const evaluators = [];
|
const evaluators = [];
|
||||||
evaluators.push({ id: outlet.district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW });
|
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_APPROVAL });
|
||||||
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE });
|
|
||||||
|
|
||||||
console.log('Expected Evaluators for this request:');
|
console.log('Expected Evaluators for this request:');
|
||||||
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));
|
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user