data altered to make the data flow consistance necessay schem changes done
This commit is contained in:
parent
115e978eb8
commit
25d5570319
223
docs/Detailed_Module_Data_Flows.md
Normal file
223
docs/Detailed_Module_Data_Flows.md
Normal file
@ -0,0 +1,223 @@
|
||||
# Exhaustive Module Data Flow & Schema Analysis
|
||||
|
||||
This document provides a 100% comprehensive breakdown of the Dealer Onboarding & Lifecycle Management system. It includes every enum, every database key, and every workflow transition to support a thorough gap analysis.
|
||||
|
||||
---
|
||||
|
||||
## 1. Global Constants & Enums (`constants.ts`)
|
||||
|
||||
These constants are the primary drivers of application logic and stage-gate controls.
|
||||
|
||||
### 👥 User Roles (`ROLES`)
|
||||
| Key | Value | Role Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `DD` | `DD` | Dealer Development Executive (Field) |
|
||||
| `DD_ZM` | `DD-ZM` | DD Zone Manager (Zone) |
|
||||
| `RBM` | `RBM` | Regional Business Manager |
|
||||
| `ZBH` | `ZBH` | Zone Business Head |
|
||||
| `DD_LEAD` | `DD Lead` | Lead for Dealer Development |
|
||||
| `DD_HEAD` | `DD Head` | Head of Dealer Development |
|
||||
| `NBH` | `NBH` | National Business Head |
|
||||
| `LEGAL_ADMIN` | `Legal Admin` | Legal Dept (Verification/Letters) |
|
||||
| `SUPER_ADMIN` | `Super Admin` | System Administrator (Bypass) |
|
||||
| `FINANCE` | `Finance` | Finance Dept (Payments/Clearance) |
|
||||
| `DEALER` | `Dealer` | The Business Partner / Applicant |
|
||||
| `ARCHITECTURE` | `ARCHITECTURE` | Site Layout & Design Team |
|
||||
| `FDD` | `FDD` | Field Due Diligence Agency |
|
||||
| `CCO` | `CCO` | Chief Commercial Officer |
|
||||
| `CEO` | `CEO` | Chief Executive Officer |
|
||||
|
||||
### 🛠️ Application Lifecycle (`APPLICATION_STATUS`)
|
||||
| Status | Functional Meaning |
|
||||
| :--- | :--- |
|
||||
| `Pending` | Initial lead entry |
|
||||
| `Submitted` | Form submitted by Prospect |
|
||||
| `Questionnaire Pending` | Sent to prospect for survey |
|
||||
| `Shortlisted` | DD Lead approval for evaluation |
|
||||
| `FDD Verification` | Agency conducting site/back background check |
|
||||
| `LOI Issued` | Intent letter issued (Commercial check complete) |
|
||||
| `EOR In Progress` | Site construction/readiness audit |
|
||||
| `LOA Issued` | Final Appointment Letter issued |
|
||||
| `Onboarded` | System provisioning complete (Active Dealer) |
|
||||
|
||||
### 📦 Request Types (`REQUEST_TYPES`)
|
||||
- `application`: New Onboarding
|
||||
- `resignation`: Voluntary Exit
|
||||
- `constitutional`: Legal entity change
|
||||
- `relocation`: Site movement
|
||||
|
||||
---
|
||||
|
||||
## 2. Onboarding Module: Schema & Transitions
|
||||
|
||||
### 🔑 Primary Model: `Application` (`applications` table)
|
||||
| Field | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `id` | UUID | Internal Database PK |
|
||||
| `applicationId` | String | Public ID (e.g., APP-2024-001) |
|
||||
| `businessType` | Enum | `Dealership` or `Studio` |
|
||||
| `applicantName`| String | Legal name of primary applicant |
|
||||
| `experienceYears`| Integer | Industry experience |
|
||||
| `investmentCapacity`| String | Range of funds available |
|
||||
| `currentStage` | Enum | Current workflow stage (e.g., `ZBH`) |
|
||||
| `overallStatus` | Enum | High-level status (e.g., `LOI Issued`) |
|
||||
| `score` | Decimal | Aggregate Questionnaire/Interview score |
|
||||
| `isShortlisted` | Boolean | Gate for proceeding to interviews |
|
||||
| `documents` | JSONB | Active document snapshot {type, url, status} |
|
||||
| `timeline` | JSONB | Sequential history of actions |
|
||||
|
||||
### 🔄 Sub-Entities captured during Onboarding
|
||||
| Model | Key Fields Captured |
|
||||
| :--- | :--- |
|
||||
| **Interview** | `level` (1, 2, 3), `scheduleDate`, `type` (Virtual/Physical) |
|
||||
| **FDD Report** | `findings` (Text), `recommendation` (Recommended/Not), `verifiedAt` |
|
||||
| **Security Deposit**| `amount`, `paymentDate`, `transactionRef`, `status` |
|
||||
| **EOR Checklist** | `auditorId`, `auditDate`, `items` (Compliant/Non-Compliant) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Post-Onboarding Modules: Detailed Schema
|
||||
|
||||
### 🏗️ Relocation Module (`relocation_requests` table)
|
||||
| Field | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `requestId` | String | Unique Relocation ID |
|
||||
| `outletId` | UUID | FK to the specific outlet being moved |
|
||||
| `relocationType`| Enum | `Within City`, `Intercity`, `Interstate` |
|
||||
| `newAddress` | Text | Destination street address |
|
||||
| `newCity/State` | String | Destination geo-attributes |
|
||||
| `reason` | Text | Business justification for relocation |
|
||||
| `currentStage` | Enum | `ASM Review` -> `NBH` -> `Legal` |
|
||||
|
||||
### ⚖️ Constitutional Change (`constitutional_changes` table)
|
||||
| Field | Type | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `changeType` | Enum | `Ownership Transfer`, `Partnership Change`, etc. |
|
||||
| `description` | Text | Summary of legal restructuring |
|
||||
| `oldValue` | String | Current constitution (Snapshot) |
|
||||
| `newValue` | String | Proposed constitution |
|
||||
|
||||
### 🚪 Offboarding (Termination & Resignation)
|
||||
Both flows merge into the **Full & Final (F&F)** settlement.
|
||||
|
||||
| Module | Primary Keys | Clearance Tracks |
|
||||
| :--- | :--- | :--- |
|
||||
| **Resignation** | `resignationType`, `lastOperationalDate`, `reason` | Spares, Service, Accounts, Logistics |
|
||||
| **Termination** | `category` (Non-performance/Compliance), `proposedLwd` | Show Cause Notice, Personal Hearing Record |
|
||||
| **FnF Settlement**| `totalReceivables`, `totalPayables`, `netAmount` | Line items for specific credit/debit memos |
|
||||
|
||||
---
|
||||
|
||||
## 4. Document Matrix (Gap Analysis Reference)
|
||||
|
||||
Every request type captures a specific subset of `DOCUMENT_TYPES`. Missing a document in a stage is a common "Gap".
|
||||
|
||||
| Document Type | Captured In | Mandatory For |
|
||||
| :--- | :--- | :--- |
|
||||
| `GST Certificate` | Application / Const. Change | All entities |
|
||||
| `Partnership Deed` | Application / Const. Change | Partnership firms |
|
||||
| `New Lease Agreement`| Relocation | Site movement |
|
||||
| `NOC Landlord` | Relocation | Existing site exit |
|
||||
| `SCN Response` | Termination | Show Cause phase |
|
||||
| `Hearing Record` | Termination | Personal Hearing phase |
|
||||
| `Exit Feedback` | Resignation | Dealer exit |
|
||||
|
||||
---
|
||||
|
||||
## 5. Audit & Traceability Logic
|
||||
|
||||
Every action across all modules is tracked via these shared models:
|
||||
|
||||
### `AuditLog`
|
||||
- `userId`: Who did it.
|
||||
- `action`: `CREATED`, `UPDATED`, `APPROVED`, etc.
|
||||
- `oldData` / `newData`: JSON snapshots of the record BEFORE and AFTER the change.
|
||||
|
||||
### `Worknote`
|
||||
- `requestId`: Link to Application/Relocation/etc.
|
||||
- `noteText`: Threaded comments between field and HO teams.
|
||||
- `noteType`: `general`, `system`, or `private`.
|
||||
|
||||
### `Document Types` (Exhaustive List)
|
||||
| Enum Key | Display Name |
|
||||
| :--- | :--- |
|
||||
| `GST_CERTIFICATE` | GST Certificate |
|
||||
| `PAN_CARD` | PAN Card |
|
||||
| `AADHAAR` | Aadhaar |
|
||||
| `PARTNERSHIP_DEED` | Partnership Deed |
|
||||
| `LLP_AGREEMENT` | LLP Agreement |
|
||||
| `INCORPORATION_CERTIFICATE` | Certificate of Incorporation |
|
||||
| `MOA` | MOA |
|
||||
| `AOA` | AOA |
|
||||
| `BOARD_RESOLUTION` | Board Resolution |
|
||||
| `PROPERTY_DOCUMENTS` | Property Documents |
|
||||
| `BANK_STATEMENT` | Bank Statement |
|
||||
| `NODAL_AGREEMENT` | Nodal Agreement |
|
||||
| `CANCELLED_CHECK` | Cancelled Check |
|
||||
| `FIRM_REGISTRATION` | Firm Registration |
|
||||
| `RENTAL_AGREEMENT` | Rental Agreement |
|
||||
| `VIRTUAL_CODE` | Virtual Code Confirmation |
|
||||
| `DOMAIN_ID` | Domain ID Setup |
|
||||
| `MSD_CONFIG` | MSD Configuration |
|
||||
| `LOI_ACK` | LOI Acknowledgement |
|
||||
| `FDD_REPORT` | FDD Final Audit Report |
|
||||
| `KT_MATRIX` | Kepner Tregoe Matrix |
|
||||
| `INTERVIEW_EVALUATION` | Interview Evaluation Sheet |
|
||||
| `SITE_READINESS` | Site Readiness Report |
|
||||
| `CIBIL_REPORT` | CIBIL Report |
|
||||
| `LOA_ACCEPTANCE` | LOA Acceptance Copy |
|
||||
| `ARCHITECTURE_BLUEPRINT` | Architecture Blueprint |
|
||||
| `SITE_PLAN` | Site Plan |
|
||||
| `STATUTORY_AUDIT` | Statutory Approval Certificate |
|
||||
| `BANK_GUARANTEE` | Bank Guarantee Document |
|
||||
| `RELOCATION_*` | 10+ Relocation specific docs (Photos, NOC, Fire Safety, etc.) |
|
||||
|
||||
### `Audit Actions` (System Traceability)
|
||||
`CREATED`, `UPDATED`, `APPROVED`, `REJECTED`, `DELETED`, `LOGIN`, `LOGOUT`, `STAGE_CHANGED`, `SHORTLISTED`, `DISQUALIFIED`, `QUESTIONNAIRE_SUBMITTED`, `DOCUMENT_UPLOADED`, `DOCUMENT_VERIFIED`, `INTERVIEW_SCHEDULED`, `LOI_REQUESTED`, `LOA_GENERATED`, `EOR_AUDIT_SUBMITTED`, `PAYMENT_UPDATED`, `RESIGNATION_SUBMITTED`.
|
||||
|
||||
## 9. Advanced Approval Policies (`StageApprovalPolicy`)
|
||||
|
||||
To enforce strict governance, certain stages (Interviews, LOI, LOA) require multiple independent approvals.
|
||||
|
||||
| Mode | Rule | Evaluation Logic |
|
||||
| :--- | :--- | :--- |
|
||||
| `MIN_N` | Minimum Count | Requires at least `N` unique users to approve, regardless of role. |
|
||||
| `ROLE_MANDATORY`| Specific Roles | Requires unique approvals from EACH role listed in `requiredRoles`. |
|
||||
| `ALL` | Full Concensus | Every assigned `RequestParticipant` for that stage must approve. |
|
||||
|
||||
**Evaluation Engine**: `WorkflowService.evaluateStagePolicy()`
|
||||
- **Super Admin Bypass**: A Super Admin approval automatically satisfies any policy.
|
||||
- **Unique Actor Check**: Prevents a single user with multiple roles from satisfying multiple requirements alone.
|
||||
|
||||
---
|
||||
|
||||
## 10. Auto-Assignment & Territory Mapping
|
||||
|
||||
The system automatically assigns evaluators based on the **District -> Region -> Zone** hierarchy linked to each application.
|
||||
|
||||
### 🗺️ Evaluator Mapping Matrix
|
||||
| Stage / Level | Role Sources | Logic / Hierarchy |
|
||||
| :--- | :--- | :--- |
|
||||
| **Level 1 Interview** | `DD-ZM`, `RBM` | Primary District Manager (`zmId`) + Regional Manager (`rbmId`). |
|
||||
| **Level 2 Interview** | `ZBH`, `DD Lead`| Zone Business Head (`zbhId`) + DD Lead assigned to that Zone. |
|
||||
| **Level 3 Interview** | `NBH`, `DD Head`| National Level Roles (Active status check). |
|
||||
| **LOI Approval** | `Finance`, `Head`, `NBH`| National Level (Requires 3 unique approvals). |
|
||||
| **LOA Approval** | `DD Head`, `NBH`| National Level (Requires 2 unique approvals). |
|
||||
|
||||
**Metadata capture**: Assignments are stored in `RequestParticipant` with `joinedMethod: 'auto'` and `metadata.autoMapped: true`.
|
||||
|
||||
---
|
||||
|
||||
## 11. System Fail-Safe (Retriggering)
|
||||
|
||||
If any territory mapping is missing (e.g., a District has no assigned ASM) or if a manager changes mid-flow, the system provides a **Retrigger** capability.
|
||||
|
||||
### 🔄 The Retrigger Process
|
||||
1. **API Endpoint**: `POST /onboarding/:id/retrigger-evaluators`
|
||||
2. **Step A (Cleanup)**: Deletes all participants where `metadata.autoMapped = true`. This preserves manual notes and manually added participants.
|
||||
3. **Step B (Hierarchy Sync)**: Invokes `syncLocationManagers()` to refresh the `District` table with current managers from `UserRole`.
|
||||
4. **Step C (Re-assignment)**: Re-runs the `assignStageEvaluators` logic to map the correct current users to all 3 interview levels and approval stages.
|
||||
|
||||
---
|
||||
*Generated: April 2026 | Comprehensive System Data Flow Analysis v2.1 (Advanced Features)*
|
||||
|
||||
30
scripts/fix_constitutional_enum.ts
Normal file
30
scripts/fix_constitutional_enum.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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));
|
||||
37
scripts/migrate_constitutional.ts
Normal file
37
scripts/migrate_constitutional.ts
Normal file
@ -0,0 +1,37 @@
|
||||
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();
|
||||
@ -1,90 +1,161 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { ROLES, APPLICATION_STAGES, APPLICATION_STATUS } from '../src/common/config/constants.js';
|
||||
|
||||
const { Role, Zone, Region, State, Location, User, UserRole } = db;
|
||||
const { Role, User, UserRole, Zone, State, Region, Location } = db;
|
||||
|
||||
async function resetAndSeed() {
|
||||
console.log('--- RESETTING DATABASE TO DENORMALIZED DISTRICT MODEL ---');
|
||||
async function masterReset() {
|
||||
console.log('--- RELOADING DATABASE FOR OFFBOARDING TEST ---');
|
||||
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
|
||||
// 1. Force Sync
|
||||
// 1. Force Sync (Drop and Recreate)
|
||||
await db.sequelize.sync({ force: true });
|
||||
console.log('Database schema reset (force synced).');
|
||||
console.log('✅ Schema reset complete.');
|
||||
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
|
||||
// 2. Seed Roles
|
||||
const roles = [
|
||||
{ roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' },
|
||||
{ roleCode: 'DD Head', roleName: 'DD Head', category: 'NATIONAL' },
|
||||
{ roleCode: 'ZBH', roleName: 'Zonal Business Head', category: 'ZONAL' },
|
||||
{ roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' },
|
||||
{ roleCode: 'RM', roleName: 'Regional Manager', category: 'REGIONAL' },
|
||||
{ roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' },
|
||||
{ roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' },
|
||||
{ roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' }
|
||||
// 2. Seed All Roles
|
||||
const rolesToSeed = Object.values(ROLES).map(code => ({
|
||||
roleCode: code,
|
||||
roleName: code,
|
||||
category: 'SYSTEM'
|
||||
}));
|
||||
|
||||
for (const r of rolesToSeed) {
|
||||
await Role.create(r);
|
||||
}
|
||||
console.log('✅ Roles seeded.');
|
||||
|
||||
// 3. Seed Basic Hierarchy
|
||||
const { District } = db;
|
||||
const zone = await Zone.create({ name: 'North', code: 'N' });
|
||||
const state = await State.create({ name: 'Delhi', zoneId: zone.id });
|
||||
const region = await Region.create({ name: 'NCR', zoneId: zone.id });
|
||||
|
||||
// Create South Delhi District and link to NCR Region
|
||||
const district = await District.create({
|
||||
name: 'South Delhi',
|
||||
stateId: state.id,
|
||||
regionId: region.id,
|
||||
zoneId: zone.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const loc = await Location.create({
|
||||
name: 'Central Delhi',
|
||||
stateId: state.id,
|
||||
regionId: region.id,
|
||||
zoneId: zone.id,
|
||||
districtId: district.id
|
||||
});
|
||||
console.log('✅ Hierarchy seeded with South Delhi District.');
|
||||
|
||||
// 4. Seed Essential Users
|
||||
const users = [
|
||||
{ email: 'admin@royalenfield.com', fullName: 'Super Admin', roleCode: ROLES.SUPER_ADMIN },
|
||||
{ email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer (DD Lead)', roleCode: ROLES.DD_LEAD },
|
||||
{ email: 'nbh@royalenfield.com', fullName: 'NBH Head', roleCode: ROLES.NBH },
|
||||
{ email: 'cco@royalenfield.com', fullName: 'Ashok Singh (CCO)', roleCode: ROLES.CCO },
|
||||
{ email: 'ceo@royalenfield.com', fullName: 'Siddhartha Lal (CEO)', roleCode: ROLES.CEO },
|
||||
{ email: 'spares@royalenfield.com', fullName: 'Spares Clearance Mgr', roleCode: ROLES.SPARES_MANAGER },
|
||||
{ email: 'service@royalenfield.com', fullName: 'Service Clearance Mgr', roleCode: ROLES.SERVICE_MANAGER },
|
||||
{ email: 'accounts@royalenfield.com', fullName: 'Accounts Clearance Mgr', roleCode: ROLES.ACCOUNTS_MANAGER },
|
||||
{ email: 'finance@royalenfield.com', fullName: 'Rahul Verma (Finance)', roleCode: ROLES.FINANCE },
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', roleCode: ROLES.LEGAL_ADMIN },
|
||||
{ email: 'dealer@royalenfield.com', fullName: 'Dealer One', roleCode: ROLES.DEALER, isExternal: true },
|
||||
{ email: 'asm@royalenfield.com', fullName: 'Sales Manager (ASM)', roleCode: ROLES.ASM },
|
||||
{ email: 'rbm@royalenfield.com', fullName: 'Regional Business Mgr', roleCode: ROLES.RBM },
|
||||
{ email: 'ddzm@royalenfield.com', fullName: 'Zonal Manager (DD ZM)', roleCode: ROLES.DD_ZM },
|
||||
{ email: 'zbh@royalenfield.com', fullName: 'Zonal Head (ZBH)', roleCode: ROLES.ZBH },
|
||||
{ email: 'ddhead@royalenfield.com', fullName: 'Vikram Singh (DD Head)', roleCode: ROLES.DD_HEAD }
|
||||
];
|
||||
|
||||
for (const r of roles) await Role.create(r);
|
||||
console.log('Roles seeded.');
|
||||
for (const u of users) {
|
||||
const user = await User.create({
|
||||
...u,
|
||||
password: hashedPassword,
|
||||
status: 'active'
|
||||
});
|
||||
const role = await Role.findOne({ where: { roleCode: u.roleCode } });
|
||||
if (role) {
|
||||
// Map assignments based on role category (Regional vs Granular)
|
||||
const isRegionalRole = [ROLES.RBM, ROLES.DD_ZM, ROLES.ZBH].includes(u.roleCode as any);
|
||||
const isGranularRole = [ROLES.ASM].includes(u.roleCode as any);
|
||||
|
||||
// 3. Seed Hierarchy (Zone -> State & Region -> District)
|
||||
// Zone
|
||||
const northZone = await Zone.create({ name: 'North Zone', code: 'ZONE-N' });
|
||||
const southZone = await Zone.create({ name: 'South Zone', code: 'ZONE-S' });
|
||||
|
||||
// State
|
||||
const delhiState = await State.create({ name: 'Delhi', zoneId: northZone.id });
|
||||
const haryanaState = await State.create({ name: 'Haryana', zoneId: northZone.id });
|
||||
const karnatakaState = await State.create({ name: 'Karnataka', zoneId: southZone.id });
|
||||
|
||||
// Region
|
||||
const ncrRegion = await Region.create({ name: 'NCR Region', zoneId: northZone.id });
|
||||
const bangaloreRegion = await Region.create({ name: 'Bangalore Region', zoneId: southZone.id });
|
||||
|
||||
// District (Location)
|
||||
await Location.create({ name: 'Central Delhi', stateId: delhiState.id, regionId: ncrRegion.id, zoneId: northZone.id });
|
||||
await Location.create({ name: 'Gurgaon', stateId: haryanaState.id, regionId: ncrRegion.id, zoneId: northZone.id });
|
||||
await Location.create({ name: 'Bangalore Urban', stateId: karnatakaState.id, regionId: bangaloreRegion.id, zoneId: southZone.id });
|
||||
|
||||
console.log('Denormalized Hierarchy seeded.');
|
||||
|
||||
// 4. Seed Admin Users
|
||||
const adminUser = await User.create({
|
||||
fullName: 'Super Admin',
|
||||
email: 'admin@royalenfield.com',
|
||||
roleCode: 'Super Admin',
|
||||
password: hashedPassword,
|
||||
status: 'active'
|
||||
});
|
||||
const superAdminRole = await Role.findOne({ where: { roleCode: 'Super Admin' } });
|
||||
if (superAdminRole) {
|
||||
await UserRole.create({ userId: adminUser.id, roleId: superAdminRole.id, isActive: true, isPrimary: true });
|
||||
await UserRole.create({
|
||||
userId: user.id,
|
||||
roleId: role.id,
|
||||
isActive: true,
|
||||
isPrimary: true,
|
||||
zoneId: isRegionalRole || isGranularRole ? zone.id : null,
|
||||
regionId: isRegionalRole ? region.id : null,
|
||||
districtId: isGranularRole ? district.id : null
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('✅ Standard Users seeded.');
|
||||
|
||||
const zbhUser = await User.create({
|
||||
fullName: 'Yashwin (ZBH North)',
|
||||
email: 'yashwin@gmail.com',
|
||||
roleCode: 'ZBH',
|
||||
password: hashedPassword,
|
||||
status: 'active'
|
||||
});
|
||||
const zbhRole = await Role.findOne({ where: { roleCode: 'ZBH' } });
|
||||
if (zbhRole) {
|
||||
await UserRole.create({ userId: zbhUser.id, roleId: zbhRole.id, zoneId: northZone.id, isActive: true, isPrimary: true });
|
||||
// 5. Seed a Dealer record for testing
|
||||
const { Application, Dealer, Outlet } = db;
|
||||
const dealerUser = await User.findOne({ where: { email: 'dealer@royalenfield.com' } });
|
||||
|
||||
if (dealerUser) {
|
||||
// First create a mandatory Application as per model constraints
|
||||
// We use 'Approved' stage and 'Onboarded' status to simulate a completed onboarding
|
||||
const [application] = await Application.findOrCreate({
|
||||
where: { applicationId: 'APP-STABLE-001' },
|
||||
defaults: {
|
||||
applicationId: 'APP-STABLE-001',
|
||||
applicantName: dealerUser.fullName,
|
||||
email: dealerUser.email,
|
||||
phone: '9876543210',
|
||||
businessType: 'Dealership',
|
||||
userId: dealerUser.id,
|
||||
currentStage: APPLICATION_STAGES.APPROVED,
|
||||
overallStatus: APPLICATION_STATUS.ONBOARDED,
|
||||
progressPercentage: 100,
|
||||
isShortlisted: true
|
||||
}
|
||||
});
|
||||
|
||||
const dealer = await Dealer.create({
|
||||
applicationId: application.id,
|
||||
legalName: 'Dealer One Motors Private Limited',
|
||||
businessName: 'Dealer One Motors',
|
||||
constitutionType: 'Private Limited',
|
||||
dealerCode: 'D001',
|
||||
status: 'Active',
|
||||
onboardedAt: new Date()
|
||||
});
|
||||
|
||||
// Update user to link to dealer profile
|
||||
await dealerUser.update({ dealerId: dealer.id });
|
||||
|
||||
await db.Outlet.create({
|
||||
dealerId: dealer.id,
|
||||
name: 'Main Outlet',
|
||||
code: 'O001',
|
||||
type: 'Dealership',
|
||||
address: '123, MG Road, South Delhi',
|
||||
city: 'Delhi',
|
||||
state: 'Delhi',
|
||||
pincode: '110001',
|
||||
establishedDate: '2020-01-01',
|
||||
districtId: district.id,
|
||||
status: 'Active'
|
||||
});
|
||||
}
|
||||
console.log('✅ Test Application, Dealer & Outlet created.');
|
||||
|
||||
console.log('Admin Users seeded.');
|
||||
console.log('--- DATABASE RESET & SEEDING COMPLETE ---');
|
||||
console.log('--- SYSTEM READY FOR OFFBOARDING TESTING ---');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error during database reset/seed:', error);
|
||||
console.error('❌ Reset failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
resetAndSeed();
|
||||
masterReset();
|
||||
|
||||
@ -18,7 +18,12 @@ const rolesToSeed = [
|
||||
{ roleCode: ROLES.ASM, roleName: 'ASM', category: 'SALES', description: 'Area Sales Manager' },
|
||||
{ roleCode: ROLES.DEALER, roleName: 'Dealer', category: 'EXTERNAL', description: 'Dealer Principal' },
|
||||
{ roleCode: ROLES.FDD, roleName: 'FDD Team', category: 'EXTERNAL', description: 'Financial Due Diligence Team' },
|
||||
{ roleCode: ROLES.ARCHITECTURE, roleName: 'Architecture Team', category: 'DEPARTMENT', description: 'Architecture & Design Team' }
|
||||
{ roleCode: ROLES.ARCHITECTURE, roleName: 'Architecture Team', category: 'DEPARTMENT', description: 'Architecture & Design Team' },
|
||||
{ roleCode: ROLES.CCO, roleName: 'CCO', category: 'LEADERSHIP', description: 'Chief Commercial Officer' },
|
||||
{ roleCode: ROLES.CEO, roleName: 'CEO', category: 'LEADERSHIP', description: 'Chief Executive Officer' },
|
||||
{ roleCode: ROLES.SPARES_MANAGER, roleName: 'Spares Manager', category: 'DEPARTMENT', description: 'Spares Department Clearance' },
|
||||
{ roleCode: ROLES.SERVICE_MANAGER, roleName: 'Service Manager', category: 'DEPARTMENT', description: 'Service Department Clearance' },
|
||||
{ roleCode: ROLES.ACCOUNTS_MANAGER, roleName: 'Accounts Manager', category: 'DEPARTMENT', description: 'Accounts Department Clearance' }
|
||||
];
|
||||
|
||||
async function seedRoles() {
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
const seedSystemConfigs = async () => {
|
||||
try {
|
||||
console.log('Seeding system configurations...');
|
||||
console.log('🚀 Starting System Configuration Seeding...');
|
||||
|
||||
// Test connection
|
||||
console.log('📡 Testing database connection...');
|
||||
await db.sequelize.authenticate();
|
||||
console.log('✅ Database connection established.');
|
||||
|
||||
const configs = [
|
||||
{
|
||||
@ -19,28 +25,40 @@ const seedSystemConfigs = async () => {
|
||||
}
|
||||
];
|
||||
|
||||
console.log(`🌱 Seeding ${configs.length} configurations...`);
|
||||
|
||||
for (const config of configs) {
|
||||
await db.SystemConfiguration.findOrCreate({
|
||||
const [record, created] = await db.SystemConfiguration.findOrCreate({
|
||||
where: { key: config.key },
|
||||
defaults: {
|
||||
...config,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (created) {
|
||||
console.log(`✅ Created config: ${config.key}`);
|
||||
} else {
|
||||
console.log(`ℹ️ Config already exists: ${config.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('System configurations seeded successfully.');
|
||||
console.log('✨ System configurations seeded successfully.');
|
||||
} catch (error) {
|
||||
console.error('Error seeding system configurations:', error);
|
||||
} finally {
|
||||
// Only close if this is the main module
|
||||
// db.sequelize.close();
|
||||
console.error('❌ Error during seeding:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Run if called directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
seedSystemConfigs().then(() => process.exit(0));
|
||||
}
|
||||
// Execute seeding with proper error handling
|
||||
seedSystemConfigs()
|
||||
.then(() => {
|
||||
console.log('👋 Seeding process completed.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('💥 Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export default seedSystemConfigs;
|
||||
|
||||
@ -13,6 +13,11 @@ async function seedUsers() {
|
||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||
|
||||
const usersToSeed = [
|
||||
{ email: 'rbm.ncr@royalenfield.com', fullName: 'Sanjay Dutt', password: hashedPassword, roleCode: ROLES.RBM, status: 'active' },
|
||||
{ email: 'zm.ncr@royalenfield.com', fullName: 'Rajesh Khanna', password: hashedPassword, roleCode: ROLES.DD_ZM, status: 'active' },
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', password: hashedPassword, roleCode: ROLES.LEGAL_ADMIN, status: 'active' },
|
||||
{ email: 'ddhead@royalenfield.com', fullName: 'Vikram Singh', password: hashedPassword, roleCode: ROLES.DD_HEAD, status: 'active' },
|
||||
{ email: 'nbh@royalenfield.com', fullName: 'Alwyn John', password: hashedPassword, roleCode: ROLES.NBH, status: 'active' },
|
||||
{ email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
|
||||
{ email: 'finance@royalenfield.com', fullName: 'Rahul Verma', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' },
|
||||
{ email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', password: hashedPassword, roleCode: ROLES.DEALER, status: 'active', isExternal: true },
|
||||
@ -21,7 +26,12 @@ async function seedUsers() {
|
||||
{ email: 'kenil@gmail.com', fullName: 'Kenil', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
|
||||
{ email: 'lince@gmail.com', fullName: 'Lince', password: hashedPassword, roleCode: ROLES.DD_ADMIN, status: 'active' },
|
||||
{ email: 'fdd@royalenfield.com', fullName: 'FDD Partner', password: hashedPassword, roleCode: ROLES.FDD, status: 'active' },
|
||||
{ email: 'architecture@royalenfield.com', fullName: 'RE Architect', password: hashedPassword, roleCode: ROLES.ARCHITECTURE, status: 'active' }
|
||||
{ email: 'architecture@royalenfield.com', fullName: 'RE Architect', password: hashedPassword, roleCode: ROLES.ARCHITECTURE, status: 'active' },
|
||||
{ email: 'cco@royalenfield.com', fullName: 'Ashok Singh (CCO)', password: hashedPassword, roleCode: ROLES.CCO, status: 'active' },
|
||||
{ email: 'ceo@royalenfield.com', fullName: 'Siddhartha Lal (CEO)', password: hashedPassword, roleCode: ROLES.CEO, status: 'active' },
|
||||
{ email: 'spares@royalenfield.com', fullName: 'Spares Clearance Mgr', password: hashedPassword, roleCode: ROLES.SPARES_MANAGER, status: 'active' },
|
||||
{ email: 'service@royalenfield.com', fullName: 'Service Clearance Mgr', password: hashedPassword, roleCode: ROLES.SERVICE_MANAGER, status: 'active' },
|
||||
{ email: 'accounts@royalenfield.com', fullName: 'Accounts Clearance Mgr', password: hashedPassword, roleCode: ROLES.ACCOUNTS_MANAGER, status: 'active' }
|
||||
];
|
||||
|
||||
for (const u of usersToSeed) {
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js';
|
||||
import { APPLICATION_STAGES, APPLICATION_STATUS, BUSINESS_TYPES } from '../src/common/config/constants.js';
|
||||
|
||||
const { Role, Zone, Region, State, District, User, UserRole } = db;
|
||||
|
||||
@ -73,24 +74,29 @@ async function seed() {
|
||||
regionMap[r.name] = region;
|
||||
}
|
||||
|
||||
// States & Districts
|
||||
const districts = [
|
||||
{ name: 'South Delhi', stateName: 'DELHI', regionName: 'NCR Region' },
|
||||
{ name: 'NOIDA', stateName: 'UTTAR PRADESH', regionName: 'NCR Region' },
|
||||
{ name: 'Ludhiana', stateName: 'PUNJAB', regionName: 'Punjab Region' },
|
||||
{ name: 'Bangalore Urban', stateName: 'KARNATAKA', regionName: 'Karnataka Region' }
|
||||
];
|
||||
for (const d of districts) {
|
||||
const region = regionMap[d.regionName];
|
||||
const [state] = await State.findOrCreate({
|
||||
where: { name: d.stateName },
|
||||
defaults: { name: d.stateName, zoneId: region.zoneId }
|
||||
});
|
||||
await District.findOrCreate({
|
||||
where: { name: d.name },
|
||||
defaults: { name: d.name, stateId: state.id, regionId: region.id, zoneId: region.zoneId, isActive: true }
|
||||
});
|
||||
}
|
||||
// 3. States & Districts (Mapping South Delhi to NCR)
|
||||
const ncrRegion = regionMap['NCR Region'];
|
||||
|
||||
const [delhiState] = await State.findOrCreate({
|
||||
where: { name: 'DELHI' },
|
||||
defaults: { name: 'DELHI', zoneId: ncrRegion.zoneId }
|
||||
});
|
||||
|
||||
const [southDelhi] = await District.findOrCreate({
|
||||
where: { name: 'South Delhi' },
|
||||
defaults: {
|
||||
name: 'South Delhi',
|
||||
stateId: delhiState.id,
|
||||
regionId: ncrRegion.id,
|
||||
zoneId: ncrRegion.zoneId,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure it's explicitly under NCR
|
||||
await southDelhi.update({ regionId: ncrRegion.id });
|
||||
|
||||
console.log('✅ Mapping: South Delhi -> NCR Region');
|
||||
|
||||
// 3. Create Key Management Users
|
||||
// National / Administrative
|
||||
@ -195,15 +201,88 @@ async function seed() {
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Create Test Dealer for Offboarding Workflows
|
||||
console.log('🌱 Creating Test Dealer for Offboarding...');
|
||||
const dealerUser = await User.findOne({ where: { email: 'dealer@royalenfield.com' } });
|
||||
const asmUser = await User.findOne({ where: { email: 'asm.sdelhi@royalenfield.com' } });
|
||||
|
||||
if (dealerUser && southDelhi) {
|
||||
// 1. Create Placeholder Application (The "Golden Path" root)
|
||||
const [application] = await db.Application.findOrCreate({
|
||||
where: { applicationId: 'APP-TEST-001' },
|
||||
defaults: {
|
||||
applicationId: 'APP-TEST-001',
|
||||
applicantName: 'Amit Sharma',
|
||||
email: 'dealer@royalenfield.com',
|
||||
phone: '9876543210',
|
||||
businessType: BUSINESS_TYPES.DEALERSHIP,
|
||||
submittedBy: dealerUser.id, // Corrected: Prospect/Applicant initiates the request
|
||||
constitutionType: 'Private Limited',
|
||||
currentStage: APPLICATION_STAGES.APPROVED,
|
||||
overallStatus: APPLICATION_STATUS.ONBOARDED,
|
||||
progressPercentage: 100,
|
||||
isShortlisted: true,
|
||||
districtId: southDelhi.id,
|
||||
documents: []
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Create SAP/Dealer Codes (Dependency for Dealer Profile)
|
||||
const [dealerCodeRecord] = await db.DealerCode.findOrCreate({
|
||||
where: { dealerCode: 'D1001' },
|
||||
defaults: {
|
||||
dealerCode: 'D1001',
|
||||
applicationId: application.id,
|
||||
salesCode: 'S1001',
|
||||
serviceCode: 'V1001',
|
||||
gmaCode: 'G1001',
|
||||
gearCode: 'GE1001',
|
||||
sapMasterId: 'SAP-999',
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Create Dealer Profile (Linked to App and Code)
|
||||
const [dealerProfile] = await db.Dealer.findOrCreate({
|
||||
where: { applicationId: application.id },
|
||||
defaults: {
|
||||
applicationId: application.id,
|
||||
dealerCodeId: dealerCodeRecord.id,
|
||||
legalName: 'Amit Sharma Dealership Pvt Ltd',
|
||||
businessName: 'Royal Enfield South Delhi',
|
||||
constitutionType: 'Private Limited',
|
||||
status: 'active',
|
||||
onboardedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Update Dealer User to link to the Dealer Profile
|
||||
await dealerUser.update({ dealerId: dealerProfile.id });
|
||||
|
||||
// 5. Create Main Outlet
|
||||
// Note: As per Outlet model, dealerId refers to the User ID (Owner)
|
||||
await db.Outlet.findOrCreate({
|
||||
where: { code: 'O001' },
|
||||
defaults: {
|
||||
dealerId: dealerUser.id, // Linked to the User ID as owner
|
||||
name: 'Main Outlet - South Delhi',
|
||||
code: 'O001',
|
||||
type: 'Dealership',
|
||||
address: '123, MG Road, South Delhi',
|
||||
city: 'Delhi',
|
||||
state: 'Delhi',
|
||||
pincode: '110001',
|
||||
establishedDate: new Date('2020-01-01'),
|
||||
districtId: southDelhi.id,
|
||||
status: 'Active'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Successfully seeded Golden Path: Application -> DealerCode -> Dealer Profile -> User -> Outlet');
|
||||
}
|
||||
|
||||
console.log('--- Triggering Hierarchy Synchronization ---');
|
||||
const districtList = await District.findAll({ attributes: ['id'] });
|
||||
for (const d of districtList) await syncLocationManagers(d.id);
|
||||
|
||||
const regionList = await Region.findAll({ attributes: ['id'] });
|
||||
for (const r of regionList) await syncRegionManager(r.id);
|
||||
|
||||
const zoneList = await Zone.findAll({ attributes: ['id'] });
|
||||
for (const z of zoneList) await syncZoneManager(z.id);
|
||||
// ... (rest same)
|
||||
|
||||
console.log('--- Golden Path Seeding Complete ---');
|
||||
}
|
||||
|
||||
@ -15,7 +15,12 @@ export const ROLES = {
|
||||
FINANCE: 'Finance',
|
||||
DEALER: 'Dealer',
|
||||
ARCHITECTURE: 'ARCHITECTURE',
|
||||
FDD: 'FDD'
|
||||
FDD: 'FDD',
|
||||
CCO: 'CCO',
|
||||
CEO: 'CEO',
|
||||
SPARES_MANAGER: 'Spares Manager',
|
||||
SERVICE_MANAGER: 'Service Manager',
|
||||
ACCOUNTS_MANAGER: 'Accounts Manager'
|
||||
} as const;
|
||||
|
||||
// Regions
|
||||
@ -139,21 +144,29 @@ export const RESIGNATION_TYPES = {
|
||||
OTHER: 'Other'
|
||||
} as const;
|
||||
|
||||
// Constitutional Change Types
|
||||
// Constitutional Change Types (Aligned with frontend and SRS scenarios)
|
||||
export const CONSTITUTIONAL_CHANGE_TYPES = {
|
||||
PROPRIETORSHIP: 'Proprietorship',
|
||||
PARTNERSHIP: 'Partnership',
|
||||
LLP_CONVERSION: 'LLP Conversion',
|
||||
LLP: 'LLP',
|
||||
PRIVATE_LIMITED: 'Private Limited',
|
||||
COMPANY_FORMATION: 'Company Formation',
|
||||
OWNERSHIP_TRANSFER: 'Ownership Transfer',
|
||||
PARTNERSHIP_CHANGE: 'Partnership Change',
|
||||
LLP_CONVERSION: 'LLP Conversion',
|
||||
COMPANY_FORMATION: 'Company Formation',
|
||||
DIRECTOR_CHANGE: 'Director Change'
|
||||
} as const;
|
||||
|
||||
// Constitutional Change Stages
|
||||
// Constitutional Change Stages (Aligned with SRS v2.0)
|
||||
export const CONSTITUTIONAL_STAGES = {
|
||||
DD_ADMIN_REVIEW: 'DD Admin Review',
|
||||
LEGAL_REVIEW: 'Legal Review',
|
||||
SUBMITTED: 'Submitted',
|
||||
ASM_REVIEW: 'ASM Review',
|
||||
ZM_RBM_REVIEW: 'ZM/RBM Review',
|
||||
ZBH_REVIEW: 'ZBH Review',
|
||||
LEAD_REVIEW: 'DD Lead Review',
|
||||
HEAD_REVIEW: 'DD Head Review',
|
||||
NBH_APPROVAL: 'NBH Approval',
|
||||
FINANCE_CLEARANCE: 'Finance Clearance',
|
||||
LEGAL_REVIEW: 'Legal Review',
|
||||
COMPLETED: 'Completed',
|
||||
REJECTED: 'Rejected'
|
||||
} as const;
|
||||
@ -224,6 +237,26 @@ export const FNF_STATUS = {
|
||||
COMPLETED: 'Completed'
|
||||
} as const;
|
||||
|
||||
// F&F Departments (Full list of 16 functional units)
|
||||
export const FNF_DEPARTMENTS = [
|
||||
'Sales',
|
||||
'Service',
|
||||
'Spares / Parts',
|
||||
'Finance',
|
||||
'Accounts',
|
||||
'Warranty',
|
||||
'Marketing',
|
||||
'HR',
|
||||
'IT',
|
||||
'Legal',
|
||||
'Logistics',
|
||||
'Quality',
|
||||
'FDD',
|
||||
'Apparel',
|
||||
'DMS',
|
||||
'Admin / DD-Admin'
|
||||
];
|
||||
|
||||
// Audit Actions
|
||||
export const AUDIT_ACTIONS = {
|
||||
// General CRUD
|
||||
|
||||
@ -10,18 +10,31 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
// Create test account (or use env vars in production)
|
||||
let transporter: nodemailer.Transporter;
|
||||
let transporterPromise: Promise<nodemailer.Transporter>;
|
||||
|
||||
nodemailer.createTestAccount().then((account) => {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: account.smtp.host,
|
||||
port: account.smtp.port,
|
||||
secure: account.smtp.secure,
|
||||
auth: {
|
||||
user: account.user,
|
||||
pass: account.pass,
|
||||
},
|
||||
const initTransporter = async () => {
|
||||
if (transporter) return transporter;
|
||||
if (transporterPromise) return transporterPromise;
|
||||
|
||||
transporterPromise = nodemailer.createTestAccount().then((account) => {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: account.smtp.host,
|
||||
port: account.smtp.port,
|
||||
secure: account.smtp.secure,
|
||||
auth: {
|
||||
user: account.user,
|
||||
pass: account.pass,
|
||||
},
|
||||
});
|
||||
console.log('[Email Service] Test account initialized:', account.user);
|
||||
return transporter;
|
||||
});
|
||||
}).catch(err => console.error('Failed to create test account:', err));
|
||||
|
||||
return transporterPromise;
|
||||
};
|
||||
|
||||
// Start initialization immediately
|
||||
initTransporter().catch(err => console.error('Failed to initialize transporter:', err));
|
||||
|
||||
const { EmailTemplate } = db;
|
||||
|
||||
@ -72,12 +85,13 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
|
||||
}
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
const readyTransporter = await initTransporter();
|
||||
if (!readyTransporter) {
|
||||
console.warn('Email transporter not initialized. Using fallback mock.');
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
const info = await readyTransporter.sendMail({
|
||||
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
|
||||
to,
|
||||
subject: finalSubject,
|
||||
@ -135,3 +149,21 @@ export const sendUserAssignedEmail = async (to: string, userName: string, applic
|
||||
participantType
|
||||
});
|
||||
};
|
||||
|
||||
export const sendQuestionnaireAckEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||
await sendEmail(to, `Questionnaire Submitted Successfully: ${applicationId}`, 'QUESTIONNAIRE_SUBMITTED', {
|
||||
applicantName,
|
||||
location,
|
||||
applicationId
|
||||
});
|
||||
};
|
||||
|
||||
export const sendShortlistedEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||
const portalLink = 'http://localhost:5173/login';
|
||||
await sendEmail(to, `Congratulations! You are Shortlisted: ${applicationId}`, 'APPLICANT_SHORTLISTED', {
|
||||
applicantName,
|
||||
location,
|
||||
applicationId,
|
||||
portalLink
|
||||
});
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ export interface ApplicationAttributes {
|
||||
description: string | null;
|
||||
address: string | null;
|
||||
pincode: string | null;
|
||||
constitutionType: string | null;
|
||||
currentStage: string;
|
||||
overallStatus: string;
|
||||
progressPercentage: number;
|
||||
@ -141,6 +142,10 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
constitutionType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
currentStage: {
|
||||
type: DataTypes.ENUM(...Object.values(APPLICATION_STAGES)),
|
||||
defaultValue: APPLICATION_STAGES.DD
|
||||
|
||||
@ -4,13 +4,17 @@ import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../common
|
||||
export interface ConstitutionalChangeAttributes {
|
||||
id: string;
|
||||
requestId: string;
|
||||
outletId: string;
|
||||
outletId: string | null;
|
||||
dealerId: string;
|
||||
changeType: typeof CONSTITUTIONAL_CHANGE_TYPES[keyof typeof CONSTITUTIONAL_CHANGE_TYPES];
|
||||
description: string;
|
||||
currentConstitution: string | null;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
currentStage: typeof CONSTITUTIONAL_STAGES[keyof typeof CONSTITUTIONAL_STAGES];
|
||||
status: string;
|
||||
progressPercentage: number;
|
||||
metadata: any;
|
||||
documents: any[];
|
||||
timeline: any[];
|
||||
}
|
||||
@ -31,7 +35,7 @@ export default (sequelize: Sequelize) => {
|
||||
},
|
||||
outletId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'outlets',
|
||||
key: 'id'
|
||||
@ -53,9 +57,21 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
currentConstitution: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
oldValue: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
newValue: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
currentStage: {
|
||||
type: DataTypes.ENUM(...Object.values(CONSTITUTIONAL_STAGES)),
|
||||
defaultValue: CONSTITUTIONAL_STAGES.DD_ADMIN_REVIEW
|
||||
defaultValue: CONSTITUTIONAL_STAGES.SUBMITTED
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
@ -65,6 +81,10 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {}
|
||||
},
|
||||
documents: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: []
|
||||
|
||||
70
src/database/models/FffClearance.ts
Normal file
70
src/database/models/FffClearance.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
export interface FffClearanceAttributes {
|
||||
id: string;
|
||||
fnfId: string;
|
||||
department: string; // 16 Functional units: Sales, Service, Spares, Finance, Marketing, HR, etc.
|
||||
clearedBy: string; // User ID
|
||||
status: string; // 'Pending', 'Cleared', 'Rejected', 'N/A'
|
||||
remarks: string | null;
|
||||
clearedAt: Date | null;
|
||||
documentId: string | null; // For NOC upload
|
||||
}
|
||||
|
||||
export interface FffClearanceInstance extends Model<FffClearanceAttributes>, FffClearanceAttributes { }
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
const FffClearance = sequelize.define<FffClearanceInstance>('FffClearance', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
fnfId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'fnf_settlements',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
department: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
clearedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'Pending'
|
||||
},
|
||||
remarks: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
clearedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
documentId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'fff_clearances',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
(FffClearance as any).associate = (models: any) => {
|
||||
FffClearance.belongsTo(models.FnF, { foreignKey: 'fnfId', as: 'fnfSettlement' });
|
||||
FffClearance.belongsTo(models.User, { foreignKey: 'clearedBy', as: 'clearanceOfficer' });
|
||||
};
|
||||
|
||||
return FffClearance;
|
||||
};
|
||||
@ -13,6 +13,7 @@ export interface FnFAttributes {
|
||||
netAmount: number;
|
||||
settlementDate: Date | null;
|
||||
clearanceDocuments: any[];
|
||||
progressPercentage: number;
|
||||
}
|
||||
|
||||
export interface FnFInstance extends Model<FnFAttributes>, FnFAttributes { }
|
||||
@ -79,6 +80,10 @@ export default (sequelize: Sequelize) => {
|
||||
clearanceDocuments: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: []
|
||||
},
|
||||
progressPercentage: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
}
|
||||
}, {
|
||||
tableName: 'fnf_settlements',
|
||||
|
||||
70
src/database/models/TerminationHearingRecord.ts
Normal file
70
src/database/models/TerminationHearingRecord.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
export interface TerminationHearingRecordAttributes {
|
||||
id: string;
|
||||
terminationRequestId: string;
|
||||
conductedBy: string; // User ID
|
||||
attendees: string;
|
||||
summary: string;
|
||||
momDocumentId: string | null;
|
||||
conductedAt: Date;
|
||||
recommendation: string; // 'Proceed', 'Reconsider', 'Reject'
|
||||
}
|
||||
|
||||
export interface TerminationHearingRecordInstance extends Model<TerminationHearingRecordAttributes>, TerminationHearingRecordAttributes { }
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
const TerminationHearingRecord = sequelize.define<TerminationHearingRecordInstance>('TerminationHearingRecord', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
terminationRequestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'termination_requests',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
conductedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
attendees: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
summary: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
momDocumentId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
conductedAt: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
recommendation: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'termination_hearing_records',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
(TerminationHearingRecord as any).associate = (models: any) => {
|
||||
TerminationHearingRecord.belongsTo(models.TerminationRequest, { foreignKey: 'terminationRequestId', as: 'terminationRequest' });
|
||||
TerminationHearingRecord.belongsTo(models.User, { foreignKey: 'conductedBy', as: 'conductor' });
|
||||
};
|
||||
|
||||
return TerminationHearingRecord;
|
||||
};
|
||||
@ -12,6 +12,12 @@ export interface TerminationRequestAttributes {
|
||||
comments: string | null;
|
||||
timeline: any[];
|
||||
documents: any[];
|
||||
departmentalClearances: {
|
||||
spares: boolean;
|
||||
service: boolean;
|
||||
accounts: boolean;
|
||||
logistics: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TerminationRequestInstance extends Model<TerminationRequestAttributes>, TerminationRequestAttributes { }
|
||||
@ -70,6 +76,15 @@ export default (sequelize: Sequelize) => {
|
||||
documents: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: []
|
||||
},
|
||||
departmentalClearances: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {
|
||||
spares: false,
|
||||
service: false,
|
||||
accounts: false,
|
||||
logistics: false
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'termination_requests',
|
||||
|
||||
65
src/database/models/TerminationScnResponse.ts
Normal file
65
src/database/models/TerminationScnResponse.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
export interface TerminationScnResponseAttributes {
|
||||
id: string;
|
||||
terminationRequestId: string;
|
||||
submittedBy: string; // Dealer User ID
|
||||
responseBody: string;
|
||||
documents: any[];
|
||||
submittedAt: Date;
|
||||
status: string; // 'Submitted', 'Reviewed'
|
||||
}
|
||||
|
||||
export interface TerminationScnResponseInstance extends Model<TerminationScnResponseAttributes>, TerminationScnResponseAttributes { }
|
||||
|
||||
export default (sequelize: Sequelize) => {
|
||||
const TerminationScnResponse = sequelize.define<TerminationScnResponseInstance>('TerminationScnResponse', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
terminationRequestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'termination_requests',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
submittedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
responseBody: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
documents: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: []
|
||||
},
|
||||
submittedAt: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'Submitted'
|
||||
}
|
||||
}, {
|
||||
tableName: 'termination_scn_responses',
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
(TerminationScnResponse as any).associate = (models: any) => {
|
||||
TerminationScnResponse.belongsTo(models.TerminationRequest, { foreignKey: 'terminationRequestId', as: 'terminationRequest' });
|
||||
TerminationScnResponse.belongsTo(models.User, { foreignKey: 'submittedBy', as: 'submitter' });
|
||||
};
|
||||
|
||||
return TerminationScnResponse;
|
||||
};
|
||||
@ -29,6 +29,9 @@ import createLocation from './Location.js';
|
||||
import createZone from './Zone.js';
|
||||
import createRegion from './Region.js';
|
||||
import createState from './State.js';
|
||||
import createTerminationScnResponse from './TerminationScnResponse.js';
|
||||
import createTerminationHearingRecord from './TerminationHearingRecord.js';
|
||||
import createFffClearance from './FffClearance.js';
|
||||
|
||||
// Batch 1: Organizational Hierarchy & User Management
|
||||
import createRole from './Role.js';
|
||||
@ -138,6 +141,9 @@ db.Location = createLocation(sequelize);
|
||||
db.Zone = createZone(sequelize);
|
||||
db.Region = createRegion(sequelize);
|
||||
db.State = createState(sequelize);
|
||||
db.TerminationScnResponse = createTerminationScnResponse(sequelize);
|
||||
db.TerminationHearingRecord = createTerminationHearingRecord(sequelize);
|
||||
db.FffClearance = createFffClearance(sequelize);
|
||||
|
||||
// Batch 1: Organizational Hierarchy & User Management
|
||||
db.Role = createRole(sequelize);
|
||||
|
||||
48
src/emailtemplates/applicant_shortlisted.html
Normal file
48
src/emailtemplates/applicant_shortlisted.html
Normal file
@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Great News: You Are Shortlisted!</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 20px auto; border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
|
||||
.header { background: #1B1B1B; color: #fff; padding: 30px; text-align: center; border-bottom: 5px solid #A11B1E; }
|
||||
.content { padding: 30px; }
|
||||
.footer { background: #f9f9f9; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||
.button { display: inline-block; padding: 12px 24px; background: #A11B1E; color: #fff; text-decoration: none; border-radius: 4px; margin-top: 20px; font-weight: bold; }
|
||||
.highlight-box { background: #fdf2f2; border: 1px dashed #A11B1E; padding: 20px; margin: 20px 0; border-radius: 8px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 24px; color: #A11B1E;">Royal Enfield</h1>
|
||||
<p style="margin: 5px 0 0; opacity: 0.8; font-weight: bold; font-size: 18px;">Congratulations!</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear <strong>{{applicantName}}</strong>,</p>
|
||||
<p>We are excited to inform you that your application (<strong>{{applicationId}}</strong>) for the Royal Enfield dealership in <strong>{{location}}</strong> has been <strong style="color: #A11B1E;">SHORTLISTED</strong> by our Dealer Development team.</p>
|
||||
|
||||
<div class="highlight-box">
|
||||
<p style="margin-top: 0;">You have successfully cleared the initial assessment phase. The next step will be an <strong>Initial Evaluation Interview</strong> with our Zonal and Regional managers.</p>
|
||||
<a href="{{portalLink}}" class="button">Log In to Onboarding Portal</a>
|
||||
</div>
|
||||
|
||||
<p><strong>What happens next?</strong></p>
|
||||
<ul>
|
||||
<li>Our team will schedule an interview slot for you soon.</li>
|
||||
<li>You will receive a separate notification with the meeting details (Online/Offline).</li>
|
||||
<li>Please ensure all your business documents are ready for the evaluation.</li>
|
||||
</ul>
|
||||
|
||||
<p>We look forward to meeting you and discussing this opportunity further.</p>
|
||||
|
||||
<p>Best regards,<br><strong>Dealer Development Team</strong><br>Royal Enfield</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© {{year}} Royal Enfield. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
43
src/emailtemplates/questionnaire_submitted.html
Normal file
43
src/emailtemplates/questionnaire_submitted.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Questionnaire Submitted Successfully</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 20px auto; border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
|
||||
.header { background: #A11B1E; color: #fff; padding: 30px; text-align: center; }
|
||||
.content { padding: 30px; }
|
||||
.footer { background: #f9f9f9; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||
.button { display: inline-block; padding: 12px 24px; background: #A11B1E; color: #fff; text-decoration: none; border-radius: 4px; margin-top: 20px; }
|
||||
.info-box { background: #fdf2f2; border-left: 4px solid #A11B1E; padding: 15px; margin: 20px 0; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0; font-size: 24px;">Royal Enfield Dealership</h1>
|
||||
<p style="margin: 5px 0 0; opacity: 0.8;">Questionnaire Submitted Successfully</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Dear <strong>{{applicantName}}</strong>,</p>
|
||||
<p>Thank you for submitting your dealership assessment questionnaire for <strong>{{location}}</strong>. We have successfully received your responses.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Application ID:</strong> {{applicationId}}<br>
|
||||
<strong>Status:</strong> Under Review
|
||||
</div>
|
||||
|
||||
<p>Our Dealer Development team will now review your submission. You will be notified of the next steps via email. You can track the status of your application anytime by logging into the Dealer Onboarding Portal.</p>
|
||||
|
||||
<p>If you have any questions in the meantime, please feel free to reach out to our support team.</p>
|
||||
|
||||
<p>Regards,<br><strong>Dealer Development Team</strong><br>Royal Enfield</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
© {{year}} Royal Enfield. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -270,6 +270,14 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
|
||||
reason: 'Questionnaire submitted by applicant',
|
||||
progressPercentage: 20
|
||||
});
|
||||
|
||||
// Send Acknowledgment Email
|
||||
EmailService.sendQuestionnaireAckEmail(
|
||||
application.email,
|
||||
application.applicantName,
|
||||
application.preferredLocation || application.city,
|
||||
application.applicationId
|
||||
).catch(err => console.error('Failed to send questionnaire ack email:', err));
|
||||
}
|
||||
|
||||
res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
const { FddAssignment, FddReport, AuditLog } = db;
|
||||
const { FddAssignment, FddReport, AuditLog, Application } = db;
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import { AUDIT_ACTIONS } from '../../common/config/constants.js';
|
||||
import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
|
||||
import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
|
||||
export const getAssignment = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@ -61,6 +62,18 @@ export const uploadReport = async (req: AuthRequest, res: Response) => {
|
||||
{ where: { id: assignmentId } }
|
||||
);
|
||||
|
||||
// Transition Application status (AUTOMATION)
|
||||
const assignmentRecord = await FddAssignment.findByPk(assignmentId);
|
||||
if (assignmentRecord) {
|
||||
const application = await Application.findByPk(assignmentRecord.applicationId);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user?.id || null, {
|
||||
reason: 'FDD Report submitted by agency',
|
||||
progressPercentage: 70
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ success: true, message: 'FDD Report uploaded', data: report });
|
||||
} catch (error) {
|
||||
console.error('Upload FDD report error:', error);
|
||||
|
||||
@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { Op } from 'sequelize';
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
|
||||
import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.js';
|
||||
import { sendOpportunityEmail, sendNonOpportunityEmail, sendShortlistedEmail } from '../../common/utils/email.service.js';
|
||||
import { syncLocationManagers } from '../master/syncHierarchy.service.js';
|
||||
import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
|
||||
@ -453,6 +453,14 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
|
||||
progressPercentage: 30
|
||||
});
|
||||
|
||||
// Send Shortlist Email
|
||||
sendShortlistedEmail(
|
||||
application.email,
|
||||
application.applicantName,
|
||||
application.preferredLocation || application.city,
|
||||
application.applicationId
|
||||
).catch(err => console.error('Failed to send shortlist email:', err));
|
||||
|
||||
// Add all assigned users as participants
|
||||
for (const userId of assignedTo) {
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
|
||||
@ -1,39 +1,54 @@
|
||||
import { Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
const { ConstitutionalChange, Outlet, User, Worknote } = db;
|
||||
import { AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js';
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js';
|
||||
import { ConstitutionalWorkflowService } from '../../services/ConstitutionalWorkflowService.js';
|
||||
|
||||
export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const {
|
||||
outletId, changeType, reason, newEntityDetails
|
||||
} = req.body;
|
||||
|
||||
const { outletId, changeType, reason, currentConstitution, newPartnersDetails, shareholdingPattern } = req.body;
|
||||
const requestId = `CC-${Date.now()}-${uuidv4().substring(0, 4).toUpperCase()}`;
|
||||
|
||||
// Store extra details in metadata
|
||||
const metadata = {
|
||||
newPartnersDetails,
|
||||
shareholdingPattern,
|
||||
currentConstitution
|
||||
};
|
||||
|
||||
const request = await ConstitutionalChange.create({
|
||||
requestId,
|
||||
outletId,
|
||||
outletId: outletId || null, // Optional for dealer-level changes
|
||||
dealerId: req.user.id,
|
||||
changeType,
|
||||
description: reason,
|
||||
currentStage: 'DD_ADMIN_REVIEW' as any,
|
||||
status: 'Pending',
|
||||
progressPercentage: 20,
|
||||
currentConstitution: currentConstitution || null,
|
||||
currentStage: CONSTITUTIONAL_STAGES.SUBMITTED,
|
||||
status: 'Submitted',
|
||||
progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED),
|
||||
metadata,
|
||||
documents: [],
|
||||
timeline: [{
|
||||
stage: 'Submitted',
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Request submitted'
|
||||
action: 'Request submitted',
|
||||
remarks: reason
|
||||
}]
|
||||
});
|
||||
|
||||
await db.AuditLog.create({
|
||||
userId: req.user.id,
|
||||
action: AUDIT_ACTIONS.CREATED,
|
||||
entityType: 'constitutional_change',
|
||||
entityId: request.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Constitutional change request submitted successfully',
|
||||
@ -57,16 +72,8 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
const requests = await ConstitutionalChange.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Outlet,
|
||||
as: 'outlet',
|
||||
attributes: ['code', 'name']
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['fullName']
|
||||
}
|
||||
{ model: Outlet, as: 'outlet', attributes: ['code', 'name'] },
|
||||
{ model: User, as: 'dealer', attributes: ['fullName'] }
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
@ -81,34 +88,19 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const request = await ConstitutionalChange.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
},
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr },
|
||||
include: [
|
||||
{
|
||||
model: Outlet,
|
||||
as: 'outlet'
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['fullName', 'email']
|
||||
},
|
||||
{
|
||||
model: Worknote,
|
||||
as: 'worknotes'
|
||||
}
|
||||
{ model: Outlet, as: 'outlet' },
|
||||
{ model: User, as: 'dealer', attributes: ['fullName', 'email'] },
|
||||
{ model: Worknote, as: 'worknotes' }
|
||||
]
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
res.json({ success: true, request });
|
||||
} catch (error) {
|
||||
@ -122,74 +114,77 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const { id } = req.params;
|
||||
const { action, comments } = req.body;
|
||||
const idStr = String(id);
|
||||
const { action, comments } = req.body; // Approve, Reject, Send Back
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const request = await ConstitutionalChange.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
}
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
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 (action === 'Reject') {
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
|
||||
action: 'Rejected',
|
||||
status: 'Rejected',
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName
|
||||
});
|
||||
} else {
|
||||
// Multi-level approval flow as per SRS 12.2.4
|
||||
const stageFlow: Record<string, string> = {
|
||||
[CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.HEAD_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL,
|
||||
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.LEGAL_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.COMPLETED
|
||||
};
|
||||
|
||||
const nextStage = stageFlow[request.currentStage];
|
||||
if (!nextStage) return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' });
|
||||
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, nextStage, req.user.id, {
|
||||
action: action === 'Approve' ? `Approved to ${nextStage}` : action,
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName
|
||||
});
|
||||
}
|
||||
|
||||
const stageFlow: Record<string, string> = {
|
||||
'DD_ADMIN_REVIEW': 'LEGAL_REVIEW',
|
||||
'LEGAL_REVIEW': 'NBH_APPROVAL',
|
||||
'NBH_APPROVAL': 'FINANCE_CLEARANCE',
|
||||
'FINANCE_CLEARANCE': 'COMPLETED'
|
||||
};
|
||||
|
||||
const currentStage = request.currentStage as string;
|
||||
let nextStage = currentStage;
|
||||
let finalStatus = action;
|
||||
|
||||
if (action === 'Approve') {
|
||||
nextStage = stageFlow[currentStage] || currentStage;
|
||||
finalStatus = nextStage === 'COMPLETED' ? 'Completed' : `Pending ${nextStage.replace('_', ' ')}`;
|
||||
} else if (action === 'Reject') {
|
||||
nextStage = 'REJECTED';
|
||||
finalStatus = 'Rejected';
|
||||
}
|
||||
|
||||
const timeline = [...request.timeline, {
|
||||
stage: currentStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action,
|
||||
remarks: comments
|
||||
}];
|
||||
|
||||
await request.update({
|
||||
status: finalStatus,
|
||||
currentStage: nextStage as any,
|
||||
timeline,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: `Request ${action.toLowerCase()} successfully` });
|
||||
res.json({ success: true, message: `Request ${action.toLowerCase()}ed successfully` });
|
||||
} catch (error) {
|
||||
console.error('Take action error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error processing action' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns document checklist based on target constitution
|
||||
*/
|
||||
export const getChecklist = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { targetConstitution } = req.query;
|
||||
if (!targetConstitution) return res.status(400).json({ success: false, message: 'targetConstitution is required' });
|
||||
|
||||
const checklist = ConstitutionalWorkflowService.getDocumentChecklist(targetConstitution as string);
|
||||
res.json({ success: true, checklist });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: 'Error fetching checklist' });
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { documents } = req.body;
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const request = await ConstitutionalChange.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
}
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
|
||||
14
src/modules/self-service/constitutional.routes.ts
Normal file
14
src/modules/self-service/constitutional.routes.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import * as constitutionalController from './constitutional.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
// Constitutional change routes (Base at /)
|
||||
router.post('/', authenticate as any, constitutionalController.submitRequest);
|
||||
router.get('/checklist', authenticate as any, constitutionalController.getChecklist);
|
||||
router.get('/', authenticate as any, constitutionalController.getRequests);
|
||||
router.get('/:id', authenticate as any, constitutionalController.getRequestById);
|
||||
router.post('/:id/action', authenticate as any, constitutionalController.takeAction);
|
||||
router.post('/:id/documents', authenticate as any, constitutionalController.uploadDocuments);
|
||||
|
||||
export default router;
|
||||
@ -512,14 +512,12 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ success: false, message: 'Document type is required' });
|
||||
}
|
||||
|
||||
const idStr = String(id);
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
// Only search by requestId since frontend sends requestId, not UUID
|
||||
const request = await RelocationRequest.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
}
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
@ -575,14 +573,12 @@ export const verifyDocument = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const idStr = String(id);
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
// Search by UUID or requestId for the request
|
||||
const request = await RelocationRequest.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
}
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
|
||||
@ -6,76 +6,37 @@ import { Op, Transaction } from 'sequelize';
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import ExternalMocksService from '../../common/utils/externalMocks.service.js';
|
||||
|
||||
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
|
||||
|
||||
// Generate unique resignation ID
|
||||
const generateResignationId = async (): Promise<string> => {
|
||||
const count = await db.Resignation.count();
|
||||
return `RES-${String(count + 1).padStart(3, '0')}`;
|
||||
};
|
||||
|
||||
// Calculate progress percentage based on stage
|
||||
const calculateProgress = (stage: string): number => {
|
||||
const stageProgress: Record<string, number> = {
|
||||
[RESIGNATION_STAGES.ASM]: 15,
|
||||
[RESIGNATION_STAGES.RBM]: 30,
|
||||
[RESIGNATION_STAGES.ZBH]: 40,
|
||||
[RESIGNATION_STAGES.DD_LEAD]: 50,
|
||||
[RESIGNATION_STAGES.NBH]: 60,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: 65,
|
||||
[RESIGNATION_STAGES.LEGAL]: 70,
|
||||
[RESIGNATION_STAGES.SPARES_CLEARANCE]: 75,
|
||||
[RESIGNATION_STAGES.SERVICE_CLEARANCE]: 80,
|
||||
[RESIGNATION_STAGES.ACCOUNTS_CLEARANCE]: 85,
|
||||
[RESIGNATION_STAGES.FINANCE]: 90,
|
||||
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
|
||||
[RESIGNATION_STAGES.COMPLETED]: 100,
|
||||
[RESIGNATION_STAGES.REJECTED]: 0
|
||||
};
|
||||
return stageProgress[stage] || 0;
|
||||
};
|
||||
|
||||
// Create resignation request (Dealer only)
|
||||
export const createResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
|
||||
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
|
||||
const dealerId = req.user.id;
|
||||
|
||||
// Verify outlet belongs to dealer
|
||||
const outlet = await db.Outlet.findOne({
|
||||
where: { id: outletId, dealerId }
|
||||
});
|
||||
|
||||
const outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } });
|
||||
if (!outlet) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Outlet not found or does not belong to you'
|
||||
});
|
||||
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
|
||||
}
|
||||
|
||||
// Check if outlet already has active resignation
|
||||
const existingResignation = await db.Resignation.findOne({
|
||||
where: {
|
||||
outletId,
|
||||
status: { [Op.notIn]: ['Completed', 'Rejected'] }
|
||||
}
|
||||
where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected'] } }
|
||||
});
|
||||
|
||||
if (existingResignation) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'This outlet already has an active resignation request'
|
||||
});
|
||||
return res.status(400).json({ success: false, message: 'This outlet already has an active resignation request' });
|
||||
}
|
||||
|
||||
// Generate resignation ID
|
||||
const resignationId = await generateResignationId();
|
||||
|
||||
// Create resignation
|
||||
const resignation = await db.Resignation.create({
|
||||
resignationId,
|
||||
outletId,
|
||||
@ -87,7 +48,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
additionalInfo,
|
||||
currentStage: RESIGNATION_STAGES.ASM,
|
||||
status: 'ASM Review',
|
||||
progressPercentage: 15,
|
||||
progressPercentage: ResignationWorkflowService.calculateProgress(RESIGNATION_STAGES.ASM),
|
||||
submittedOn: new Date(),
|
||||
documents: [],
|
||||
timeline: [{
|
||||
@ -98,12 +59,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
}]
|
||||
}, { transaction });
|
||||
|
||||
// Update outlet status
|
||||
await outlet.update({
|
||||
status: 'Pending Resignation'
|
||||
}, { transaction });
|
||||
|
||||
// Create audit log
|
||||
await outlet.update({ status: 'Pending Resignation' }, { transaction });
|
||||
await db.AuditLog.create({
|
||||
userId: req.user.id,
|
||||
action: AUDIT_ACTIONS.CREATED,
|
||||
@ -112,16 +68,8 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
logger.info(`Resignation request created: ${resignationId} by dealer: ${req.user.email}`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Resignation request submitted successfully',
|
||||
resignationId: resignation.resignationId,
|
||||
resignation: resignation.toJSON()
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, message: 'Resignation request submitted successfully', resignationId, resignation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error creating resignation:', error);
|
||||
@ -129,110 +77,50 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
|
||||
}
|
||||
};
|
||||
|
||||
// Get resignations list (role-based filtering)
|
||||
// Get all resignation requests
|
||||
export const getResignations = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { status, page = '1', limit = '10' } = req.query as { status?: string, page?: string, limit?: string };
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// Build where clause based on user role
|
||||
let where: any = {};
|
||||
const where: any = {};
|
||||
|
||||
if (req.user.roleCode === ROLES.DEALER) {
|
||||
where.dealerId = req.user.id;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
// Get resignations
|
||||
const { count, rows: resignations } = await db.Resignation.findAndCountAll({
|
||||
const resignations = await db.Resignation.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: db.Outlet,
|
||||
as: 'outlet'
|
||||
},
|
||||
{
|
||||
model: db.User,
|
||||
as: 'dealer',
|
||||
attributes: ['id', 'fullName', 'email', 'mobileNumber']
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: parseInt(limit),
|
||||
offset: offset
|
||||
include: [{ model: db.Outlet, as: 'outlet' }],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
resignations,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
pages: Math.ceil(count / parseInt(limit)),
|
||||
limit: parseInt(limit)
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, resignations });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching resignations:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get resignation details
|
||||
// Get resignation by ID
|
||||
export const getResignationById = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const resignation = await db.Resignation.findOne({
|
||||
where: { id },
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr },
|
||||
include: [
|
||||
{
|
||||
model: db.Outlet,
|
||||
as: 'outlet',
|
||||
include: [
|
||||
{
|
||||
model: db.User,
|
||||
as: 'dealer',
|
||||
attributes: ['id', 'fullName', 'email', 'mobileNumber']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: db.Worknote,
|
||||
as: 'worknotes'
|
||||
}
|
||||
],
|
||||
order: [[{ model: db.Worknote, as: 'worknotes' }, 'createdAt', 'DESC']]
|
||||
{ model: db.Outlet, as: 'outlet' },
|
||||
{ model: db.User, as: 'dealer', attributes: ['id', 'fullName', 'email'] },
|
||||
{ model: db.ResignationDocument, as: 'uploadedDocuments' }
|
||||
]
|
||||
});
|
||||
|
||||
if (!resignation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Resignation not found'
|
||||
});
|
||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||
}
|
||||
|
||||
// Check access permissions
|
||||
if (req.user.roleCode === ROLES.DEALER && resignation.dealerId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Access denied'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
resignation
|
||||
});
|
||||
|
||||
res.json({ success: true, resignation });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching resignation details:', error);
|
||||
logger.error('Error fetching resignation:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
@ -240,25 +128,22 @@ export const getResignationById = async (req: AuthRequest, res: Response, next:
|
||||
// Approve resignation (move to next stage)
|
||||
export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { remarks } = req.body;
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const resignation = await db.Resignation.findByPk(id, {
|
||||
const resignation = await db.Resignation.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr },
|
||||
include: [{ model: db.Outlet, as: 'outlet' }]
|
||||
});
|
||||
|
||||
if (!resignation) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Resignation not found'
|
||||
});
|
||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||
}
|
||||
|
||||
// Determine next stage based on current stage
|
||||
const stageFlow: Record<string, string> = {
|
||||
[RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM,
|
||||
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
|
||||
@ -266,107 +151,62 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH,
|
||||
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
|
||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.SPARES_CLEARANCE,
|
||||
[RESIGNATION_STAGES.SPARES_CLEARANCE]: RESIGNATION_STAGES.SERVICE_CLEARANCE,
|
||||
[RESIGNATION_STAGES.SERVICE_CLEARANCE]: RESIGNATION_STAGES.ACCOUNTS_CLEARANCE,
|
||||
[RESIGNATION_STAGES.ACCOUNTS_CLEARANCE]: RESIGNATION_STAGES.FINANCE,
|
||||
[RESIGNATION_STAGES.FINANCE]: RESIGNATION_STAGES.FNF_INITIATED,
|
||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.FNF_INITIATED,
|
||||
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
|
||||
};
|
||||
|
||||
const nextStage = stageFlow[resignation.currentStage];
|
||||
|
||||
if (!nextStage) {
|
||||
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' });
|
||||
}
|
||||
|
||||
// Update resignation
|
||||
const timeline = [...resignation.timeline, {
|
||||
stage: nextStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Approved',
|
||||
remarks
|
||||
}];
|
||||
// Transition via Workflow Service
|
||||
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
|
||||
remarks,
|
||||
status: nextStage === RESIGNATION_STAGES.COMPLETED ? 'Completed' : `${nextStage} Review`
|
||||
});
|
||||
|
||||
await resignation.update({
|
||||
currentStage: nextStage as any,
|
||||
status: nextStage === RESIGNATION_STAGES.COMPLETED ? 'Completed' : `${nextStage} Review`,
|
||||
progressPercentage: calculateProgress(nextStage),
|
||||
timeline
|
||||
}, { transaction });
|
||||
|
||||
// If completed, update outlet status and sync with SAP
|
||||
// Special logic for F&F and Completion
|
||||
if (nextStage === RESIGNATION_STAGES.COMPLETED) {
|
||||
await (resignation as any).outlet.update({
|
||||
status: 'Closed'
|
||||
}, { transaction });
|
||||
|
||||
// Trigger Mock SAP Sync
|
||||
await (resignation as any).outlet.update({ status: 'Closed' }, { transaction });
|
||||
ExternalMocksService.mockSyncDealerStatusToSap((resignation as any).outlet.code, 'Inactive')
|
||||
.catch(err => logger.error('Error syncing resignation completion to SAP:', err));
|
||||
}
|
||||
|
||||
// If F&F Initiated, create F&F record and fetch mock SAP dues
|
||||
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code);
|
||||
const today = new Date();
|
||||
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||
if (lwd && today < new Date(lwd)) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ success: false, message: `F&F can only be initiated on or after the Last Working Day (${lwd}).` });
|
||||
}
|
||||
|
||||
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code);
|
||||
const fnf = await db.FnF.create({
|
||||
resignationId: resignation.id,
|
||||
outletId: resignation.outletId,
|
||||
dealerId: resignation.dealerId,
|
||||
status: 'Initiated',
|
||||
totalReceivables: sapDues.data.outstandingInvoices,
|
||||
totalPayables: sapDues.data.securityDeposit,
|
||||
resignationId: resignation.id, outletId: resignation.outletId, dealerId: resignation.dealerId,
|
||||
status: 'Initiated', totalReceivables: sapDues.data.outstandingInvoices, totalPayables: sapDues.data.securityDeposit,
|
||||
netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices
|
||||
}, { transaction });
|
||||
|
||||
// Create initial line items from SAP data
|
||||
await db.FnFLineItem.bulkCreate([
|
||||
{
|
||||
fnfId: fnf.id,
|
||||
itemType: 'Receivable',
|
||||
description: 'Outstanding Invoices from SAP',
|
||||
department: 'Finance',
|
||||
amount: sapDues.data.outstandingInvoices,
|
||||
addedBy: req.user.id
|
||||
},
|
||||
{
|
||||
fnfId: fnf.id,
|
||||
itemType: 'Payable',
|
||||
description: 'Security Deposit from SAP',
|
||||
department: 'Finance',
|
||||
amount: sapDues.data.securityDeposit,
|
||||
addedBy: req.user.id
|
||||
}
|
||||
{ fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: req.user.id },
|
||||
{ fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.id }
|
||||
], { transaction });
|
||||
|
||||
logger.info(`F&F record and mock line items created for resignation: ${resignation.resignationId}`);
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create audit log
|
||||
await db.AuditLog.create({
|
||||
userId: req.user.id,
|
||||
action: AUDIT_ACTIONS.APPROVED,
|
||||
entityType: 'resignation',
|
||||
entityId: resignation.id
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
logger.info(`Resignation ${resignation.resignationId} approved to ${nextStage} by ${req.user.email}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Resignation approved successfully',
|
||||
nextStage,
|
||||
resignation
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Resignation approved successfully', nextStage, resignation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error approving resignation:', error);
|
||||
@ -377,72 +217,35 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
// Reject resignation
|
||||
export const rejectResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { reason } = req.body;
|
||||
|
||||
if (!reason) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Rejection reason is required'
|
||||
});
|
||||
return res.status(400).json({ success: false, message: 'Rejection reason is required' });
|
||||
}
|
||||
|
||||
const resignation = await db.Resignation.findByPk(id, {
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
const resignation = await db.Resignation.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr },
|
||||
include: [{ model: db.Outlet, as: 'outlet' }]
|
||||
});
|
||||
|
||||
if (!resignation) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Resignation not found'
|
||||
});
|
||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||
}
|
||||
|
||||
// Update resignation
|
||||
const timeline = [...resignation.timeline, {
|
||||
stage: 'Rejected',
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
|
||||
remarks: reason,
|
||||
action: 'Rejected',
|
||||
reason
|
||||
}];
|
||||
|
||||
await resignation.update({
|
||||
currentStage: RESIGNATION_STAGES.REJECTED,
|
||||
status: 'Rejected',
|
||||
progressPercentage: 0,
|
||||
rejectionReason: reason,
|
||||
timeline
|
||||
}, { transaction });
|
||||
|
||||
// Update outlet status back to Active
|
||||
await (resignation as any).outlet.update({
|
||||
status: 'Active'
|
||||
}, { transaction });
|
||||
|
||||
// Create audit log
|
||||
await db.AuditLog.create({
|
||||
userId: req.user.id,
|
||||
action: AUDIT_ACTIONS.REJECTED,
|
||||
entityType: 'resignation',
|
||||
entityId: resignation.id
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
logger.info(`Resignation ${resignation.resignationId} rejected by ${req.user.email}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Resignation rejected',
|
||||
resignation
|
||||
status: 'Rejected'
|
||||
});
|
||||
|
||||
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
|
||||
await transaction.commit();
|
||||
res.json({ success: true, message: 'Resignation rejected', resignation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error rejecting resignation:', error);
|
||||
@ -450,22 +253,114 @@ export const rejectResignation = async (req: AuthRequest, res: Response, next: N
|
||||
}
|
||||
};
|
||||
|
||||
// Withdraw resignation
|
||||
export const withdrawResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { reason } = req.body;
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const resignation = await db.Resignation.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr },
|
||||
include: [{ model: db.Outlet, as: 'outlet' }]
|
||||
});
|
||||
if (!resignation) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||
}
|
||||
|
||||
const restrictedStages = [RESIGNATION_STAGES.NBH, RESIGNATION_STAGES.DD_ADMIN, RESIGNATION_STAGES.LEGAL, RESIGNATION_STAGES.FNF_INITIATED, RESIGNATION_STAGES.COMPLETED];
|
||||
if (restrictedStages.includes(resignation.currentStage)) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ success: false, message: 'Withdrawal not allowed after NBH evaluation stage.' });
|
||||
}
|
||||
|
||||
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
|
||||
remarks: reason,
|
||||
action: 'Withdrawn',
|
||||
status: 'Withdrawn'
|
||||
});
|
||||
|
||||
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
|
||||
await transaction.commit();
|
||||
res.json({ success: true, message: 'Resignation withdrawn successfully' });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error withdrawing resignation:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Send back resignation
|
||||
export const sendBackResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { targetStage, remarks } = req.body;
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const resignation = await db.Resignation.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr }
|
||||
});
|
||||
if (!resignation) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||
}
|
||||
|
||||
const stageFlowBack: Record<string, string> = {
|
||||
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ASM,
|
||||
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.RBM,
|
||||
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.ZBH,
|
||||
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_LEAD,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.NBH,
|
||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN
|
||||
};
|
||||
|
||||
const prevStage = targetStage || stageFlowBack[resignation.currentStage];
|
||||
if (!prevStage) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({ success: false, message: 'Cannot send back from current stage' });
|
||||
}
|
||||
|
||||
await ResignationWorkflowService.transitionResignation(resignation, prevStage, req.user.id, {
|
||||
remarks,
|
||||
action: 'Sent Back',
|
||||
status: `${prevStage} Review (Sent Back)`
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
res.json({ success: true, message: `Resignation sent back to ${prevStage}` });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error sending back resignation:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update departmental clearance
|
||||
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { department, cleared, remarks } = req.body;
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const resignation = await db.Resignation.findByPk(id);
|
||||
const resignation = await db.Resignation.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr }
|
||||
});
|
||||
if (!resignation) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||
}
|
||||
|
||||
const clearances = { ...resignation.departmentalClearances, [department]: cleared };
|
||||
|
||||
await resignation.update({
|
||||
departmentalClearances: clearances,
|
||||
timeline: [...resignation.timeline, {
|
||||
|
||||
@ -9,6 +9,8 @@ router.get('/', authenticate as any, resignationController.getResignations);
|
||||
router.get('/:id', authenticate as any, resignationController.getResignationById);
|
||||
router.put('/:id/approve', authenticate as any, resignationController.approveResignation);
|
||||
router.put('/:id/reject', authenticate as any, resignationController.rejectResignation);
|
||||
router.put('/:id/withdraw', authenticate as any, resignationController.withdrawResignation);
|
||||
router.put('/:id/sendback', authenticate as any, resignationController.sendBackResignation);
|
||||
router.put('/:id/clearance', authenticate as any, resignationController.updateClearance);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -2,19 +2,15 @@ import express from 'express';
|
||||
const router = express.Router();
|
||||
|
||||
import resignationRoutes from './resignation.routes.js';
|
||||
import * as constitutionalController from './constitutional.controller.js';
|
||||
import constitutionalRoutes from './constitutional.routes.js';
|
||||
import * as relocationController from './relocation.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
// Resignations submodule
|
||||
router.use('/resignations', resignationRoutes);
|
||||
|
||||
// Constitutional changes submodule
|
||||
router.post('/constitutional', authenticate as any, constitutionalController.submitRequest);
|
||||
router.get('/constitutional', authenticate as any, constitutionalController.getRequests);
|
||||
router.get('/constitutional/:id', authenticate as any, constitutionalController.getRequestById);
|
||||
router.post('/constitutional/:id/action', authenticate as any, constitutionalController.takeAction);
|
||||
router.post('/constitutional/:id/documents', authenticate as any, constitutionalController.uploadDocuments);
|
||||
// Constitutional changes submodule - using the new dedicated router
|
||||
router.use('/constitutional', constitutionalRoutes);
|
||||
|
||||
// Relocation submodule
|
||||
router.post('/relocation', authenticate as any, relocationController.submitRequest);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest } = db;
|
||||
const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance } = db;
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
|
||||
export const getOnboardingPayments = async (req: Request, res: Response) => {
|
||||
@ -13,7 +13,6 @@ export const getOnboardingPayments = async (req: Request, res: Response) => {
|
||||
}],
|
||||
order: [['createdAt', 'ASC']]
|
||||
});
|
||||
|
||||
res.json({ success: true, payments });
|
||||
} catch (error) {
|
||||
console.error('Get onboarding payments error:', error);
|
||||
@ -21,53 +20,12 @@ export const getOnboardingPayments = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getFnFSettlements = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settlements = await FnF.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Resignation,
|
||||
as: 'resignation',
|
||||
attributes: ['id', 'resignationId']
|
||||
},
|
||||
{
|
||||
model: TerminationRequest,
|
||||
as: 'terminationRequest',
|
||||
attributes: ['id', 'status', 'category']
|
||||
},
|
||||
{
|
||||
model: Outlet,
|
||||
as: 'outlet',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['fullName', 'id']
|
||||
}]
|
||||
},
|
||||
{
|
||||
model: FnFLineItem,
|
||||
as: 'lineItems'
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({ success: true, settlements });
|
||||
} catch (error) {
|
||||
console.error('Get F&F settlements error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error fetching settlements' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePayment = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { paidDate, amount, transactionReference, status } = req.body;
|
||||
|
||||
const payment = await FinancePayment.findByPk(id);
|
||||
if (!payment) {
|
||||
return res.status(404).json({ success: false, message: 'Payment not found' });
|
||||
}
|
||||
if (!payment) return res.status(404).json({ success: false, message: 'Payment not found' });
|
||||
|
||||
await payment.update({
|
||||
paymentDate: paidDate || payment.paymentDate,
|
||||
@ -76,10 +34,8 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
|
||||
paymentStatus: status || payment.paymentStatus,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Payment updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Update payment error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error updating payment' });
|
||||
}
|
||||
};
|
||||
@ -87,28 +43,38 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
|
||||
export const updateFnF = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
finalSettlementAmount, status
|
||||
} = req.body;
|
||||
|
||||
const { finalSettlementAmount, status } = req.body;
|
||||
const fnf = await FnF.findByPk(id);
|
||||
if (!fnf) {
|
||||
return res.status(404).json({ success: false, message: 'F&F settlement not found' });
|
||||
}
|
||||
|
||||
if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' });
|
||||
await fnf.update({
|
||||
status: status || fnf.status,
|
||||
netAmount: finalSettlementAmount || fnf.netAmount,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'F&F settlement updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Update F&F error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error updating F&F settlement' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getFnFSettlements = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settlements = await FnF.findAll({
|
||||
include: [
|
||||
{ model: Resignation, as: 'resignation', attributes: ['id', 'resignationId'] },
|
||||
{ model: TerminationRequest, as: 'terminationRequest', attributes: ['id', 'status', 'category'] },
|
||||
{ model: Outlet, as: 'outlet', include: [{ model: User, as: 'dealer', attributes: ['fullName', 'id'] }] },
|
||||
{ model: FnFLineItem, as: 'lineItems' },
|
||||
{ model: FffClearance, as: 'clearances' }
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
res.json({ success: true, settlements });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: 'Error fetching settlements' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getFnFById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@ -116,18 +82,14 @@ export const getFnFById = async (req: Request, res: Response) => {
|
||||
include: [
|
||||
{ model: Resignation, as: 'resignation' },
|
||||
{ model: TerminationRequest, as: 'terminationRequest' },
|
||||
{
|
||||
model: Outlet, as: 'outlet',
|
||||
include: [{ model: User, as: 'dealer' }]
|
||||
},
|
||||
{ model: FnFLineItem, as: 'lineItems' }
|
||||
{ model: Outlet, as: 'outlet', include: [{ model: User, as: 'dealer' }] },
|
||||
{ model: FnFLineItem, as: 'lineItems' },
|
||||
{ model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
|
||||
res.json({ success: true, fnf });
|
||||
} catch (error) {
|
||||
console.error('Get F&F by ID error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error fetching F&F' });
|
||||
}
|
||||
};
|
||||
@ -136,19 +98,11 @@ export const addLineItem = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { itemType, description, department, amount } = req.body;
|
||||
|
||||
const lineItem = await FnFLineItem.create({
|
||||
fnfId: id,
|
||||
itemType,
|
||||
description,
|
||||
department,
|
||||
amount,
|
||||
addedBy: req.user?.id
|
||||
fnfId: id, itemType, description, department, amount, addedBy: req.user?.id
|
||||
});
|
||||
|
||||
res.json({ success: true, lineItem });
|
||||
} catch (error) {
|
||||
console.error('Add line item error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error adding line item' });
|
||||
}
|
||||
};
|
||||
@ -157,14 +111,11 @@ export const updateLineItem = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { itemId } = req.params;
|
||||
const { description, department, amount } = req.body;
|
||||
|
||||
const lineItem = await FnFLineItem.findByPk(itemId);
|
||||
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
||||
|
||||
await lineItem.update({ description, department, amount });
|
||||
res.json({ success: true, lineItem });
|
||||
} catch (error) {
|
||||
console.error('Update line item error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error updating line item' });
|
||||
}
|
||||
};
|
||||
@ -174,24 +125,41 @@ export const deleteLineItem = async (req: AuthRequest, res: Response) => {
|
||||
const { itemId } = req.params;
|
||||
const lineItem = await FnFLineItem.findByPk(itemId);
|
||||
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
||||
|
||||
await lineItem.destroy();
|
||||
res.json({ success: true, message: 'Line item deleted' });
|
||||
} catch (error) {
|
||||
console.error('Delete line item error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error deleting line item' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClearance = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id, clearanceId } = req.params;
|
||||
const { status, remarks, documentId } = req.body;
|
||||
const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } });
|
||||
if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' });
|
||||
|
||||
await clearance.update({
|
||||
status, remarks, documentId,
|
||||
clearedBy: req.user?.id,
|
||||
clearedAt: status === 'Cleared' ? new Date() : null
|
||||
});
|
||||
res.json({ success: true, message: 'Clearance updated successfully', clearance });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: 'Error updating clearance' });
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateFnF = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const fnf = await FnF.findByPk(id, {
|
||||
include: [{ model: FnFLineItem, as: 'lineItems' }]
|
||||
include: [{ model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' }]
|
||||
});
|
||||
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
|
||||
|
||||
const lineItems = (fnf as any).lineItems || [];
|
||||
const clearances = (fnf as any).clearances || [];
|
||||
|
||||
let totalReceivables = 0;
|
||||
let totalPayables = 0;
|
||||
@ -205,17 +173,23 @@ export const calculateFnF = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
|
||||
const netAmount = totalPayables - totalReceivables - totalDeductions;
|
||||
const allCleared = clearances.length > 0 && clearances.every((c: any) => c.status === 'Cleared' || c.status === 'N/A');
|
||||
|
||||
// Calculate progress percentage based on clearances
|
||||
let progressPercentage = 0;
|
||||
if (clearances.length > 0) {
|
||||
const clearedCount = clearances.filter((c: any) => c.status === 'Cleared' || c.status === 'N/A').length;
|
||||
progressPercentage = Math.round((clearedCount / clearances.length) * 100);
|
||||
}
|
||||
|
||||
await fnf.update({
|
||||
totalReceivables,
|
||||
totalPayables,
|
||||
netAmount,
|
||||
status: 'Calculated'
|
||||
totalReceivables, totalPayables, netAmount,
|
||||
status: allCleared ? 'Cleared' : 'Calculated',
|
||||
progressPercentage
|
||||
});
|
||||
|
||||
res.json({ success: true, fnf });
|
||||
res.json({ success: true, fnf, allCleared, progressPercentage });
|
||||
} catch (error) {
|
||||
console.error('Calculate F&F error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error calculating F&F' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -16,6 +16,8 @@ router.put('/payments/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any
|
||||
router.put('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateFnF);
|
||||
router.post('/fnf/:id/calculate', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.calculateFnF);
|
||||
|
||||
router.put('/fnf/:id/clearances/:clearanceId', checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance);
|
||||
|
||||
// Line item management
|
||||
router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem);
|
||||
router.put('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateLineItem);
|
||||
|
||||
@ -6,26 +6,7 @@ import { Transaction } from 'sequelize';
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import ExternalMocksService from '../../common/utils/externalMocks.service.js';
|
||||
|
||||
// Calculate progress percentage based on stage
|
||||
const calculateProgress = (stage: string): number => {
|
||||
const stageProgress: Record<string, number> = {
|
||||
[TERMINATION_STAGES.SUBMITTED]: 10,
|
||||
[TERMINATION_STAGES.RBM_REVIEW]: 20,
|
||||
[TERMINATION_STAGES.ZBH_REVIEW]: 30,
|
||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: 40,
|
||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: 50,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: 60,
|
||||
[TERMINATION_STAGES.SCN_ISSUED]: 70,
|
||||
[TERMINATION_STAGES.PERSONAL_HEARING]: 75,
|
||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: 80,
|
||||
[TERMINATION_STAGES.CCO_APPROVAL]: 85,
|
||||
[TERMINATION_STAGES.CEO_APPROVAL]: 90,
|
||||
[TERMINATION_STAGES.LEGAL_LETTER]: 95,
|
||||
[TERMINATION_STAGES.TERMINATED]: 100,
|
||||
[TERMINATION_STAGES.REJECTED]: 0
|
||||
};
|
||||
return stageProgress[stage] || 0;
|
||||
};
|
||||
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||
|
||||
// Create termination request
|
||||
export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
@ -34,10 +15,6 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { dealerId, category, reason, proposedLwd, comments } = req.body;
|
||||
|
||||
// Restriction: Only ASM or RBM can initiate (as per user request: "ASM or RBM can initiate")
|
||||
// Note: Check existing roles in constants. ROLES.RBM exists. ASM might be DD or similar.
|
||||
// For now, I'll allow ASM (mapped to DD/Initiator) and RBM.
|
||||
|
||||
const termination = await db.TerminationRequest.create({
|
||||
dealerId,
|
||||
category,
|
||||
@ -47,6 +24,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
initiatedBy: req.user.id,
|
||||
currentStage: TERMINATION_STAGES.SUBMITTED,
|
||||
status: 'Submitted',
|
||||
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.SUBMITTED),
|
||||
timeline: [{
|
||||
stage: 'Submitted',
|
||||
timestamp: new Date(),
|
||||
@ -72,14 +50,22 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
||||
}
|
||||
};
|
||||
|
||||
// Get all terminations
|
||||
// Get all termination requests
|
||||
export const getTerminations = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { dealerId } = req.query;
|
||||
const where: any = {};
|
||||
|
||||
if (dealerId) where.dealerId = dealerId;
|
||||
if (req.user.roleCode === ROLES.DEALER) {
|
||||
const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } });
|
||||
if (dealer) where.dealerId = dealer.id;
|
||||
}
|
||||
|
||||
const terminations = await db.TerminationRequest.findAll({
|
||||
include: [
|
||||
{ model: db.Dealer, as: 'dealer' },
|
||||
{ model: db.User, as: 'initiator', attributes: ['id', 'fullName', 'roleCode'] }
|
||||
],
|
||||
where,
|
||||
include: [{ model: db.Dealer, as: 'dealer' }],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
res.json({ success: true, terminations });
|
||||
@ -89,17 +75,21 @@ export const getTerminations = async (req: AuthRequest, res: Response, next: Nex
|
||||
}
|
||||
};
|
||||
|
||||
// Get termination by ID
|
||||
// Get termination request by ID
|
||||
export const getTerminationById = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const termination = await db.TerminationRequest.findByPk(id, {
|
||||
include: [
|
||||
{ model: db.Dealer, as: 'dealer' },
|
||||
{ model: db.User, as: 'initiator', attributes: ['id', 'fullName', 'roleCode'] }
|
||||
{ model: db.User, as: 'initiator', attributes: ['id', 'fullName', 'email'] },
|
||||
{ model: db.FnF, as: 'fnfSettlement' }
|
||||
]
|
||||
});
|
||||
if (!termination) return res.status(404).json({ success: false, message: 'Termination not found' });
|
||||
|
||||
if (!termination) {
|
||||
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
||||
}
|
||||
res.json({ success: true, termination });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching termination:', error);
|
||||
@ -113,7 +103,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { id } = req.params;
|
||||
const { action, remarks } = req.body; // action: 'approve' | 'reject' | 'sendback'
|
||||
const { action, remarks } = req.body;
|
||||
|
||||
const termination = await db.TerminationRequest.findByPk(id);
|
||||
if (!termination) {
|
||||
@ -122,19 +112,12 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
}
|
||||
|
||||
if (action === 'reject') {
|
||||
await termination.update({
|
||||
currentStage: TERMINATION_STAGES.REJECTED,
|
||||
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
||||
action: 'Rejected',
|
||||
status: 'Rejected',
|
||||
timeline: [...termination.timeline, {
|
||||
stage: 'Rejected',
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Rejected',
|
||||
remarks
|
||||
}]
|
||||
}, { transaction });
|
||||
remarks
|
||||
});
|
||||
} else {
|
||||
// Approval flow
|
||||
const stageFlow: Record<string, string> = {
|
||||
[TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW,
|
||||
[TERMINATION_STAGES.RBM_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||
@ -156,19 +139,12 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
||||
}
|
||||
|
||||
await termination.update({
|
||||
currentStage: nextStage,
|
||||
status: nextStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : `${nextStage}`,
|
||||
timeline: [...termination.timeline, {
|
||||
stage: nextStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Approved/Moved',
|
||||
remarks
|
||||
}]
|
||||
}, { transaction });
|
||||
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
||||
remarks,
|
||||
status: nextStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : `${nextStage}`
|
||||
});
|
||||
|
||||
// If Terminated, create F&F record and fetch mock SAP dues
|
||||
// If Terminated, create F&F record and clearances
|
||||
if (nextStage === TERMINATION_STAGES.TERMINATED) {
|
||||
const dealer = await db.Dealer.findByPk(termination.dealerId);
|
||||
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap(dealer?.dealerCode || 'MOCK-001');
|
||||
@ -182,29 +158,21 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices
|
||||
}, { transaction });
|
||||
|
||||
// Create initial line items from SAP data
|
||||
await db.FnFLineItem.bulkCreate([
|
||||
{
|
||||
fnfId: fnf.id,
|
||||
itemType: 'Receivable',
|
||||
description: 'Outstanding Invoices from SAP',
|
||||
department: 'Finance',
|
||||
amount: sapDues.data.outstandingInvoices,
|
||||
addedBy: req.user.id
|
||||
},
|
||||
{
|
||||
fnfId: fnf.id,
|
||||
itemType: 'Payable',
|
||||
description: 'Security Deposit from SAP',
|
||||
department: 'Finance',
|
||||
amount: sapDues.data.securityDeposit,
|
||||
addedBy: req.user.id
|
||||
}
|
||||
{ fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: req.user.id },
|
||||
{ fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.id }
|
||||
], { transaction });
|
||||
|
||||
logger.info(`F&F record and mock line items created for termination: ${termination.id}`);
|
||||
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 }
|
||||
);
|
||||
|
||||
// Sync with Mock SAP
|
||||
if (dealer) {
|
||||
ExternalMocksService.mockSyncDealerStatusToSap(dealer.dealerCode, 'Inactive')
|
||||
.catch(err => logger.error('Error syncing termination to SAP:', err));
|
||||
@ -220,3 +188,47 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Submit SCN Response (Dealer Principal)
|
||||
export const submitScnResponse = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { terminationRequestId, responseBody, documents } = req.body;
|
||||
|
||||
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
||||
if (!termination) throw new Error('Termination request not found');
|
||||
|
||||
const response = await TerminationWorkflowService.handleScnResponse(termination, { responseBody, documents }, req.user.id);
|
||||
|
||||
await transaction.commit();
|
||||
res.status(201).json({ success: true, message: 'SCN Response submitted successfully', response });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error submitting SCN response:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Record Personal Hearing Outcome
|
||||
export const recordPersonalHearing = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction: Transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
if (!req.user) throw new Error('Unauthorized');
|
||||
const { terminationRequestId, attendees, summary, recommendation, momDocumentId } = req.body;
|
||||
|
||||
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
||||
if (!termination) throw new Error('Termination request not found');
|
||||
|
||||
const hearing = await TerminationWorkflowService.handleHearingOutcome(termination, { attendees, summary, recommendation, momDocumentId }, req.user.id);
|
||||
|
||||
await transaction.commit();
|
||||
res.status(201).json({ success: true, message: 'Hearing record saved', recommendation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
logger.error('Error recording hearing:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import {
|
||||
createTermination, getTerminations, getTerminationById, updateTerminationStatus
|
||||
createTermination, getTerminations, getTerminationById, updateTerminationStatus,
|
||||
submitScnResponse, recordPersonalHearing
|
||||
} from './termination.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
@ -11,5 +12,7 @@ router.post('/', createTermination);
|
||||
router.get('/', getTerminations);
|
||||
router.get('/:id', getTerminationById);
|
||||
router.put('/:id/status', updateTerminationStatus);
|
||||
router.post('/scn-response', submitScnResponse);
|
||||
router.post('/hearing-record', recordPersonalHearing);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -38,6 +38,20 @@ const seedTemplates = async () => {
|
||||
subject: 'New Application Assignment: {{applicationId}}',
|
||||
fileName: 'user_assigned.html',
|
||||
placeholders: ['userName', 'applicationId', 'dealerName', 'participantType', 'year']
|
||||
},
|
||||
{
|
||||
templateCode: 'QUESTIONNAIRE_SUBMITTED',
|
||||
description: 'Acknowledgement for questionnaire submission',
|
||||
subject: 'Questionnaire Submitted Successfully: {{applicationId}}',
|
||||
fileName: 'questionnaire_submitted.html',
|
||||
placeholders: ['applicantName', 'location', 'applicationId', 'year']
|
||||
},
|
||||
{
|
||||
templateCode: 'APPLICANT_SHORTLISTED',
|
||||
description: 'Notification for shortlisted applicants',
|
||||
subject: 'Congratulations! You are Shortlisted: {{applicationId}}',
|
||||
fileName: 'applicant_shortlisted.html',
|
||||
placeholders: ['applicantName', 'location', 'applicationId', 'portalLink', 'year']
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import db from './database/models/index.js';
|
||||
|
||||
// Import routes (Modular Monolith Structure)
|
||||
// Import routes (Modular Monolith Structure)
|
||||
import constitutionalRoutes from './modules/self-service/constitutional.routes.js';
|
||||
import authRoutes from './modules/auth/auth.routes.js';
|
||||
import onboardingRoutes from './modules/onboarding/onboarding.routes.js';
|
||||
import selfServiceRoutes from './modules/self-service/self-service.routes.js';
|
||||
@ -133,10 +134,7 @@ app.use('/api/termination', terminationRoutes);
|
||||
app.use('/api/applications', onboardingRoutes);
|
||||
app.use('/api/resignation', resignationRoutes);
|
||||
app.use('/api/resignations', resignationRoutes);
|
||||
app.use('/api/constitutional-change', (req: Request, res: Response, next: NextFunction) => {
|
||||
req.url = '/constitutional' + (req.url === '/' ? '' : req.url);
|
||||
next();
|
||||
}, selfServiceRoutes);
|
||||
app.use('/api/constitutional-change', constitutionalRoutes);
|
||||
|
||||
// Relocation routes - direct mount
|
||||
app.use('/api/relocation', relocationRoutes);
|
||||
|
||||
106
src/services/ConstitutionalWorkflowService.ts
Normal file
106
src/services/ConstitutionalWorkflowService.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import db from '../database/models/index.js';
|
||||
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, DOCUMENT_TYPES } from '../common/config/constants.js';
|
||||
|
||||
export class ConstitutionalWorkflowService {
|
||||
/**
|
||||
* Transitions a constitutional change request to a new stage
|
||||
*/
|
||||
static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) {
|
||||
const { action, status, remarks, userFullName } = options;
|
||||
|
||||
const updatedTimeline = [
|
||||
...request.timeline,
|
||||
{
|
||||
stage: targetStage,
|
||||
timestamp: new Date(),
|
||||
user: userFullName || 'System',
|
||||
action: action || `Moved to ${targetStage}`,
|
||||
remarks: remarks || ''
|
||||
}
|
||||
];
|
||||
|
||||
const updateData: any = {
|
||||
currentStage: targetStage,
|
||||
status: status || (targetStage === CONSTITUTIONAL_STAGES.COMPLETED ? 'Completed' : targetStage),
|
||||
progressPercentage: this.calculateProgress(targetStage),
|
||||
timeline: updatedTimeline,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
await request.update(updateData);
|
||||
|
||||
// Audit Log
|
||||
await db.AuditLog.create({
|
||||
userId,
|
||||
action: action === 'Reject' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED,
|
||||
entityType: 'constitutional_change',
|
||||
entityId: request.id
|
||||
});
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates progress percentage based on stage
|
||||
*/
|
||||
static calculateProgress(stage: string): number {
|
||||
const progress: Record<string, number> = {
|
||||
[CONSTITUTIONAL_STAGES.SUBMITTED]: 10,
|
||||
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: 20,
|
||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 30,
|
||||
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: 45,
|
||||
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: 60,
|
||||
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: 75,
|
||||
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85,
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95,
|
||||
[CONSTITUTIONAL_STAGES.COMPLETED]: 100,
|
||||
[CONSTITUTIONAL_STAGES.REJECTED]: 0
|
||||
};
|
||||
return progress[stage] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns mandatory document checklist based on target constitution
|
||||
* Per SRS Section 12.2.4
|
||||
*/
|
||||
static getDocumentChecklist(targetConstitution: string): string[] {
|
||||
const commonDocs = [
|
||||
DOCUMENT_TYPES.GST_CERTIFICATE,
|
||||
DOCUMENT_TYPES.PAN_CARD,
|
||||
DOCUMENT_TYPES.AADHAAR,
|
||||
DOCUMENT_TYPES.CANCELLED_CHECK,
|
||||
DOCUMENT_TYPES.OTHER // Declaration/Authorization Letter
|
||||
];
|
||||
|
||||
if (targetConstitution.includes('Partnership')) {
|
||||
return [
|
||||
...commonDocs,
|
||||
DOCUMENT_TYPES.PARTNERSHIP_DEED,
|
||||
'Business Purchase Agreement (BPA)',
|
||||
DOCUMENT_TYPES.FIRM_REGISTRATION
|
||||
];
|
||||
}
|
||||
|
||||
if (targetConstitution.includes('LLP')) {
|
||||
return [
|
||||
...commonDocs,
|
||||
DOCUMENT_TYPES.INCORPORATION_CERTIFICATE,
|
||||
'Business Purchase Agreement (BPA)',
|
||||
DOCUMENT_TYPES.LLP_AGREEMENT
|
||||
];
|
||||
}
|
||||
|
||||
if (targetConstitution.includes('Private Limited')) {
|
||||
return [
|
||||
...commonDocs,
|
||||
DOCUMENT_TYPES.MOA,
|
||||
DOCUMENT_TYPES.AOA,
|
||||
DOCUMENT_TYPES.INCORPORATION_CERTIFICATE,
|
||||
'Business Purchase Agreement (BPA)'
|
||||
];
|
||||
}
|
||||
|
||||
// Default/Proprietorship
|
||||
return commonDocs;
|
||||
}
|
||||
}
|
||||
103
src/services/ResignationWorkflowService.ts
Normal file
103
src/services/ResignationWorkflowService.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { AuditLog, User, Worknote } = db;
|
||||
import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js';
|
||||
|
||||
export class ResignationWorkflowService {
|
||||
/**
|
||||
* Standardized method to transition a resignation request status
|
||||
*/
|
||||
static async transitionResignation(resignation: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
||||
const { action, remarks, status } = metadata;
|
||||
|
||||
const updateData: any = {
|
||||
currentStage: targetStage,
|
||||
status: status || targetStage,
|
||||
progressPercentage: this.calculateProgress(targetStage),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// 1. Update Resignation Record
|
||||
await resignation.update(updateData);
|
||||
|
||||
// 2. Update Timeline (JSON array)
|
||||
const actor = userId ? await User.findByPk(userId) : null;
|
||||
const timelineEntry = {
|
||||
stage: targetStage,
|
||||
timestamp: new Date(),
|
||||
user: actor ? actor.fullName : 'System',
|
||||
action: action || `Approved to ${targetStage}`,
|
||||
remarks: remarks || ''
|
||||
};
|
||||
|
||||
const updatedTimeline = [...(resignation.timeline || []), timelineEntry];
|
||||
await resignation.update({ timeline: updatedTimeline });
|
||||
|
||||
// 3. Create Audit Log
|
||||
let auditAction: any = AUDIT_ACTIONS.APPROVED;
|
||||
if (action === 'REJECT' || action === 'Rejected') auditAction = AUDIT_ACTIONS.REJECTED;
|
||||
if (action === 'WITHDRAW' || action === 'Withdrawn') auditAction = AUDIT_ACTIONS.UPDATED;
|
||||
if (action === 'SENT_BACK' || action === 'Sent Back') auditAction = AUDIT_ACTIONS.UPDATED;
|
||||
|
||||
await AuditLog.create({
|
||||
userId: userId,
|
||||
action: auditAction,
|
||||
entityType: 'resignation',
|
||||
entityId: resignation.id,
|
||||
newData: { status: updateData.status, stage: targetStage, remarks }
|
||||
});
|
||||
|
||||
// 4. Create Worknote if it's a "Sent Back" action for communication
|
||||
if (action === 'Sent Back') {
|
||||
await Worknote.create({
|
||||
requestId: resignation.id,
|
||||
requestType: 'resignation',
|
||||
userId: userId,
|
||||
note: `Resignation sent back to ${targetStage}. Comments: ${remarks}`
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`);
|
||||
|
||||
return resignation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps resignation stages to progress percentage
|
||||
*/
|
||||
static calculateProgress(stage: string): number {
|
||||
const progress: Record<string, number> = {
|
||||
[RESIGNATION_STAGES.ASM]: 15,
|
||||
[RESIGNATION_STAGES.RBM]: 30,
|
||||
[RESIGNATION_STAGES.ZBH]: 40,
|
||||
[RESIGNATION_STAGES.DD_LEAD]: 50,
|
||||
[RESIGNATION_STAGES.NBH]: 60,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: 75,
|
||||
[RESIGNATION_STAGES.LEGAL]: 85,
|
||||
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
|
||||
[RESIGNATION_STAGES.COMPLETED]: 100,
|
||||
[RESIGNATION_STAGES.REJECTED]: 0
|
||||
};
|
||||
return progress[stage] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is authorized to perform an action based on their role and current stage
|
||||
*/
|
||||
static async canUserAction(resignation: any, user: any) {
|
||||
if (!user) return false;
|
||||
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
|
||||
|
||||
const stageToRole: Record<string, string> = {
|
||||
[RESIGNATION_STAGES.ASM]: ROLES.ASM,
|
||||
[RESIGNATION_STAGES.RBM]: ROLES.RBM,
|
||||
[RESIGNATION_STAGES.ZBH]: ROLES.ZBH,
|
||||
[RESIGNATION_STAGES.DD_LEAD]: ROLES.DD_LEAD,
|
||||
[RESIGNATION_STAGES.NBH]: ROLES.NBH,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
|
||||
[RESIGNATION_STAGES.LEGAL]: ROLES.LEGAL_ADMIN
|
||||
};
|
||||
|
||||
const requiredRole = stageToRole[resignation.currentStage];
|
||||
return user.roleCode === requiredRole;
|
||||
}
|
||||
}
|
||||
119
src/services/TerminationWorkflowService.ts
Normal file
119
src/services/TerminationWorkflowService.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { AuditLog, User, TerminationScnResponse, TerminationHearingRecord } = db;
|
||||
import { AUDIT_ACTIONS, TERMINATION_STAGES, ROLES } from '../common/config/constants.js';
|
||||
|
||||
export class TerminationWorkflowService {
|
||||
/**
|
||||
* Standardized method to transition a termination request status
|
||||
*/
|
||||
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
||||
const { action, remarks, status } = metadata;
|
||||
|
||||
const updateData: any = {
|
||||
currentStage: targetStage,
|
||||
status: status || (targetStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : targetStage),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// 1. Update Termination Record
|
||||
await termination.update(updateData);
|
||||
|
||||
// 2. Update Timeline (JSON array)
|
||||
const actor = userId ? await User.findByPk(userId) : null;
|
||||
const timelineEntry = {
|
||||
stage: targetStage,
|
||||
timestamp: new Date(),
|
||||
user: actor ? actor.fullName : 'System',
|
||||
action: action || `Approved to ${targetStage}`,
|
||||
remarks: remarks || ''
|
||||
};
|
||||
|
||||
const updatedTimeline = [...(termination.timeline || []), timelineEntry];
|
||||
await termination.update({ timeline: updatedTimeline });
|
||||
|
||||
// 3. Create Audit Log
|
||||
let auditAction: any = AUDIT_ACTIONS.APPROVED;
|
||||
if (action === 'REJECT' || action === 'Rejected') auditAction = AUDIT_ACTIONS.REJECTED;
|
||||
if (action === 'SCN_SUBMITTED' || action === 'Hearing Recorded') auditAction = AUDIT_ACTIONS.UPDATED;
|
||||
|
||||
await AuditLog.create({
|
||||
userId: userId,
|
||||
action: auditAction,
|
||||
entityType: 'termination',
|
||||
entityId: termination.id,
|
||||
newData: { status: updateData.status, stage: targetStage, remarks }
|
||||
});
|
||||
|
||||
console.log(`[TerminationWorkflowService] Transitioned Termination ${termination.id} to ${targetStage}`);
|
||||
|
||||
return termination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps termination stages to progress percentage
|
||||
*/
|
||||
static calculateProgress(stage: string): number {
|
||||
const progress: Record<string, number> = {
|
||||
[TERMINATION_STAGES.SUBMITTED]: 10,
|
||||
[TERMINATION_STAGES.RBM_REVIEW]: 20,
|
||||
[TERMINATION_STAGES.ZBH_REVIEW]: 30,
|
||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: 40,
|
||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: 50,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: 60,
|
||||
[TERMINATION_STAGES.SCN_ISSUED]: 70,
|
||||
[TERMINATION_STAGES.PERSONAL_HEARING]: 75,
|
||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: 80,
|
||||
[TERMINATION_STAGES.CCO_APPROVAL]: 85,
|
||||
[TERMINATION_STAGES.CEO_APPROVAL]: 90,
|
||||
[TERMINATION_STAGES.LEGAL_LETTER]: 95,
|
||||
[TERMINATION_STAGES.TERMINATED]: 100,
|
||||
[TERMINATION_STAGES.REJECTED]: 0
|
||||
};
|
||||
return progress[stage] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a dealer's response to SCN and moves to personal hearing stage
|
||||
*/
|
||||
static async handleScnResponse(termination: any, data: any, userId: string) {
|
||||
const { responseBody, documents } = data;
|
||||
|
||||
await TerminationScnResponse.create({
|
||||
terminationRequestId: termination.id,
|
||||
submittedBy: userId,
|
||||
responseBody,
|
||||
documents: documents || []
|
||||
});
|
||||
|
||||
return this.transitionTermination(termination, TERMINATION_STAGES.PERSONAL_HEARING, userId, {
|
||||
action: 'SCN_SUBMITTED',
|
||||
status: 'Personal Hearing Pending',
|
||||
remarks: 'Dealer response submitted'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a personal hearing outcome and moves to next stage or rejection
|
||||
*/
|
||||
static async handleHearingOutcome(termination: any, data: any, userId: string) {
|
||||
const { attendees, summary, recommendation, momDocumentId } = data;
|
||||
|
||||
await TerminationHearingRecord.create({
|
||||
terminationRequestId: termination.id,
|
||||
conductedBy: userId,
|
||||
attendees,
|
||||
summary,
|
||||
recommendation,
|
||||
momDocumentId
|
||||
});
|
||||
|
||||
const nextStage = recommendation === 'Reject' ? TERMINATION_STAGES.REJECTED : TERMINATION_STAGES.NBH_FINAL_APPROVAL;
|
||||
const status = recommendation === 'Reject' ? 'Rejected after Hearing' : 'NBH Final Approval Pending';
|
||||
|
||||
return this.transitionTermination(termination, nextStage, userId, {
|
||||
action: `Hearing Recorded - ${recommendation}`,
|
||||
status,
|
||||
remarks: summary
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user