data altered to make the data flow consistance necessay schem changes done

This commit is contained in:
laxmanhalaki 2026-04-06 09:37:19 +05:30
parent 115e978eb8
commit 25d5570319
38 changed files with 1823 additions and 680 deletions

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

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

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

View File

@ -1,90 +1,161 @@
import 'dotenv/config'; import 'dotenv/config';
import db from '../src/database/models/index.js'; import db from '../src/database/models/index.js';
import bcrypt from 'bcryptjs'; 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() { async function masterReset() {
console.log('--- RESETTING DATABASE TO DENORMALIZED DISTRICT MODEL ---'); console.log('--- RELOADING DATABASE FOR OFFBOARDING TEST ---');
try { try {
await db.sequelize.authenticate(); await db.sequelize.authenticate();
console.log('Database connected.');
// 1. Force Sync (Drop and Recreate)
// 1. Force Sync
await db.sequelize.sync({ force: true }); 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); const hashedPassword = await bcrypt.hash('Admin@123', 10);
// 2. Seed Roles // 2. Seed All Roles
const roles = [ const rolesToSeed = Object.values(ROLES).map(code => ({
{ roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' }, roleCode: code,
{ roleCode: 'DD Head', roleName: 'DD Head', category: 'NATIONAL' }, roleName: code,
{ roleCode: 'ZBH', roleName: 'Zonal Business Head', category: 'ZONAL' }, category: 'SYSTEM'
{ roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' }, }));
{ roleCode: 'RM', roleName: 'Regional Manager', category: 'REGIONAL' },
{ roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' }, for (const r of rolesToSeed) {
{ roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' }, await Role.create(r);
{ roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' } }
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); for (const u of users) {
console.log('Roles seeded.'); 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) await UserRole.create({
// Zone userId: user.id,
const northZone = await Zone.create({ name: 'North Zone', code: 'ZONE-N' }); roleId: role.id,
const southZone = await Zone.create({ name: 'South Zone', code: 'ZONE-S' }); isActive: true,
isPrimary: true,
// State zoneId: isRegionalRole || isGranularRole ? zone.id : null,
const delhiState = await State.create({ name: 'Delhi', zoneId: northZone.id }); regionId: isRegionalRole ? region.id : null,
const haryanaState = await State.create({ name: 'Haryana', zoneId: northZone.id }); districtId: isGranularRole ? district.id : null
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 });
} }
console.log('✅ Standard Users seeded.');
const zbhUser = await User.create({ // 5. Seed a Dealer record for testing
fullName: 'Yashwin (ZBH North)', const { Application, Dealer, Outlet } = db;
email: 'yashwin@gmail.com', const dealerUser = await User.findOne({ where: { email: 'dealer@royalenfield.com' } });
roleCode: 'ZBH',
password: hashedPassword, if (dealerUser) {
status: 'active' // First create a mandatory Application as per model constraints
}); // We use 'Approved' stage and 'Onboarded' status to simulate a completed onboarding
const zbhRole = await Role.findOne({ where: { roleCode: 'ZBH' } }); const [application] = await Application.findOrCreate({
if (zbhRole) { where: { applicationId: 'APP-STABLE-001' },
await UserRole.create({ userId: zbhUser.id, roleId: zbhRole.id, zoneId: northZone.id, isActive: true, isPrimary: true }); 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('--- SYSTEM READY FOR OFFBOARDING TESTING ---');
console.log('--- DATABASE RESET & SEEDING COMPLETE ---');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error('Error during database reset/seed:', error); console.error('❌ Reset failed:', error);
process.exit(1); process.exit(1);
} }
} }
resetAndSeed(); masterReset();

View File

@ -18,7 +18,12 @@ const rolesToSeed = [
{ roleCode: ROLES.ASM, roleName: 'ASM', category: 'SALES', description: 'Area Sales Manager' }, { roleCode: ROLES.ASM, roleName: 'ASM', category: 'SALES', description: 'Area Sales Manager' },
{ roleCode: ROLES.DEALER, roleName: 'Dealer', category: 'EXTERNAL', description: 'Dealer Principal' }, { 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.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() { async function seedRoles() {

View File

@ -1,8 +1,14 @@
import 'dotenv/config';
import db from '../src/database/models/index.js'; import db from '../src/database/models/index.js';
const seedSystemConfigs = async () => { const seedSystemConfigs = async () => {
try { 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 = [ const configs = [
{ {
@ -19,28 +25,40 @@ const seedSystemConfigs = async () => {
} }
]; ];
console.log(`🌱 Seeding ${configs.length} configurations...`);
for (const config of configs) { for (const config of configs) {
await db.SystemConfiguration.findOrCreate({ const [record, created] = await db.SystemConfiguration.findOrCreate({
where: { key: config.key }, where: { key: config.key },
defaults: { defaults: {
...config, ...config,
isActive: true 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) { } catch (error) {
console.error('Error seeding system configurations:', error); console.error('❌ Error during seeding:', error);
} finally { throw error;
// Only close if this is the main module
// db.sequelize.close();
} }
}; };
// Run if called directly // Execute seeding with proper error handling
if (import.meta.url === `file://${process.argv[1]}`) { seedSystemConfigs()
seedSystemConfigs().then(() => process.exit(0)); .then(() => {
} console.log('👋 Seeding process completed.');
process.exit(0);
})
.catch((err) => {
console.error('💥 Fatal error:', err);
process.exit(1);
});
export default seedSystemConfigs; export default seedSystemConfigs;

View File

@ -13,6 +13,11 @@ async function seedUsers() {
const hashedPassword = await bcrypt.hash('Admin@123', 10); const hashedPassword = await bcrypt.hash('Admin@123', 10);
const usersToSeed = [ 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: '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: '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 }, { 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: '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: '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: '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) { for (const u of usersToSeed) {

View File

@ -2,6 +2,7 @@ import 'dotenv/config';
import db from '../src/database/models/index.js'; import db from '../src/database/models/index.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js'; 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; const { Role, Zone, Region, State, District, User, UserRole } = db;
@ -73,24 +74,29 @@ async function seed() {
regionMap[r.name] = region; regionMap[r.name] = region;
} }
// States & Districts // 3. States & Districts (Mapping South Delhi to NCR)
const districts = [ const ncrRegion = regionMap['NCR Region'];
{ name: 'South Delhi', stateName: 'DELHI', regionName: 'NCR Region' },
{ name: 'NOIDA', stateName: 'UTTAR PRADESH', regionName: 'NCR Region' }, const [delhiState] = await State.findOrCreate({
{ name: 'Ludhiana', stateName: 'PUNJAB', regionName: 'Punjab Region' }, where: { name: 'DELHI' },
{ name: 'Bangalore Urban', stateName: 'KARNATAKA', regionName: 'Karnataka Region' } defaults: { name: 'DELHI', zoneId: ncrRegion.zoneId }
]; });
for (const d of districts) {
const region = regionMap[d.regionName]; const [southDelhi] = await District.findOrCreate({
const [state] = await State.findOrCreate({ where: { name: 'South Delhi' },
where: { name: d.stateName }, defaults: {
defaults: { name: d.stateName, zoneId: region.zoneId } name: 'South Delhi',
}); stateId: delhiState.id,
await District.findOrCreate({ regionId: ncrRegion.id,
where: { name: d.name }, zoneId: ncrRegion.zoneId,
defaults: { name: d.name, stateId: state.id, regionId: region.id, zoneId: region.zoneId, isActive: true } 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 // 3. Create Key Management Users
// National / Administrative // 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 ---'); console.log('--- Triggering Hierarchy Synchronization ---');
const districtList = await District.findAll({ attributes: ['id'] }); // ... (rest same)
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);
console.log('--- Golden Path Seeding Complete ---'); console.log('--- Golden Path Seeding Complete ---');
} }

View File

@ -15,7 +15,12 @@ export const ROLES = {
FINANCE: 'Finance', FINANCE: 'Finance',
DEALER: 'Dealer', DEALER: 'Dealer',
ARCHITECTURE: 'ARCHITECTURE', ARCHITECTURE: 'ARCHITECTURE',
FDD: 'FDD' FDD: 'FDD',
CCO: 'CCO',
CEO: 'CEO',
SPARES_MANAGER: 'Spares Manager',
SERVICE_MANAGER: 'Service Manager',
ACCOUNTS_MANAGER: 'Accounts Manager'
} as const; } as const;
// Regions // Regions
@ -139,21 +144,29 @@ export const RESIGNATION_TYPES = {
OTHER: 'Other' OTHER: 'Other'
} as const; } as const;
// Constitutional Change Types // Constitutional Change Types (Aligned with frontend and SRS scenarios)
export const CONSTITUTIONAL_CHANGE_TYPES = { export const CONSTITUTIONAL_CHANGE_TYPES = {
PROPRIETORSHIP: 'Proprietorship',
PARTNERSHIP: 'Partnership',
LLP_CONVERSION: 'LLP Conversion',
LLP: 'LLP',
PRIVATE_LIMITED: 'Private Limited',
COMPANY_FORMATION: 'Company Formation',
OWNERSHIP_TRANSFER: 'Ownership Transfer', OWNERSHIP_TRANSFER: 'Ownership Transfer',
PARTNERSHIP_CHANGE: 'Partnership Change', PARTNERSHIP_CHANGE: 'Partnership Change',
LLP_CONVERSION: 'LLP Conversion',
COMPANY_FORMATION: 'Company Formation',
DIRECTOR_CHANGE: 'Director Change' DIRECTOR_CHANGE: 'Director Change'
} as const; } as const;
// Constitutional Change Stages // Constitutional Change Stages (Aligned with SRS v2.0)
export const CONSTITUTIONAL_STAGES = { export const CONSTITUTIONAL_STAGES = {
DD_ADMIN_REVIEW: 'DD Admin Review', SUBMITTED: 'Submitted',
LEGAL_REVIEW: 'Legal Review', 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', NBH_APPROVAL: 'NBH Approval',
FINANCE_CLEARANCE: 'Finance Clearance', LEGAL_REVIEW: 'Legal Review',
COMPLETED: 'Completed', COMPLETED: 'Completed',
REJECTED: 'Rejected' REJECTED: 'Rejected'
} as const; } as const;
@ -224,6 +237,26 @@ export const FNF_STATUS = {
COMPLETED: 'Completed' COMPLETED: 'Completed'
} as const; } 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 // Audit Actions
export const AUDIT_ACTIONS = { export const AUDIT_ACTIONS = {
// General CRUD // General CRUD

View File

@ -10,18 +10,31 @@ const __dirname = path.dirname(__filename);
// Create test account (or use env vars in production) // Create test account (or use env vars in production)
let transporter: nodemailer.Transporter; let transporter: nodemailer.Transporter;
let transporterPromise: Promise<nodemailer.Transporter>;
nodemailer.createTestAccount().then((account) => { const initTransporter = async () => {
transporter = nodemailer.createTransport({ if (transporter) return transporter;
host: account.smtp.host, if (transporterPromise) return transporterPromise;
port: account.smtp.port,
secure: account.smtp.secure, transporterPromise = nodemailer.createTestAccount().then((account) => {
auth: { transporter = nodemailer.createTransport({
user: account.user, host: account.smtp.host,
pass: account.pass, 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; 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.'); console.warn('Email transporter not initialized. Using fallback mock.');
return; return;
} }
const info = await transporter.sendMail({ const info = await readyTransporter.sendMail({
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>', from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
to, to,
subject: finalSubject, subject: finalSubject,
@ -135,3 +149,21 @@ export const sendUserAssignedEmail = async (to: string, userName: string, applic
participantType 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
});
};

View File

@ -24,6 +24,7 @@ export interface ApplicationAttributes {
description: string | null; description: string | null;
address: string | null; address: string | null;
pincode: string | null; pincode: string | null;
constitutionType: string | null;
currentStage: string; currentStage: string;
overallStatus: string; overallStatus: string;
progressPercentage: number; progressPercentage: number;
@ -141,6 +142,10 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true allowNull: true
}, },
constitutionType: {
type: DataTypes.STRING,
allowNull: true
},
currentStage: { currentStage: {
type: DataTypes.ENUM(...Object.values(APPLICATION_STAGES)), type: DataTypes.ENUM(...Object.values(APPLICATION_STAGES)),
defaultValue: APPLICATION_STAGES.DD defaultValue: APPLICATION_STAGES.DD

View File

@ -4,13 +4,17 @@ import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../common
export interface ConstitutionalChangeAttributes { export interface ConstitutionalChangeAttributes {
id: string; id: string;
requestId: string; requestId: string;
outletId: string; outletId: string | null;
dealerId: string; dealerId: string;
changeType: typeof CONSTITUTIONAL_CHANGE_TYPES[keyof typeof CONSTITUTIONAL_CHANGE_TYPES]; changeType: typeof CONSTITUTIONAL_CHANGE_TYPES[keyof typeof CONSTITUTIONAL_CHANGE_TYPES];
description: string; description: string;
currentConstitution: string | null;
oldValue: string | null;
newValue: string | null;
currentStage: typeof CONSTITUTIONAL_STAGES[keyof typeof CONSTITUTIONAL_STAGES]; currentStage: typeof CONSTITUTIONAL_STAGES[keyof typeof CONSTITUTIONAL_STAGES];
status: string; status: string;
progressPercentage: number; progressPercentage: number;
metadata: any;
documents: any[]; documents: any[];
timeline: any[]; timeline: any[];
} }
@ -31,7 +35,7 @@ export default (sequelize: Sequelize) => {
}, },
outletId: { outletId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: true,
references: { references: {
model: 'outlets', model: 'outlets',
key: 'id' key: 'id'
@ -53,9 +57,21 @@ export default (sequelize: Sequelize) => {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false allowNull: false
}, },
currentConstitution: {
type: DataTypes.STRING,
allowNull: true
},
oldValue: {
type: DataTypes.STRING,
allowNull: true
},
newValue: {
type: DataTypes.STRING,
allowNull: true
},
currentStage: { currentStage: {
type: DataTypes.ENUM(...Object.values(CONSTITUTIONAL_STAGES)), type: DataTypes.ENUM(...Object.values(CONSTITUTIONAL_STAGES)),
defaultValue: CONSTITUTIONAL_STAGES.DD_ADMIN_REVIEW defaultValue: CONSTITUTIONAL_STAGES.SUBMITTED
}, },
status: { status: {
type: DataTypes.STRING, type: DataTypes.STRING,
@ -65,6 +81,10 @@ export default (sequelize: Sequelize) => {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 0 defaultValue: 0
}, },
metadata: {
type: DataTypes.JSON,
defaultValue: {}
},
documents: { documents: {
type: DataTypes.JSON, type: DataTypes.JSON,
defaultValue: [] defaultValue: []

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

View File

@ -13,6 +13,7 @@ export interface FnFAttributes {
netAmount: number; netAmount: number;
settlementDate: Date | null; settlementDate: Date | null;
clearanceDocuments: any[]; clearanceDocuments: any[];
progressPercentage: number;
} }
export interface FnFInstance extends Model<FnFAttributes>, FnFAttributes { } export interface FnFInstance extends Model<FnFAttributes>, FnFAttributes { }
@ -79,6 +80,10 @@ export default (sequelize: Sequelize) => {
clearanceDocuments: { clearanceDocuments: {
type: DataTypes.JSON, type: DataTypes.JSON,
defaultValue: [] defaultValue: []
},
progressPercentage: {
type: DataTypes.INTEGER,
defaultValue: 0
} }
}, { }, {
tableName: 'fnf_settlements', tableName: 'fnf_settlements',

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

View File

@ -12,6 +12,12 @@ export interface TerminationRequestAttributes {
comments: string | null; comments: string | null;
timeline: any[]; timeline: any[];
documents: any[]; documents: any[];
departmentalClearances: {
spares: boolean;
service: boolean;
accounts: boolean;
logistics: boolean;
} | null;
} }
export interface TerminationRequestInstance extends Model<TerminationRequestAttributes>, TerminationRequestAttributes { } export interface TerminationRequestInstance extends Model<TerminationRequestAttributes>, TerminationRequestAttributes { }
@ -70,6 +76,15 @@ export default (sequelize: Sequelize) => {
documents: { documents: {
type: DataTypes.JSON, type: DataTypes.JSON,
defaultValue: [] defaultValue: []
},
departmentalClearances: {
type: DataTypes.JSON,
defaultValue: {
spares: false,
service: false,
accounts: false,
logistics: false
}
} }
}, { }, {
tableName: 'termination_requests', tableName: 'termination_requests',

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

View File

@ -29,6 +29,9 @@ import createLocation from './Location.js';
import createZone from './Zone.js'; import createZone from './Zone.js';
import createRegion from './Region.js'; import createRegion from './Region.js';
import createState from './State.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 // Batch 1: Organizational Hierarchy & User Management
import createRole from './Role.js'; import createRole from './Role.js';
@ -138,6 +141,9 @@ db.Location = createLocation(sequelize);
db.Zone = createZone(sequelize); db.Zone = createZone(sequelize);
db.Region = createRegion(sequelize); db.Region = createRegion(sequelize);
db.State = createState(sequelize); db.State = createState(sequelize);
db.TerminationScnResponse = createTerminationScnResponse(sequelize);
db.TerminationHearingRecord = createTerminationHearingRecord(sequelize);
db.FffClearance = createFffClearance(sequelize);
// Batch 1: Organizational Hierarchy & User Management // Batch 1: Organizational Hierarchy & User Management
db.Role = createRole(sequelize); db.Role = createRole(sequelize);

View 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">
&copy; {{year}} Royal Enfield. All rights reserved.
</div>
</div>
</body>
</html>

View 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">
&copy; {{year}} Royal Enfield. All rights reserved.
</div>
</div>
</body>
</html>

View File

@ -270,6 +270,14 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons
reason: 'Questionnaire submitted by applicant', reason: 'Questionnaire submitted by applicant',
progressPercentage: 20 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 }); res.status(201).json({ success: true, message: 'Responses submitted and scored successfully', score: totalWeightedScore });

View File

@ -1,8 +1,9 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import db from '../../database/models/index.js'; 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 { 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) => { export const getAssignment = async (req: Request, res: Response) => {
try { try {
@ -61,6 +62,18 @@ export const uploadReport = async (req: AuthRequest, res: Response) => {
{ where: { id: assignmentId } } { 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 }); res.status(201).json({ success: true, message: 'FDD Report uploaded', data: report });
} catch (error) { } catch (error) {
console.error('Upload FDD report error:', error); console.error('Upload FDD report error:', error);

View File

@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js'; 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 { syncLocationManagers } from '../master/syncHierarchy.service.js';
import { WorkflowService } from '../../services/WorkflowService.js'; import { WorkflowService } from '../../services/WorkflowService.js';
@ -453,6 +453,14 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => {
progressPercentage: 30 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 // Add all assigned users as participants
for (const userId of assignedTo) { for (const userId of assignedTo) {
await db.RequestParticipant.findOrCreate({ await db.RequestParticipant.findOrCreate({

View File

@ -1,39 +1,54 @@
import { Response } from 'express'; import { Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { ConstitutionalChange, Outlet, User, Worknote } = db; const { ConstitutionalChange, Outlet, User, Worknote } = db;
import { AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js';
import { Op, Transaction } from 'sequelize'; import { Op, Transaction } from 'sequelize';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { AuthRequest } from '../../types/express.types.js'; 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) => { export const submitRequest = async (req: AuthRequest, res: Response) => {
try { try {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { const { outletId, changeType, reason, currentConstitution, newPartnersDetails, shareholdingPattern } = req.body;
outletId, changeType, reason, newEntityDetails
} = req.body;
const requestId = `CC-${Date.now()}-${uuidv4().substring(0, 4).toUpperCase()}`; 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({ const request = await ConstitutionalChange.create({
requestId, requestId,
outletId, outletId: outletId || null, // Optional for dealer-level changes
dealerId: req.user.id, dealerId: req.user.id,
changeType, changeType,
description: reason, description: reason,
currentStage: 'DD_ADMIN_REVIEW' as any, currentConstitution: currentConstitution || null,
status: 'Pending', currentStage: CONSTITUTIONAL_STAGES.SUBMITTED,
progressPercentage: 20, status: 'Submitted',
progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED),
metadata,
documents: [], documents: [],
timeline: [{ timeline: [{
stage: 'Submitted', stage: 'Submitted',
timestamp: new Date(), timestamp: new Date(),
user: req.user.fullName, 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({ res.status(201).json({
success: true, success: true,
message: 'Constitutional change request submitted successfully', message: 'Constitutional change request submitted successfully',
@ -57,16 +72,8 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
const requests = await ConstitutionalChange.findAll({ const requests = await ConstitutionalChange.findAll({
where, where,
include: [ include: [
{ { model: Outlet, as: 'outlet', attributes: ['code', 'name'] },
model: Outlet, { model: User, as: 'dealer', attributes: ['fullName'] }
as: 'outlet',
attributes: ['code', 'name']
},
{
model: User,
as: 'dealer',
attributes: ['fullName']
}
], ],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });
@ -81,34 +88,19 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
export const getRequestById = async (req: AuthRequest, res: Response) => { export const getRequestById = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; 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({ const request = await ConstitutionalChange.findOne({
where: { where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr },
[Op.or]: [
{ id },
{ requestId: id }
]
},
include: [ include: [
{ { model: Outlet, as: 'outlet' },
model: Outlet, { model: User, as: 'dealer', attributes: ['fullName', 'email'] },
as: 'outlet' { model: Worknote, as: 'worknotes' }
},
{
model: User,
as: 'dealer',
attributes: ['fullName', 'email']
},
{
model: Worknote,
as: 'worknotes'
}
] ]
}); });
if (!request) { if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
return res.status(404).json({ success: false, message: 'Request not found' });
}
res.json({ success: true, request }); res.json({ success: true, request });
} catch (error) { } 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' }); if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const { id } = req.params; 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({ const request = await ConstitutionalChange.findOne({
where: { where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
[Op.or]: [
{ id },
{ requestId: id }
]
}
}); });
if (!request) { if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
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> = { res.json({ success: true, message: `Request ${action.toLowerCase()}ed successfully` });
'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` });
} catch (error) { } catch (error) {
console.error('Take action error:', error); console.error('Take action error:', error);
res.status(500).json({ success: false, message: 'Error processing action' }); 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) => { export const uploadDocuments = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const idStr = String(id);
const { documents } = req.body; 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({ const request = await ConstitutionalChange.findOne({
where: { where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
[Op.or]: [
{ id },
{ requestId: id }
]
}
}); });
if (!request) { if (!request) {

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

View File

@ -512,14 +512,12 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
return res.status(400).json({ success: false, message: 'Document type is required' }); 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 // Only search by requestId since frontend sends requestId, not UUID
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
[Op.or]: [
{ id },
{ requestId: id }
]
}
}); });
if (!request) { 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' }); 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 // Search by UUID or requestId for the request
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
[Op.or]: [
{ id },
{ requestId: id }
]
}
}); });
if (!request) { if (!request) {

View File

@ -6,76 +6,37 @@ import { Op, Transaction } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import ExternalMocksService from '../../common/utils/externalMocks.service.js'; import ExternalMocksService from '../../common/utils/externalMocks.service.js';
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
// Generate unique resignation ID // Generate unique resignation ID
const generateResignationId = async (): Promise<string> => { const generateResignationId = async (): Promise<string> => {
const count = await db.Resignation.count(); const count = await db.Resignation.count();
return `RES-${String(count + 1).padStart(3, '0')}`; 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) // Create resignation request (Dealer only)
export const createResignation = async (req: AuthRequest, res: Response, next: NextFunction) => { export const createResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction(); const transaction: Transaction = await db.sequelize.transaction();
try { try {
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body; const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
const dealerId = req.user.id; 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) { if (!outlet) {
await transaction.rollback(); await transaction.rollback();
return res.status(404).json({ return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
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({ const existingResignation = await db.Resignation.findOne({
where: { where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected'] } }
outletId,
status: { [Op.notIn]: ['Completed', 'Rejected'] }
}
}); });
if (existingResignation) { if (existingResignation) {
await transaction.rollback(); await transaction.rollback();
return res.status(400).json({ return res.status(400).json({ success: false, message: 'This outlet already has an active resignation request' });
success: false,
message: 'This outlet already has an active resignation request'
});
} }
// Generate resignation ID
const resignationId = await generateResignationId(); const resignationId = await generateResignationId();
// Create resignation
const resignation = await db.Resignation.create({ const resignation = await db.Resignation.create({
resignationId, resignationId,
outletId, outletId,
@ -87,7 +48,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
additionalInfo, additionalInfo,
currentStage: RESIGNATION_STAGES.ASM, currentStage: RESIGNATION_STAGES.ASM,
status: 'ASM Review', status: 'ASM Review',
progressPercentage: 15, progressPercentage: ResignationWorkflowService.calculateProgress(RESIGNATION_STAGES.ASM),
submittedOn: new Date(), submittedOn: new Date(),
documents: [], documents: [],
timeline: [{ timeline: [{
@ -98,12 +59,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
}] }]
}, { transaction }); }, { transaction });
// Update outlet status await outlet.update({ status: 'Pending Resignation' }, { transaction });
await outlet.update({
status: 'Pending Resignation'
}, { transaction });
// Create audit log
await db.AuditLog.create({ await db.AuditLog.create({
userId: req.user.id, userId: req.user.id,
action: AUDIT_ACTIONS.CREATED, action: AUDIT_ACTIONS.CREATED,
@ -112,16 +68,8 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N
}, { transaction }); }, { transaction });
await transaction.commit(); await transaction.commit();
logger.info(`Resignation request created: ${resignationId} by dealer: ${req.user.email}`); logger.info(`Resignation request created: ${resignationId} by dealer: ${req.user.email}`);
res.status(201).json({ success: true, message: 'Resignation request submitted successfully', resignationId, resignation });
res.status(201).json({
success: true,
message: 'Resignation request submitted successfully',
resignationId: resignation.resignationId,
resignation: resignation.toJSON()
});
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();
logger.error('Error creating resignation:', error); 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) => { export const getResignations = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { status, page = '1', limit = '10' } = req.query as { status?: string, page?: string, limit?: string }; const where: any = {};
const offset = (parseInt(page) - 1) * parseInt(limit);
// Build where clause based on user role
let where: any = {};
if (req.user.roleCode === ROLES.DEALER) { if (req.user.roleCode === ROLES.DEALER) {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} }
if (status) { const resignations = await db.Resignation.findAll({
where.status = status;
}
// Get resignations
const { count, rows: resignations } = await db.Resignation.findAndCountAll({
where, where,
include: [ include: [{ model: db.Outlet, as: 'outlet' }],
{ order: [['createdAt', 'DESC']]
model: db.Outlet,
as: 'outlet'
},
{
model: db.User,
as: 'dealer',
attributes: ['id', 'fullName', 'email', 'mobileNumber']
}
],
order: [['createdAt', 'DESC']],
limit: parseInt(limit),
offset: offset
}); });
res.json({ success: true, resignations });
res.json({
success: true,
resignations,
pagination: {
total: count,
page: parseInt(page),
pages: Math.ceil(count / parseInt(limit)),
limit: parseInt(limit)
}
});
} catch (error) { } catch (error) {
logger.error('Error fetching resignations:', error); logger.error('Error fetching resignations:', error);
next(error); next(error);
} }
}; };
// Get resignation details // Get resignation by ID
export const getResignationById = async (req: AuthRequest, res: Response, next: NextFunction) => { export const getResignationById = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params; 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({ const resignation = await db.Resignation.findOne({
where: { id }, where: isUUID ? { [Op.or]: [{ id: idStr }, { resignationId: idStr }] } : { resignationId: idStr },
include: [ include: [
{ { model: db.Outlet, as: 'outlet' },
model: db.Outlet, { model: db.User, as: 'dealer', attributes: ['id', 'fullName', 'email'] },
as: 'outlet', { model: db.ResignationDocument, as: 'uploadedDocuments' }
include: [ ]
{
model: db.User,
as: 'dealer',
attributes: ['id', 'fullName', 'email', 'mobileNumber']
}
]
},
{
model: db.Worknote,
as: 'worknotes'
}
],
order: [[{ model: db.Worknote, as: 'worknotes' }, 'createdAt', 'DESC']]
}); });
if (!resignation) { if (!resignation) {
return res.status(404).json({ return res.status(404).json({ success: false, message: 'Resignation not found' });
success: false,
message: 'Resignation not found'
});
} }
res.json({ success: true, resignation });
// 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
});
} catch (error) { } catch (error) {
logger.error('Error fetching resignation details:', error); logger.error('Error fetching resignation:', error);
next(error); next(error);
} }
}; };
@ -240,25 +128,22 @@ export const getResignationById = async (req: AuthRequest, res: Response, next:
// Approve resignation (move to next stage) // Approve resignation (move to next stage)
export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => { export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction(); const transaction: Transaction = await db.sequelize.transaction();
try { try {
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { id } = req.params; const { id } = req.params;
const idStr = String(id);
const { remarks } = req.body; 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' }] include: [{ model: db.Outlet, as: 'outlet' }]
}); });
if (!resignation) { if (!resignation) {
await transaction.rollback(); await transaction.rollback();
return res.status(404).json({ return res.status(404).json({ success: false, message: 'Resignation not found' });
success: false,
message: 'Resignation not found'
});
} }
// Determine next stage based on current stage
const stageFlow: Record<string, string> = { const stageFlow: Record<string, string> = {
[RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM, [RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM,
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH, [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.DD_LEAD]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN, [RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL, [RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.SPARES_CLEARANCE, [RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.FNF_INITIATED,
[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.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED [RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
}; };
const nextStage = stageFlow[resignation.currentStage]; const nextStage = stageFlow[resignation.currentStage];
if (!nextStage) { if (!nextStage) {
await transaction.rollback(); await transaction.rollback();
return res.status(400).json({ return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
success: false,
message: 'Cannot approve from current stage'
});
} }
// Update resignation // Transition via Workflow Service
const timeline = [...resignation.timeline, { await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
stage: nextStage, remarks,
timestamp: new Date(), status: nextStage === RESIGNATION_STAGES.COMPLETED ? 'Completed' : `${nextStage} Review`
user: req.user.fullName, });
action: 'Approved',
remarks
}];
await resignation.update({ // Special logic for F&F and Completion
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
if (nextStage === RESIGNATION_STAGES.COMPLETED) { if (nextStage === RESIGNATION_STAGES.COMPLETED) {
await (resignation as any).outlet.update({ await (resignation as any).outlet.update({ status: 'Closed' }, { transaction });
status: 'Closed'
}, { transaction });
// Trigger Mock SAP Sync
ExternalMocksService.mockSyncDealerStatusToSap((resignation as any).outlet.code, 'Inactive') ExternalMocksService.mockSyncDealerStatusToSap((resignation as any).outlet.code, 'Inactive')
.catch(err => logger.error('Error syncing resignation completion to SAP:', err)); .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) { if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
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 sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code);
const fnf = await db.FnF.create({ const fnf = await db.FnF.create({
resignationId: resignation.id, resignationId: resignation.id, outletId: resignation.outletId, dealerId: resignation.dealerId,
outletId: resignation.outletId, status: 'Initiated', totalReceivables: sapDues.data.outstandingInvoices, totalPayables: sapDues.data.securityDeposit,
dealerId: resignation.dealerId,
status: 'Initiated',
totalReceivables: sapDues.data.outstandingInvoices,
totalPayables: sapDues.data.securityDeposit,
netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices
}, { transaction }); }, { transaction });
// Create initial line items from SAP data
await db.FnFLineItem.bulkCreate([ 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, { fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.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 }); ], { 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(); await transaction.commit();
res.json({ success: true, message: 'Resignation approved successfully', nextStage, resignation });
logger.info(`Resignation ${resignation.resignationId} approved to ${nextStage} by ${req.user.email}`);
res.json({
success: true,
message: 'Resignation approved successfully',
nextStage,
resignation
});
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();
logger.error('Error approving resignation:', error); logger.error('Error approving resignation:', error);
@ -377,72 +217,35 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
// Reject resignation // Reject resignation
export const rejectResignation = async (req: AuthRequest, res: Response, next: NextFunction) => { export const rejectResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction(); const transaction: Transaction = await db.sequelize.transaction();
try { try {
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { id } = req.params; const { id } = req.params;
const idStr = String(id);
const { reason } = req.body; const { reason } = req.body;
if (!reason) { if (!reason) {
await transaction.rollback(); await transaction.rollback();
return res.status(400).json({ return res.status(400).json({ success: false, message: 'Rejection reason is required' });
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' }] include: [{ model: db.Outlet, as: 'outlet' }]
}); });
if (!resignation) { if (!resignation) {
await transaction.rollback(); await transaction.rollback();
return res.status(404).json({ return res.status(404).json({ success: false, message: 'Resignation not found' });
success: false,
message: 'Resignation not found'
});
} }
// Update resignation await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
const timeline = [...resignation.timeline, { remarks: reason,
stage: 'Rejected',
timestamp: new Date(),
user: req.user.fullName,
action: 'Rejected', action: 'Rejected',
reason status: 'Rejected'
}];
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
}); });
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
await transaction.commit();
res.json({ success: true, message: 'Resignation rejected', resignation });
} catch (error) { } catch (error) {
if (transaction) await transaction.rollback(); if (transaction) await transaction.rollback();
logger.error('Error rejecting resignation:', error); 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 // Update departmental clearance
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => { export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction(); const transaction: Transaction = await db.sequelize.transaction();
try { try {
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { id } = req.params; const { id } = req.params;
const idStr = String(id);
const { department, cleared, remarks } = req.body; 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) { if (!resignation) {
await transaction.rollback(); 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' });
} }
const clearances = { ...resignation.departmentalClearances, [department]: cleared }; const clearances = { ...resignation.departmentalClearances, [department]: cleared };
await resignation.update({ await resignation.update({
departmentalClearances: clearances, departmentalClearances: clearances,
timeline: [...resignation.timeline, { timeline: [...resignation.timeline, {

View File

@ -9,6 +9,8 @@ router.get('/', authenticate as any, resignationController.getResignations);
router.get('/:id', authenticate as any, resignationController.getResignationById); router.get('/:id', authenticate as any, resignationController.getResignationById);
router.put('/:id/approve', authenticate as any, resignationController.approveResignation); router.put('/:id/approve', authenticate as any, resignationController.approveResignation);
router.put('/:id/reject', authenticate as any, resignationController.rejectResignation); 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); router.put('/:id/clearance', authenticate as any, resignationController.updateClearance);
export default router; export default router;

View File

@ -2,19 +2,15 @@ import express from 'express';
const router = express.Router(); const router = express.Router();
import resignationRoutes from './resignation.routes.js'; 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 * as relocationController from './relocation.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
// Resignations submodule // Resignations submodule
router.use('/resignations', resignationRoutes); router.use('/resignations', resignationRoutes);
// Constitutional changes submodule // Constitutional changes submodule - using the new dedicated router
router.post('/constitutional', authenticate as any, constitutionalController.submitRequest); router.use('/constitutional', constitutionalRoutes);
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);
// Relocation submodule // Relocation submodule
router.post('/relocation', authenticate as any, relocationController.submitRequest); router.post('/relocation', authenticate as any, relocationController.submitRequest);

View File

@ -1,6 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import db from '../../database/models/index.js'; 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'; import { AuthRequest } from '../../types/express.types.js';
export const getOnboardingPayments = async (req: Request, res: Response) => { export const getOnboardingPayments = async (req: Request, res: Response) => {
@ -13,7 +13,6 @@ export const getOnboardingPayments = async (req: Request, res: Response) => {
}], }],
order: [['createdAt', 'ASC']] order: [['createdAt', 'ASC']]
}); });
res.json({ success: true, payments }); res.json({ success: true, payments });
} catch (error) { } catch (error) {
console.error('Get onboarding payments error:', 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) => { export const updatePayment = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { paidDate, amount, transactionReference, status } = req.body; const { paidDate, amount, transactionReference, status } = req.body;
const payment = await FinancePayment.findByPk(id); const payment = await FinancePayment.findByPk(id);
if (!payment) { if (!payment) return res.status(404).json({ success: false, message: 'Payment not found' });
return res.status(404).json({ success: false, message: 'Payment not found' });
}
await payment.update({ await payment.update({
paymentDate: paidDate || payment.paymentDate, paymentDate: paidDate || payment.paymentDate,
@ -76,10 +34,8 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
paymentStatus: status || payment.paymentStatus, paymentStatus: status || payment.paymentStatus,
updatedAt: new Date() updatedAt: new Date()
}); });
res.json({ success: true, message: 'Payment updated successfully' }); res.json({ success: true, message: 'Payment updated successfully' });
} catch (error) { } catch (error) {
console.error('Update payment error:', error);
res.status(500).json({ success: false, message: 'Error updating payment' }); 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) => { export const updateFnF = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { const { finalSettlementAmount, status } = req.body;
finalSettlementAmount, status
} = req.body;
const fnf = await FnF.findByPk(id); const fnf = await FnF.findByPk(id);
if (!fnf) { if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' });
return res.status(404).json({ success: false, message: 'F&F settlement not found' });
}
await fnf.update({ await fnf.update({
status: status || fnf.status, status: status || fnf.status,
netAmount: finalSettlementAmount || fnf.netAmount, netAmount: finalSettlementAmount || fnf.netAmount,
updatedAt: new Date() updatedAt: new Date()
}); });
res.json({ success: true, message: 'F&F settlement updated successfully' }); res.json({ success: true, message: 'F&F settlement updated successfully' });
} catch (error) { } catch (error) {
console.error('Update F&F error:', error);
res.status(500).json({ success: false, message: 'Error updating F&F settlement' }); 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) => { export const getFnFById = async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -116,18 +82,14 @@ export const getFnFById = async (req: Request, res: Response) => {
include: [ include: [
{ model: Resignation, as: 'resignation' }, { model: Resignation, as: 'resignation' },
{ model: TerminationRequest, as: 'terminationRequest' }, { model: TerminationRequest, as: 'terminationRequest' },
{ { model: Outlet, as: 'outlet', include: [{ model: User, as: 'dealer' }] },
model: Outlet, as: 'outlet', { model: FnFLineItem, as: 'lineItems' },
include: [{ model: User, as: 'dealer' }] { model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] }
},
{ model: FnFLineItem, as: 'lineItems' }
] ]
}); });
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
res.json({ success: true, fnf }); res.json({ success: true, fnf });
} catch (error) { } catch (error) {
console.error('Get F&F by ID error:', error);
res.status(500).json({ success: false, message: 'Error fetching F&F' }); res.status(500).json({ success: false, message: 'Error fetching F&F' });
} }
}; };
@ -136,19 +98,11 @@ export const addLineItem = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { itemType, description, department, amount } = req.body; const { itemType, description, department, amount } = req.body;
const lineItem = await FnFLineItem.create({ const lineItem = await FnFLineItem.create({
fnfId: id, fnfId: id, itemType, description, department, amount, addedBy: req.user?.id
itemType,
description,
department,
amount,
addedBy: req.user?.id
}); });
res.json({ success: true, lineItem }); res.json({ success: true, lineItem });
} catch (error) { } catch (error) {
console.error('Add line item error:', error);
res.status(500).json({ success: false, message: 'Error adding line item' }); res.status(500).json({ success: false, message: 'Error adding line item' });
} }
}; };
@ -157,14 +111,11 @@ export const updateLineItem = async (req: AuthRequest, res: Response) => {
try { try {
const { itemId } = req.params; const { itemId } = req.params;
const { description, department, amount } = req.body; const { description, department, amount } = req.body;
const lineItem = await FnFLineItem.findByPk(itemId); const lineItem = await FnFLineItem.findByPk(itemId);
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
await lineItem.update({ description, department, amount }); await lineItem.update({ description, department, amount });
res.json({ success: true, lineItem }); res.json({ success: true, lineItem });
} catch (error) { } catch (error) {
console.error('Update line item error:', error);
res.status(500).json({ success: false, message: 'Error updating line item' }); 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 { itemId } = req.params;
const lineItem = await FnFLineItem.findByPk(itemId); const lineItem = await FnFLineItem.findByPk(itemId);
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
await lineItem.destroy(); await lineItem.destroy();
res.json({ success: true, message: 'Line item deleted' }); res.json({ success: true, message: 'Line item deleted' });
} catch (error) { } catch (error) {
console.error('Delete line item error:', error);
res.status(500).json({ success: false, message: 'Error deleting line item' }); 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) => { export const calculateFnF = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const fnf = await FnF.findByPk(id, { 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' }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
const lineItems = (fnf as any).lineItems || []; const lineItems = (fnf as any).lineItems || [];
const clearances = (fnf as any).clearances || [];
let totalReceivables = 0; let totalReceivables = 0;
let totalPayables = 0; let totalPayables = 0;
@ -205,17 +173,23 @@ export const calculateFnF = async (req: AuthRequest, res: Response) => {
}); });
const netAmount = totalPayables - totalReceivables - totalDeductions; 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({ await fnf.update({
totalReceivables, totalReceivables, totalPayables, netAmount,
totalPayables, status: allCleared ? 'Cleared' : 'Calculated',
netAmount, progressPercentage
status: 'Calculated'
}); });
res.json({ success: true, fnf }); res.json({ success: true, fnf, allCleared, progressPercentage });
} catch (error) { } catch (error) {
console.error('Calculate F&F error:', error);
res.status(500).json({ success: false, message: 'Error calculating F&F' }); res.status(500).json({ success: false, message: 'Error calculating F&F' });
} }
}; };

View File

@ -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.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.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 // Line item management
router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem); 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); router.put('/fnf/line-items/:itemId', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateLineItem);

View File

@ -6,26 +6,7 @@ import { Transaction } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js'; import { AuthRequest } from '../../types/express.types.js';
import ExternalMocksService from '../../common/utils/externalMocks.service.js'; import ExternalMocksService from '../../common/utils/externalMocks.service.js';
// Calculate progress percentage based on stage import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
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;
};
// Create termination request // Create termination request
export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => { 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'); if (!req.user) throw new Error('Unauthorized');
const { dealerId, category, reason, proposedLwd, comments } = req.body; 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({ const termination = await db.TerminationRequest.create({
dealerId, dealerId,
category, category,
@ -47,6 +24,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
initiatedBy: req.user.id, initiatedBy: req.user.id,
currentStage: TERMINATION_STAGES.SUBMITTED, currentStage: TERMINATION_STAGES.SUBMITTED,
status: 'Submitted', status: 'Submitted',
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.SUBMITTED),
timeline: [{ timeline: [{
stage: 'Submitted', stage: 'Submitted',
timestamp: new Date(), 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) => { export const getTerminations = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { 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({ const terminations = await db.TerminationRequest.findAll({
include: [ where,
{ model: db.Dealer, as: 'dealer' }, include: [{ model: db.Dealer, as: 'dealer' }],
{ model: db.User, as: 'initiator', attributes: ['id', 'fullName', 'roleCode'] }
],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });
res.json({ success: true, terminations }); 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) => { export const getTerminationById = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const { id } = req.params; const { id } = req.params;
const termination = await db.TerminationRequest.findByPk(id, { const termination = await db.TerminationRequest.findByPk(id, {
include: [ include: [
{ model: db.Dealer, as: 'dealer' }, { 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 }); res.json({ success: true, termination });
} catch (error) { } catch (error) {
logger.error('Error fetching termination:', error); logger.error('Error fetching termination:', error);
@ -113,7 +103,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
try { try {
if (!req.user) throw new Error('Unauthorized'); if (!req.user) throw new Error('Unauthorized');
const { id } = req.params; 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); const termination = await db.TerminationRequest.findByPk(id);
if (!termination) { if (!termination) {
@ -122,19 +112,12 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
} }
if (action === 'reject') { if (action === 'reject') {
await termination.update({ await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
currentStage: TERMINATION_STAGES.REJECTED, action: 'Rejected',
status: 'Rejected', status: 'Rejected',
timeline: [...termination.timeline, { remarks
stage: 'Rejected', });
timestamp: new Date(),
user: req.user.fullName,
action: 'Rejected',
remarks
}]
}, { transaction });
} else { } else {
// Approval flow
const stageFlow: Record<string, string> = { const stageFlow: Record<string, string> = {
[TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW, [TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW,
[TERMINATION_STAGES.RBM_REVIEW]: TERMINATION_STAGES.ZBH_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' }); return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
} }
await termination.update({ await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
currentStage: nextStage, remarks,
status: nextStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : `${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 });
// If Terminated, create F&F record and fetch mock SAP dues // If Terminated, create F&F record and clearances
if (nextStage === TERMINATION_STAGES.TERMINATED) { if (nextStage === TERMINATION_STAGES.TERMINATED) {
const dealer = await db.Dealer.findByPk(termination.dealerId); const dealer = await db.Dealer.findByPk(termination.dealerId);
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap(dealer?.dealerCode || 'MOCK-001'); 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 netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices
}, { transaction }); }, { transaction });
// Create initial line items from SAP data
await db.FnFLineItem.bulkCreate([ 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, { fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.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 }); ], { transaction });
logger.info(`F&F record and mock line items created for termination: ${termination.id}`);
// Sync with Mock SAP 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 }
);
if (dealer) { if (dealer) {
ExternalMocksService.mockSyncDealerStatusToSap(dealer.dealerCode, 'Inactive') ExternalMocksService.mockSyncDealerStatusToSap(dealer.dealerCode, 'Inactive')
.catch(err => logger.error('Error syncing termination to SAP:', err)); .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); 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);
}
};

View File

@ -1,7 +1,8 @@
import express from 'express'; import express from 'express';
const router = express.Router(); const router = express.Router();
import { import {
createTermination, getTerminations, getTerminationById, updateTerminationStatus createTermination, getTerminations, getTerminationById, updateTerminationStatus,
submitScnResponse, recordPersonalHearing
} from './termination.controller.js'; } from './termination.controller.js';
import { authenticate } from '../../common/middleware/auth.js'; import { authenticate } from '../../common/middleware/auth.js';
@ -11,5 +12,7 @@ router.post('/', createTermination);
router.get('/', getTerminations); router.get('/', getTerminations);
router.get('/:id', getTerminationById); router.get('/:id', getTerminationById);
router.put('/:id/status', updateTerminationStatus); router.put('/:id/status', updateTerminationStatus);
router.post('/scn-response', submitScnResponse);
router.post('/hearing-record', recordPersonalHearing);
export default router; export default router;

View File

@ -38,6 +38,20 @@ const seedTemplates = async () => {
subject: 'New Application Assignment: {{applicationId}}', subject: 'New Application Assignment: {{applicationId}}',
fileName: 'user_assigned.html', fileName: 'user_assigned.html',
placeholders: ['userName', 'applicationId', 'dealerName', 'participantType', 'year'] 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']
} }
]; ];

View File

@ -14,6 +14,7 @@ import db from './database/models/index.js';
// Import routes (Modular Monolith Structure) // Import routes (Modular Monolith Structure)
// 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 authRoutes from './modules/auth/auth.routes.js';
import onboardingRoutes from './modules/onboarding/onboarding.routes.js'; import onboardingRoutes from './modules/onboarding/onboarding.routes.js';
import selfServiceRoutes from './modules/self-service/self-service.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/applications', onboardingRoutes);
app.use('/api/resignation', resignationRoutes); app.use('/api/resignation', resignationRoutes);
app.use('/api/resignations', resignationRoutes); app.use('/api/resignations', resignationRoutes);
app.use('/api/constitutional-change', (req: Request, res: Response, next: NextFunction) => { app.use('/api/constitutional-change', constitutionalRoutes);
req.url = '/constitutional' + (req.url === '/' ? '' : req.url);
next();
}, selfServiceRoutes);
// Relocation routes - direct mount // Relocation routes - direct mount
app.use('/api/relocation', relocationRoutes); app.use('/api/relocation', relocationRoutes);

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

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

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