From feeb613136b040d30de1d319992b1e406a0d5bcd Mon Sep 17 00:00:00 2001 From: laxman h Date: Tue, 31 Mar 2026 21:11:08 +0530 Subject: [PATCH] hirarchcy made orte stable and and tested upto interview levl 3 --- check_history.ts | 31 ++ package.json | 2 +- scripts/check_app.ts | 26 ++ scripts/debug_roles.ts | 20 + scripts/seed_normalized_data.ts | 347 ++++++++++-------- src/database/models/Application.ts | 5 - src/diag_zbh.ts | 65 ++++ src/diag_zm.ts | 39 ++ src/modules/admin/admin.controller.ts | 31 +- .../assessment/assessment.controller.ts | 138 +++++-- src/modules/eor/eor.controller.ts | 15 +- src/modules/loa/loa.controller.ts | 16 +- src/modules/loi/loi.controller.ts | 21 +- src/modules/master/master.controller.ts | 140 +++++-- src/modules/master/syncHierarchy.service.ts | 72 +++- .../onboarding/onboarding.controller.ts | 251 ++++++++++--- src/modules/onboarding/onboarding.routes.ts | 4 +- src/scripts/seedQuestionnaire.ts | 36 +- 18 files changed, 946 insertions(+), 313 deletions(-) create mode 100644 check_history.ts create mode 100644 scripts/check_app.ts create mode 100644 scripts/debug_roles.ts create mode 100644 src/diag_zbh.ts create mode 100644 src/diag_zm.ts diff --git a/check_history.ts b/check_history.ts new file mode 100644 index 0000000..5362e53 --- /dev/null +++ b/check_history.ts @@ -0,0 +1,31 @@ +import db from './src/database/models/index.js'; +const { Application, ApplicationStatusHistory } = db; + +async function checkApp() { + try { + const app = await Application.findOne({ order: [['updatedAt', 'DESC']] }); + if (!app) { + console.log('No apps found'); + return; + } + console.log('--- Application Info ---'); + console.log(`ID: ${app.id}, Reg: ${app.applicationId}, Name: ${app.applicantName}`); + console.log(`Current Status: ${app.overallStatus}, Progress: ${app.progressPercentage}`); + + const history = await ApplicationStatusHistory.findAll({ + where: { applicationId: app.id }, + order: [['createdAt', 'ASC']] + }); + + console.log('\n--- Status History ---'); + history.forEach((h: any) => { + console.log(`[${h.createdAt.toISOString()}] ${h.previousStatus} -> ${h.newStatus} (Reason: ${h.reason})`); + }); + } catch (err) { + console.error(err); + } finally { + process.exit(0); + } +} + +checkApp(); diff --git a/package.json b/package.json index 831b325..8dd06a5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "seed:approval-policies": "tsx scripts/seed-approval-policies.ts", "seed:email-templates": "tsx src/scripts/seed-master-emails.ts", "seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates", - "setup:fresh": "npm run migrate && npm run seed:all && npm run sync:hierarchy", + "setup:fresh": "npm run migrate && npm run seed:real-geo && npm run seed:all && npm run sync:hierarchy", "seed:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy", "sync:hierarchy": "tsx scripts/sync-all-hierarchy.ts", "seed:questionnaire": "tsx src/scripts/seedQuestionnaire.ts", diff --git a/scripts/check_app.ts b/scripts/check_app.ts new file mode 100644 index 0000000..29e79e5 --- /dev/null +++ b/scripts/check_app.ts @@ -0,0 +1,26 @@ +import db from '../src/database/models/index.js'; + +async function check() { + try { + const app = await (db as any).Application.findOne({ + where: { email: 'test-dealer-tumkur@example.com' }, + include: [{ model: (db as any).District, as: 'district' }] + }); + + if (app) { + console.log('Application Found:'); + console.log('ID:', app.applicationId); + console.log('District Name:', app.district ? app.district.name : 'NULL'); + console.log('District ID:', app.districtId); + console.log('Is Opportunity Available (Status):', app.overallStatus); + } else { + console.log('Application not found.'); + } + process.exit(0); + } catch (err) { + console.error(err); + process.exit(1); + } +} + +check(); diff --git a/scripts/debug_roles.ts b/scripts/debug_roles.ts new file mode 100644 index 0000000..ea96cb7 --- /dev/null +++ b/scripts/debug_roles.ts @@ -0,0 +1,20 @@ +import db from '../src/database/models/index.js'; + +async function check() { + try { + const roles = await (db as any).Role.findAll(); + console.log('--- ROLES START ---'); + console.log(JSON.stringify(roles.map((r: any) => ({ + name: r.roleName, + code: r.roleCode, + id: r.id + })), null, 2)); + console.log('--- ROLES END ---'); + process.exit(0); + } catch (error) { + console.error('Error listing roles:', error); + process.exit(1); + } +} + +check(); diff --git a/scripts/seed_normalized_data.ts b/scripts/seed_normalized_data.ts index 66616db..701a66c 100644 --- a/scripts/seed_normalized_data.ts +++ b/scripts/seed_normalized_data.ts @@ -3,166 +3,207 @@ import db from '../src/database/models/index.js'; import bcrypt from 'bcryptjs'; import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js'; - const { Role, Zone, Region, State, District, User, UserRole } = db; +const { Role, Zone, Region, State, District, User, UserRole } = db; - async function seed() { - console.log('--- Seeding Normalized Denormalized Data ---'); +async function seed() { + console.log('--- Seeding Comprehensive Golden Path Data ---'); - await db.sequelize.authenticate(); - // Use sync with alter false to match main app behavior - await db.sequelize.sync({ alter: false }); + await db.sequelize.authenticate(); + await db.sequelize.sync({ alter: false }); - const hashedPassword = await bcrypt.hash('Admin@123', 10); + const hashedPassword = await bcrypt.hash('Admin@123', 10); - // 1. Create Roles - const roles = [ - { roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' }, - { roleCode: 'DD Head', roleName: 'DD Head', category: 'NATIONAL' }, - { roleCode: 'ZBH', roleName: 'Zonal Business Head', category: 'ZONAL' }, - { roleCode: 'DD Lead', roleName: 'DD Lead', category: 'ZONAL' }, - { roleCode: 'RBM', roleName: 'Regional Business Manager', category: 'REGIONAL' }, - { roleCode: 'RM', roleName: 'Regional Manager', category: 'REGIONAL' }, - { roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' }, - { roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' }, - { roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' }, - { roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' }, - { roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' }, - { roleCode: 'DD Admin', roleName: 'DD Admin', category: 'ADMIN' }, - { roleCode: 'Legal Admin', roleName: 'Legal Admin', category: 'DEPARTMENT' } - ]; + // 1. Ensure Roles exist + const roles = [ + { roleCode: 'NBH', roleName: 'National Business Head', category: 'NATIONAL' }, + { roleCode: 'DD Head', roleName: 'DD Head', category: 'NATIONAL' }, + { roleCode: 'ZBH', roleName: 'Zonal Business Head', category: 'ZONAL' }, + { roleCode: 'DD Lead', roleName: 'DD Lead', category: 'ZONAL' }, + { roleCode: 'RBM', roleName: 'Regional Business Manager', category: 'REGIONAL' }, + { roleCode: 'DD-ZM', roleName: 'DD Zonal Manager', category: 'ZONAL' }, + { roleCode: 'ASM', roleName: 'Area Sales Manager', category: 'AREA' }, + { roleCode: 'Super Admin', roleName: 'Super Admin', category: 'NATIONAL' }, + { roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' }, + { roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' }, + { roleCode: 'DD Admin', roleName: 'DD Admin', category: 'ADMIN' } + ]; - for (const r of roles) { - await Role.findOrCreate({ where: { roleCode: r.roleCode }, defaults: r }); - } - console.log('Roles seeded.'); - - // 2. Create Districts (Hierarchy) - const [zone1] = await Zone.findOrCreate({ - where: { name: 'North Zone' }, - defaults: { name: 'North Zone', code: 'ZONE-N' } - }); - - const [zone2] = await Zone.findOrCreate({ - where: { name: 'South Zone' }, - defaults: { name: 'South Zone', code: 'ZONE-S' } - }); - - const [state1] = await State.findOrCreate({ - where: { name: 'Delhi' }, - defaults: { name: 'Delhi', zoneId: zone1.id } - }); - - const [region1] = await Region.findOrCreate({ - where: { name: 'NCR Region' }, - defaults: { name: 'NCR Region', zoneId: zone1.id } - }); - - const [region2] = await Region.findOrCreate({ - where: { name: 'Bangalore Region' }, - defaults: { name: 'Bangalore Region', zoneId: zone2.id } - }); - - const [district1] = await District.findOrCreate({ - where: { name: 'South Delhi District' }, - defaults: { name: 'South Delhi District', stateId: state1.id, regionId: region1.id, zoneId: zone1.id } - }); - - console.log('Geographical Hierarchy seeded.'); - - const mapUserRole = async (userRec: any, roleCode: string, assignment: { zoneId?: string | null, regionId?: string | null, districtId?: string | null } = {}) => { - const role = await Role.findOne({ where: { roleCode } }); - if (role) { - await UserRole.findOrCreate({ - where: { - userId: userRec.id, - roleId: role.id, - ...assignment - }, - defaults: { - userId: userRec.id, - roleId: role.id, - ...assignment, - isActive: true, - isPrimary: true - } - }); - } - }; - - // 4. Create Users and Map them - const nbhResult = await User.findOrCreate({ - where: { email: 'nbh@example.com' }, - defaults: { fullName: 'National Head', roleCode: 'NBH', password: hashedPassword } - }); - await mapUserRole(nbhResult[0], 'NBH'); - - const zbhResult = await User.findOrCreate({ - where: { email: 'zbh.north@example.com' }, - defaults: { fullName: 'North Zonal Head', roleCode: 'ZBH', password: hashedPassword, employeeId: 'ZBH001' } - }); - await mapUserRole(zbhResult[0], 'ZBH', { zoneId: zone1.id }); - - const rmResult = await User.findOrCreate({ - where: { email: 'rbm.delhi@example.com' }, - defaults: { fullName: 'Delhi Regional Manager', roleCode: 'RM', password: hashedPassword, employeeId: 'RBM001' } - }); - await mapUserRole(rmResult[0], 'RM', { regionId: region1.id }); - - // ZM is now mapped to Regions (not Districts) — multi-region support - const zmResult = await User.findOrCreate({ - where: { email: 'zm.north@example.com' }, - defaults: { fullName: 'North Zonal Manager', roleCode: 'DD-ZM', password: hashedPassword, employeeId: 'ZM001' } - }); - // One UserRole entry per region managed by this ZM - await mapUserRole(zmResult[0], 'DD-ZM', { zoneId: zone1.id, regionId: region1.id }); - - const asmResult = await User.findOrCreate({ - where: { email: 'asm.sdelhi@example.com' }, - defaults: { fullName: 'South Delhi ASM', roleCode: 'ASM', password: hashedPassword, employeeId: 'ASM001' } - }); - await mapUserRole(asmResult[0], 'ASM', { districtId: district1.id }); - - // Mock Users alignment - const mockUsers = [ - { email: 'ddlead@royalenfield.com', name: 'Meera Iyer', roleCode: 'DD Lead', assignment: { zoneId: zone1.id } }, - { email: 'finance@royalenfield.com', name: 'Rahul Verma', roleCode: 'Finance', assignment: {} }, - { email: 'dealer@royalenfield.com', name: 'Amit Sharma', roleCode: 'Dealer', assignment: { districtId: district1.id }, isExt: true }, - { email: 'admin@royalenfield.com', name: 'Laxman H', roleCode: 'DD Lead', assignment: { zoneId: zone2.id } }, - { email: 'yashwin@gmail.com', name: 'Yashwin', roleCode: 'ZBH', assignment: { zoneId: zone1.id } }, - { email: 'kenil@gmail.com', name: 'Kenil', roleCode: 'DD Lead', assignment: { zoneId: zone1.id } }, - { email: 'lince@gmail.com', name: 'Lince', roleCode: 'DD Admin', assignment: {} } - ]; - - for (const m of mockUsers) { - const [u] = await User.findOrCreate({ - where: { email: m.email }, - defaults: { - fullName: m.name, - roleCode: m.roleCode, - password: hashedPassword, - isExternal: (m as any).isExt || false, - status: 'active' - } - }); - await mapUserRole(u, m.roleCode, m.assignment); - } - - console.log('Users and Mappings seeded.'); - - console.log('--- Triggering Hierarchy Synchronization ---'); - // syncLocationManagers now resolves zmId from the region parent — so districts get updated automatically - const districtList = await District.findAll({ attributes: ['id'] }); - for (const d of districtList) await syncLocationManagers(d.id); - - const regionList = await Region.findAll({ attributes: ['id'] }); - for (const r of regionList) await syncRegionManager(r.id); - - const zoneList = await Zone.findAll({ attributes: ['id'] }); - for (const z of zoneList) await syncZoneManager(z.id); - - console.log('--- Seeding & Synchronization Complete ---'); + for (const r of roles) { + await Role.findOrCreate({ where: { roleCode: r.roleCode }, defaults: r }); } + const mapUserRole = async (userRec: any, roleCode: string, assignment: any = {}) => { + const role = await Role.findOne({ where: { roleCode } }); + if (role) { + await UserRole.findOrCreate({ + where: { userId: userRec.id, roleId: role.id, ...assignment }, + defaults: { userId: userRec.id, roleId: role.id, ...assignment, isActive: true, isPrimary: true } + }); + } + }; + + // 2. Create Hierarchical Structure + // Zones + const zones = [ + { name: 'North Zone', code: 'ZONE-N' }, + { name: 'South Zone', code: 'ZONE-S' } + ]; + const zoneMap: Record = {}; + for (const z of zones) { + const [zone] = await Zone.findOrCreate({ where: { name: z.name }, defaults: z }); + zoneMap[z.name] = zone; + } + + // Regions + const regions = [ + { name: 'NCR Region', zoneName: 'North Zone' }, + { name: 'Punjab Region', zoneName: 'North Zone' }, + { name: 'Karnataka Region', zoneName: 'South Zone' }, + { name: 'Tamil Nadu Region', zoneName: 'South Zone' } + ]; + const regionMap: Record = {}; + for (const r of regions) { + const zone = zoneMap[r.zoneName]; + const [region] = await Region.findOrCreate({ + where: { name: r.name }, + defaults: { name: r.name, zoneId: zone.id } + }); + regionMap[r.name] = region; + } + + // States & Districts + const districts = [ + { name: 'South Delhi', stateName: 'DELHI', regionName: 'NCR Region' }, + { name: 'NOIDA', stateName: 'UTTAR PRADESH', regionName: 'NCR Region' }, + { name: 'Ludhiana', stateName: 'PUNJAB', regionName: 'Punjab Region' }, + { name: 'Bangalore Urban', stateName: 'KARNATAKA', regionName: 'Karnataka Region' } + ]; + for (const d of districts) { + const region = regionMap[d.regionName]; + const [state] = await State.findOrCreate({ + where: { name: d.stateName }, + defaults: { name: d.stateName, zoneId: region.zoneId } + }); + await District.findOrCreate({ + where: { name: d.name }, + defaults: { name: d.name, stateId: state.id, regionId: region.id, zoneId: region.zoneId, isActive: true } + }); + } + + // 3. Create Key Management Users + // National / Administrative + const nationalUsers = [ + { email: 'nbh@royalenfield.com', name: 'Alwyn John', role: 'NBH' }, + { email: 'ddhead@royalenfield.com', name: 'Vikram Singh', role: 'DD Head' }, + { email: 'finance@royalenfield.com', name: 'Rahul Verma', role: 'Finance' }, + { email: 'admin@royalenfield.com', name: 'Laxman H', role: 'Super Admin' }, + { email: 'lince@gmail.com', name: 'Lince', role: 'DD Admin' } + ]; + for (const u of nationalUsers) { + const [user] = await User.findOrCreate({ + where: { email: u.email }, + defaults: { fullName: u.name, roleCode: u.role, password: hashedPassword, status: 'active' } + }); + await mapUserRole(user, u.role); + } + + // Frontend Mock Users for Quick Login (Ensuring exact matches) + const frontendMocks = [ + { email: 'ddlead@royalenfield.com', name: 'Meera Iyer', role: 'DD Lead', zone: 'North Zone' }, + { email: 'yashwin@gmail.com', name: 'Yashwin', role: 'ZBH', zone: 'North Zone' }, + { email: 'kenil@gmail.com', name: 'Kenil', role: 'DD Lead', zone: 'North Zone' }, + { email: 'dealer@royalenfield.com', name: 'Amit Sharma', role: 'Dealer', district: 'South Delhi' } + ]; + for (const m of frontendMocks) { + const assignment: any = {}; + if (m.zone) assignment.zoneId = zoneMap[m.zone].id; + if (m.district) { + const d = await District.findOne({ where: { name: m.district } }); + if (d) { + assignment.districtId = d.id; + assignment.zoneId = d.zoneId; + assignment.regionId = d.regionId; + } + } + const [user] = await User.findOrCreate({ + where: { email: m.email }, + defaults: { fullName: m.name, roleCode: m.role, password: hashedPassword, status: 'active' } + }); + await mapUserRole(user, m.role, assignment); + } + + // Zonal Business Heads (Additional) + const zbhUsers = [ + { email: 'zbh.south@royalenfield.com', name: 'Srinivasan K', zone: 'South Zone' } + ]; + for (const u of zbhUsers) { + const zone = zoneMap[u.zone]; + const [user] = await User.findOrCreate({ + where: { email: u.email }, + defaults: { fullName: u.name, roleCode: 'ZBH', password: hashedPassword, status: 'active' } + }); + await mapUserRole(user, 'ZBH', { zoneId: zone.id }); + } + + // Regional Managers (RBMs) + const rbmUsers = [ + { email: 'rbm.ncr@royalenfield.com', name: 'Sanjay Dutt', region: 'NCR Region' }, + { email: 'rbm.punjab@royalenfield.com', name: 'Harpreet Singh', region: 'Punjab Region' }, + { email: 'rbm.kar@royalenfield.com', name: 'Manish Kumar', region: 'Karnataka Region' } + ]; + for (const u of rbmUsers) { + const region = regionMap[u.region]; + const [user] = await User.findOrCreate({ + where: { email: u.email }, + defaults: { fullName: u.name, roleCode: 'RBM', password: hashedPassword, status: 'active' } + }); + await mapUserRole(user, 'RBM', { regionId: region.id, zoneId: region.zoneId }); + } + + // Zonal Managers (DD-ZM) - Assigned to Regions + const zmUsers = [ + { email: 'zm.ncr@royalenfield.com', name: 'Rajesh Khanna', region: 'NCR Region' }, + { email: 'zm.south@royalenfield.com', name: 'Kartik Subbaraj', region: 'Karnataka Region' } + ]; + for (const u of zmUsers) { + const region = regionMap[u.region]; + const [user] = await User.findOrCreate({ + where: { email: u.email }, + defaults: { fullName: u.name, roleCode: 'DD-ZM', password: hashedPassword, status: 'active' } + }); + await mapUserRole(user, 'DD-ZM', { regionId: region.id, zoneId: region.zoneId }); + } + + // ASMs (Assigned to Districts) + const asmUsers = [ + { email: 'asm.sdelhi@royalenfield.com', name: 'Arun Jaitley', district: 'South Delhi' }, + { email: 'asm.noida@royalenfield.com', name: 'Kishan Reddy', district: 'NOIDA' }, + { email: 'asm.bangalore@royalenfield.com', name: 'Vishnu Dev', district: 'Bangalore Urban' } + ]; + for (const u of asmUsers) { + const district = await District.findOne({ where: { name: u.district } }); + if (district) { + const [user] = await User.findOrCreate({ + where: { email: u.email }, + defaults: { fullName: u.name, roleCode: 'ASM', password: hashedPassword, status: 'active' } + }); + await mapUserRole(user, 'ASM', { districtId: district.id, zoneId: district.zoneId, regionId: district.regionId }); + } + } + + console.log('--- Triggering Hierarchy Synchronization ---'); + const districtList = await District.findAll({ attributes: ['id'] }); + for (const d of districtList) await syncLocationManagers(d.id); + + const regionList = await Region.findAll({ attributes: ['id'] }); + for (const r of regionList) await syncRegionManager(r.id); + + const zoneList = await Zone.findAll({ attributes: ['id'] }); + for (const z of zoneList) await syncZoneManager(z.id); + + console.log('--- Golden Path Seeding Complete ---'); +} + seed().catch(err => { console.error(err); process.exit(1); diff --git a/src/database/models/Application.ts b/src/database/models/Application.ts index f5a51cd..e09fd8c 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/Application.ts @@ -24,7 +24,6 @@ export interface ApplicationAttributes { description: string | null; address: string | null; pincode: string | null; - locationType: string | null; currentStage: string; overallStatus: string; progressPercentage: number; @@ -142,10 +141,6 @@ export default (sequelize: Sequelize) => { type: DataTypes.STRING, allowNull: true }, - locationType: { - type: DataTypes.STRING, - allowNull: true - }, currentStage: { type: DataTypes.ENUM(...Object.values(APPLICATION_STAGES)), defaultValue: APPLICATION_STAGES.DD diff --git a/src/diag_zbh.ts b/src/diag_zbh.ts new file mode 100644 index 0000000..795d807 --- /dev/null +++ b/src/diag_zbh.ts @@ -0,0 +1,65 @@ + +import db from '../src/database/models/index.js'; +import { ROLES } from '../src/common/config/constants.js'; + +async function diagnoseZBH() { + try { + console.log('--- Diagnosis Start ---'); + + // 1. Check Role table + const roles = await db.Role.findAll(); + console.log('Available Roles:'); + roles.forEach(r => console.log(`- ID: ${r.id}, Code: ${r.roleCode}, Name: ${r.roleName}`)); + + const zbhRole = await db.Role.findOne({ + where: { + [db.Sequelize.Op.or]: [ + { roleCode: ROLES.ZBH }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zonal Business Head%' } }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zone Business Head%' } } + ] + } + }); + + if (zbhRole) { + console.log(`\nIdentified ZBH Role: ID=${zbhRole.id}, Code=${zbhRole.roleCode}`); + } else { + console.error('\nFAILED to identify ZBH Role using current logic!'); + } + + // 2. Check Zones + const zones = await db.Zone.findAll({ + include: [{ model: db.User, as: 'zonalBusinessHead' }] + }); + console.log('\nZones Status:'); + zones.forEach(z => { + console.log(`- Zone: ${z.name} (ID: ${z.id})`); + console.log(` zbhId (Zone table): ${z.zbhId}`); + console.log(` zbhCode (Zone table): ${z.zbhCode}`); + console.log(` zonalBusinessHead (Association): ${z.zonalBusinessHead ? z.zonalBusinessHead.fullName : 'None'}`); + }); + + // 3. Check UserRoles for ZBH + if (zbhRole) { + const zbhAssignments = await db.UserRole.findAll({ + where: { roleId: zbhRole.id, isActive: true }, + include: [ + { model: db.User, as: 'user' }, + { model: db.Zone, as: 'zone' } + ] + }); + console.log('\nActive ZBH UserRole Assignments:'); + zbhAssignments.forEach(a => { + console.log(`- User: ${a.user.fullName} (ID: ${a.userId}) -> Zone: ${a.zone?.name || 'NULL'} (ID: ${a.zoneId})`); + }); + } + + console.log('\n--- Diagnosis End ---'); + process.exit(0); + } catch (error) { + console.error('Diagnosis Failed:', error); + process.exit(1); + } +} + +diagnoseZBH(); diff --git a/src/diag_zm.ts b/src/diag_zm.ts new file mode 100644 index 0000000..4ddda8d --- /dev/null +++ b/src/diag_zm.ts @@ -0,0 +1,39 @@ + +import db from '../src/database/models/index.js'; +import { ROLES } from '../src/common/config/constants.js'; + +async function diagnoseZM() { + try { + console.log('--- ZM Diagnosis Start ---'); + + const zones = await db.Zone.findAll(); + const roles = await db.Role.findAll({ + where: { roleCode: { [db.Sequelize.Op.in]: ['ZBH', 'DD-ZM'] } } + }); + + const zmRoleIds = roles.filter((r: any) => r.roleCode === 'DD-ZM').map((r: any) => r.id); + console.log(`ZM Role IDs: ${zmRoleIds.join(', ')}`); + + for (const zone of zones) { + console.log(`\nZone: ${zone.name} (ID: ${zone.id})`); + + const zms = await db.UserRole.findAll({ + where: { roleId: { [db.Sequelize.Op.in]: zmRoleIds }, zoneId: zone.id, isActive: true }, + include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'mobileNumber', 'employeeId'] }] + }); + + console.log(`Active ZM Count: ${zms.length}`); + zms.forEach(z => { + console.log(`- ZM: ${z.user.fullName} (ID: ${z.user.id})`); + }); + } + + console.log('\n--- ZM Diagnosis End ---'); + process.exit(0); + } catch (error) { + console.error('ZM Diagnosis Failed:', error); + process.exit(1); + } +} + +diagnoseZM(); diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index 305ada4..80ed518 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -176,18 +176,23 @@ export const getAllUsers = async (req: Request, res: Response) => { const { roleCode, locationId } = req.query; const whereClause: any = {}; - if (roleCode) { - // Handle both single string and array of role codes (if passed as multiple params) - if (Array.isArray(roleCode)) { - whereClause.roleCode = { [Op.in]: roleCode }; - } else { - whereClause.roleCode = roleCode; + let rawRoleCode: any = roleCode || req.query['roleCode[]']; + let finalRoleCodes: string[] = []; + + if (rawRoleCode) { + if (Array.isArray(rawRoleCode)) { + finalRoleCodes = rawRoleCode; + } else if (typeof rawRoleCode === 'string') { + finalRoleCodes = rawRoleCode.split(',').map(r => r.trim()); } } + if (finalRoleCodes.length > 0) { + whereClause.roleCode = { [Op.in]: finalRoleCodes }; + } + const nationalRoles = ['NBH', 'DD Head', 'Super Admin']; - const isNationalRole = (typeof roleCode === 'string' && nationalRoles.includes(roleCode)) || - (Array.isArray(roleCode) && roleCode.some(r => typeof r === 'string' && nationalRoles.includes(r))); + const isNationalRole = finalRoleCodes.some(r => nationalRoles.includes(r)); if (!isNationalRole && locationId) { const district: any = await db.District.findByPk(locationId as string, { @@ -196,7 +201,15 @@ export const getAllUsers = async (req: Request, res: Response) => { if (district) { const relevantIds = [district.id, district.zoneId, district.regionId, district.stateId].filter(Boolean); - whereClause.districtId = { [Op.in]: relevantIds }; + whereClause[Op.or] = [ + { districtId: { [Op.in]: relevantIds } }, + { zoneId: { [Op.in]: relevantIds } }, + { regionId: { [Op.in]: relevantIds } }, + { stateId: { [Op.in]: relevantIds } }, + { '$userRoles.districtId$': { [Op.in]: relevantIds } }, + { '$userRoles.zoneId$': { [Op.in]: relevantIds } }, + { '$userRoles.regionId$': { [Op.in]: relevantIds } } + ]; } } diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 8381103..77d12d3 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -55,7 +55,29 @@ const processInterviewApprovalDecision = async (params: { const policy = await ensureInterviewPolicy(interview.level); const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : []; - if (requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && roleCode !== 'Super Admin') { + // Check if user is an assigned participant for this specific level + const userAssignments = await db.RequestParticipant.findAll({ + where: { + requestId: interview.applicationId, + requestType: 'application', + userId: userId + } + }); + + const assignedParticipant = userAssignments.find((p: any) => + p.metadata && Number(p.metadata.interviewLevel) === Number(interview.level) + ); + + const isAssigned = !!assignedParticipant; + const assignedRole = assignedParticipant?.metadata?.role; + + console.log(`[debug] User ID: ${userId}, Role: ${roleCode}, isAssigned: ${isAssigned}, assignedRole: ${assignedRole}`); + if (isAssigned) { + console.log(`[debug] Assigned Participant Metadata: ${JSON.stringify(assignedParticipant.metadata)}`); + } + + // Forbidden if not Super Admin AND not in required roles AND not an assigned participant + if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) { return { forbidden: true, policy, requiredRoles, currentRole: roleCode }; } @@ -80,7 +102,7 @@ const processInterviewApprovalDecision = async (params: { interviewId, stageCode: policy.stageCode, actorUserId: userId, - actorRole: roleCode, + actorRole: assignedRole || roleCode, // Use assigned role if available (e.g. ZBH acting as ZM) decision, remarks: remarks || null }); @@ -89,35 +111,63 @@ const processInterviewApprovalDecision = async (params: { where: { interviewId, stageCode: policy.stageCode } }); - const uniqueApprovalsByRole = new Set( - actions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole) - ); + const approvedActions = actions.filter((a: any) => a.decision === 'Approved'); + const uniqueApprovalsByRole = new Set(approvedActions.map((a: any) => a.actorRole)); + + console.log(`[debug] Interview Level: ${interview.level}, Stage: ${policy.stageCode}`); + console.log(`[debug] Required Roles: ${JSON.stringify(requiredRoles)}`); + console.log(`[debug] Approved Roles: ${JSON.stringify(Array.from(uniqueApprovalsByRole))}`); + console.log(`[debug] Approved Actions Count: ${approvedActions.length}`); + console.log(`[debug] Min Approvals Required: ${policy.minApprovals}`); + const hasRejection = actions.some((a: any) => a.decision === 'Rejected'); const hasAllRequiredRoleApprovals = requiredRoles.length === 0 ? true : requiredRoles.every((role: string) => uniqueApprovalsByRole.has(role)); + const meetsMinApprovals = uniqueApprovalsByRole.size >= (policy.minApprovals || 1); - if (hasRejection) { - await interview.update({ status: 'Completed' }); - await db.Application.update({ - overallStatus: 'Rejected', - currentStage: 'Rejected' - }, { where: { id: interview.applicationId } }); - await db.ApplicationStatusHistory.create({ - applicationId: interview.applicationId, - previousStatus: 'Interview Pending', - newStatus: 'Rejected', - changedBy: userId, - reason: 'Rejected in interview approval workflow' - }); - } else if (hasAllRequiredRoleApprovals && meetsMinApprovals) { + console.log(`[debug] hasAllRequiredRoleApprovals: ${hasAllRequiredRoleApprovals}`); + console.log(`[debug] meetsMinApprovals: ${meetsMinApprovals}`); + console.log(`[debug] hasRejection: ${hasRejection}`); + + if (hasRejection) { + await interview.update({ status: 'Completed' }); + + const application: any = await db.Application.findByPk(interview.applicationId); + let rejectionProgress = application?.progressPercentage || 0; + + // Marker progress values to show which stage was last reached + if (policy.stageCode.includes('LEVEL1')) rejectionProgress = Math.max(rejectionProgress, 35); + if (policy.stageCode.includes('LEVEL2')) rejectionProgress = Math.max(rejectionProgress, 50); + if (policy.stageCode.includes('LEVEL3')) rejectionProgress = Math.max(rejectionProgress, 65); + + await db.Application.update({ + overallStatus: 'Rejected', + currentStage: 'Rejected', + progressPercentage: rejectionProgress + }, { where: { id: interview.applicationId } }); + + await db.ApplicationStatusHistory.create({ + applicationId: interview.applicationId, + previousStatus: 'Interview Pending', + newStatus: 'Rejected', + changedBy: userId, + reason: 'Rejected in interview approval workflow' + }); + } else if (hasAllRequiredRoleApprovals && meetsMinApprovals) { await interview.update({ status: 'Completed', outcome: 'Selected' }); const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'Level 3 Approved' }; + const progressMap: any = { 1: 40, 2: 55, 3: 70 }; const newStatus = nextStatusMap[interview.level] || 'Approved'; + + const application = await db.Application.findByPk(interview.applicationId); + const newProgress = progressMap[interview.level] || (application?.progressPercentage || 0); + await db.Application.update({ overallStatus: newStatus, - currentStage: newStatus + currentStage: newStatus, + progressPercentage: newProgress }, { where: { id: interview.applicationId } }); await db.ApplicationStatusHistory.create({ applicationId: interview.applicationId, @@ -291,17 +341,17 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { const application = await db.Application.findByPk(applicationId); let participantIds: string[] = Array.isArray(participants) ? participants : []; - // Auto-include relevant ZBH by location hierarchy when interviewer list is omitted. - if (participantIds.length === 0 && (application?.districtId || application?.locationId)) { - const ancestorLocationIds = await getLocationAncestors(application.districtId || application.locationId); - const zonalHeads = await User.findAll({ + // Auto-fill participants from pre-assigned RequestParticipants if not provided + if (participantIds.length === 0) { + const preAssigned = await db.RequestParticipant.findAll({ where: { - roleCode: 'ZBH', - locationId: { [Op.in]: ancestorLocationIds } + requestId: applicationId, + requestType: 'application', + 'metadata.interviewLevel': levelNum }, - attributes: ['id'] + attributes: ['userId'] }); - participantIds = zonalHeads.map((user: any) => user.id); + participantIds = preAssigned.map((p: any) => p.userId); } participantIds = [...new Set(participantIds)]; @@ -464,6 +514,19 @@ export const submitKTMatrix = async (req: AuthRequest, res: Response) => { })); await db.KTMatrixScore.bulkCreate(scoreRecords); + // Auto-process approval if recommendation is provided + if (recommendation && req.user?.id && req.user?.roleCode) { + const normalizedDecision = (['Recommended', 'Approved', 'Selected', 'Approve'].includes(recommendation)) + ? 'Approved' : 'Rejected'; + await processInterviewApprovalDecision({ + interviewId, + decision: normalizedDecision, + remarks: feedback, + userId: req.user.id, + roleCode: req.user.roleCode + }); + } + res.status(201).json({ success: true, message: 'KT Matrix submitted successfully', data: evaluation }); } catch (error) { console.error('Submit KT Matrix error:', error); @@ -510,6 +573,18 @@ export const submitLevel2Feedback = async (req: AuthRequest, res: Response) => { await db.InterviewFeedback.bulkCreate(feedbackRecords); } + // Auto-process approval if recommendation is provided + if (recommendation && req.user?.id && req.user?.roleCode) { + const normalizedDecision = (['Recommended', 'Approved', 'Selected', 'Approve'].includes(recommendation)) + ? 'Approved' : 'Rejected'; + await processInterviewApprovalDecision({ + interviewId, + decision: normalizedDecision, + userId: req.user.id, + roleCode: req.user.roleCode + }); + } + res.status(201).json({ success: true, message: 'Level 2 Feedback submitted successfully', data: evaluation }); } catch (error) { console.error('Submit Level 2 Feedback error:', error); @@ -627,9 +702,8 @@ export const updateRecommendation = async (req: AuthRequest, res: Response) => { return res.status(401).json({ success: false, message: 'Unauthorized' }); } const { interviewId, recommendation } = req.body; // recommendation: 'Recommended' | 'Not Recommended' - const normalizedDecision = (recommendation === 'Recommended' || recommendation === 'Selected' || recommendation === 'Approved') - ? 'Approved' - : 'Rejected'; + const normalizedDecision = (['Recommended', 'Approved', 'Selected', 'Approve'].includes(recommendation)) + ? 'Approved' : 'Rejected'; const result: any = await processInterviewApprovalDecision({ interviewId, @@ -672,7 +746,7 @@ export const updateInterviewDecision = async (req: AuthRequest, res: Response) = return res.status(401).json({ success: false, message: 'Unauthorized' }); } const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected' - const normalizedDecision = decision === 'Approved' ? 'Approved' : 'Rejected'; + const normalizedDecision = (decision === 'Approved' || decision === 'Approve') ? 'Approved' : 'Rejected'; const result: any = await processInterviewApprovalDecision({ interviewId, decision: normalizedDecision, diff --git a/src/modules/eor/eor.controller.ts b/src/modules/eor/eor.controller.ts index 5698c54..db9fed3 100644 --- a/src/modules/eor/eor.controller.ts +++ b/src/modules/eor/eor.controller.ts @@ -6,14 +6,12 @@ import { AuthRequest } from '../../types/express.types.js'; export const getChecklist = async (req: Request, res: Response) => { try { const { applicationId } = req.params; - // Could auto-create if not exists? let checklist = await EorChecklist.findOne({ where: { applicationId }, include: [{ model: EorChecklistItem, as: 'items', include: ['proofDocument'] }] }); if (!checklist) { - // Optional: Return empty or create new res.status(404).json({ success: false, message: 'Checklist not found' }); return; } @@ -63,7 +61,8 @@ export const createChecklist = async (req: AuthRequest, res: Response) => { await EorChecklistItem.bulkCreate(itemsData); } - await application.update({ overallStatus: 'EOR In Progress' }); + // Status transition will be handled by the global handleApprove workflow or explicit trigger + // await application.update({ overallStatus: 'EOR In Progress' }); res.status(201).json({ success: true, message: 'EOR Checklist initiated with default items', data: checklist }); } catch (error) { @@ -111,6 +110,16 @@ export const submitAudit = async (req: AuthRequest, res: Response) => { { where: { id: checklistId } } ); + if (status === 'Completed') { + const checklist = await EorChecklist.findByPk(checklistId); + if (checklist) { + await db.Application.update({ + overallStatus: 'Approved', + progressPercentage: 100 + }, { where: { id: checklist.applicationId } }); + } + } + res.json({ success: true, message: 'EOR Audit submitted' }); } catch (error) { res.status(500).json({ success: false, message: 'Error submitting audit' }); diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 6054d6e..6571770 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -63,7 +63,10 @@ export const createRequest = async (req: AuthRequest, res: Response) => { } }); - await application.update({ overallStatus: 'LOA Pending' }); + await application.update({ + overallStatus: 'LOA Pending', + progressPercentage: 92 + }); res.status(201).json({ success: true, message: 'LOA Request initiated with DD Head approval', data: request }); } catch (error) { @@ -130,7 +133,11 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { if (action === 'Rejected' || hasRejection) { await request.update({ status: 'Rejected' }); - await db.Application.update({ overallStatus: 'LOA Rejected' }, { where: { id: request.applicationId } }); + await db.Application.update({ + overallStatus: 'LOA Rejected', + currentStage: 'Rejected', + progressPercentage: 92 + }, { where: { id: request.applicationId } }); return res.json({ success: true, message: 'LOA Request rejected' }); } @@ -145,7 +152,10 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { filePath: `/uploads/loa/${mockFile}` }); - await db.Application.update({ overallStatus: 'Authorized for Operations' }, { where: { id: request.applicationId } }); + await db.Application.update({ + overallStatus: 'Authorized for Operations', + progressPercentage: 97 + }, { where: { id: request.applicationId } }); res.json({ success: true, message: 'LOA fully approved and issued' }); } else { res.json({ diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index 8bd7cef..c3ef11b 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -54,7 +54,10 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => { status: 'Acknowledged' }); - await db.Application.update({ overallStatus: 'Dealer Code Generation' }, { where: { id: request.applicationId } }); + await db.Application.update({ + overallStatus: 'Dealer Code Generation', + progressPercentage: 90 + }, { where: { id: request.applicationId } }); res.json({ success: true, message: 'LOI Acknowledged by applicant' }); } catch (error) { @@ -87,7 +90,10 @@ export const createRequest = async (req: AuthRequest, res: Response) => { } }); - await application.update({ overallStatus: 'LOI In Progress' }); + await application.update({ + overallStatus: 'LOI In Progress', + progressPercentage: 75 + }); res.status(201).json({ success: true, message: 'LOI Request initiated with Finance approval', data: request }); } catch (error) { @@ -174,7 +180,11 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { // 2. Handle Logic based on Action if (action === 'Rejected' || hasRejection) { await request.update({ status: 'Rejected' }); - await db.Application.update({ overallStatus: 'LOI Rejected' }, { where: { id: request.applicationId } }); + await db.Application.update({ + overallStatus: 'LOI Rejected', + currentStage: 'Rejected', + progressPercentage: 75 + }, { where: { id: request.applicationId } }); return res.json({ success: true, message: 'LOI Request rejected' }); } @@ -191,7 +201,10 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { filePath: `/uploads/loi/${mockFile}` }); - await db.Application.update({ overallStatus: 'LOI Issued' }, { where: { id: request.applicationId } }); + await db.Application.update({ + overallStatus: 'LOI Issued', + progressPercentage: 85 + }, { where: { id: request.applicationId } }); res.json({ success: true, message: 'LOI Request fully approved and document generated' }); } else { diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index e65564c..91b4b84 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -1,7 +1,8 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; -import { syncLocationManagers, syncRegionManager, syncZoneManager } from './syncHierarchy.service.js'; +import { syncDistrictsByRegion, syncDistrictsByZone, syncLocationManagers, syncRegionManager, syncZoneManager } from './syncHierarchy.service.js'; import db from '../../database/models/index.js'; +import { ROLES } from '../../common/config/constants.js'; const { User } = db; // --- Areas (Granular Locations) --- @@ -219,7 +220,7 @@ export const getRegions = async (req: Request, res: Response) => { db.UserRole.count({ where: { roleId: { [db.Sequelize.Op.in]: rmRoleIds }, regionId: region.id, isActive: true }, distinct: true, col: 'userId' }), db.UserRole.findOne({ where: { roleId: { [db.Sequelize.Op.in]: rmRoleIds }, regionId: region.id, isActive: true }, - include: [{ model: db.User, as: 'user' }] + include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'mobileNumber', 'employeeId'] }] }) ]); @@ -297,6 +298,11 @@ export const createRegion = async (req: Request, res: Response) => { ); } + await syncRegionManager(region.id); + if (Array.isArray(targetDistrictIds) && targetDistrictIds.length > 0) { + await syncDistrictsByRegion(region.id); + } + res.status(201).json({ success: true, message: 'Region created', data: region }); } catch (error: any) { console.error('Create region error:', error); @@ -373,6 +379,9 @@ export const updateRegion = async (req: Request, res: Response) => { } } + await syncRegionManager(id as string); + await syncDistrictsByRegion(id as string); + res.json({ success: true, message: 'Region updated' }); } catch (error: any) { console.error('Update region error:', error); @@ -395,7 +404,8 @@ export const getZones = async (req: Request, res: Response) => { include: [ { model: db.Region, as: 'regions', attributes: ['id', 'name'] }, { model: db.State, as: 'states', attributes: ['id', 'name'] }, - { model: db.District, as: 'districts', attributes: ['id'] } + { model: db.District, as: 'districts', attributes: ['id'] }, + { model: db.User, as: 'zonalBusinessHead', attributes: ['id', 'fullName', 'email', 'employeeId'] } ], order: [['name', 'ASC']] }); @@ -411,28 +421,42 @@ export const getZones = async (req: Request, res: Response) => { const [zbhAssignment, zms] = await Promise.all([ db.UserRole.findOne({ - where: { roleId: { [db.Sequelize.Op.in]: zbhRoleIds }, zoneId: zone.id, isPrimary: true, isActive: true }, - include: [{ model: db.User, as: 'user' }] + where: { roleId: { [db.Sequelize.Op.in]: zbhRoleIds }, zoneId: zone.id, isActive: true }, + include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'mobileNumber', 'employeeId'] }], + order: [['assignedAt', 'DESC']] }), db.UserRole.findAll({ where: { roleId: { [db.Sequelize.Op.in]: zmRoleIds }, zoneId: zone.id, isActive: true }, - include: [{ model: db.User, as: 'user' }] + include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'mobileNumber', 'employeeId'] }] }) ]); - // For each ZM, fetch their assigned districts in this zone + // For each ZM, fetch their assigned regions in this zone const zonalManagers = await Promise.all(zms.map(async (zmRole: any) => { - const districts = await db.District.findAll({ - where: { zmId: zmRole.user.id, zoneId: zone.id }, // ZMs usually managed regions or specific district sets - attributes: ['name'] + // Fetch all active regions assigned to this ZM in this zone + const assignedRoles = await db.UserRole.findAll({ + where: { + userId: zmRole.user.id, + zoneId: zone.id, + isActive: true, + roleId: { [db.Sequelize.Op.in]: zmRoleIds } + }, + include: [{ model: db.Region, as: 'region', attributes: ['id', 'name'] }] }); + + const regionNames = Array.from(new Set( + assignedRoles + .filter((r: any) => r.region?.name) + .map((r: any) => r.region.name) + )); + return { id: zmRole.user.id, name: zmRole.user.fullName || zmRole.user.name, email: zmRole.user.email, phone: zmRole.user.mobileNumber || 'N/A', code: zmRole.managerCode || zmRole.user.employeeId || 'N/A', - districts: districts.map((d: any) => d.name) + regions: regionNames }; })); @@ -442,11 +466,17 @@ export const getZones = async (req: Request, res: Response) => { zoneJson.zmCount = zms.length; zoneJson.zonalBusinessHead = zbhAssignment ? { id: zbhAssignment.user.id, - name: zbhAssignment.user.fullName || zbhAssignment.user.name, + name: zbhAssignment.user.fullName, email: zbhAssignment.user.email, phone: zbhAssignment.user.mobileNumber || 'N/A', code: zone.zbhCode || 'N/A' - } : null; + } : (zone.zonalBusinessHead ? { + id: zone.zonalBusinessHead.id, + name: zone.zonalBusinessHead.fullName, + email: zone.zonalBusinessHead.email, + phone: zone.zonalBusinessHead.mobileNumber || 'N/A', + code: zone.zbhCode || 'N/A' + } : null); zoneJson.zonalManagers = zonalManagers; return zoneJson; })); @@ -464,11 +494,21 @@ export const createZone = async (req: Request, res: Response) => { const zone = await db.Zone.create({ name, code }); - // 1. Assign ZBH - if (managerId) { - const zbhRole = await db.Role.findOne({ where: { roleCode: 'ZBH' } }); + if (managerId && managerId !== 'none') { + const zbhRole = await db.Role.findOne({ + where: { + [db.Sequelize.Op.or]: [ + { roleCode: ROLES.ZBH }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zonal Business Head%' } }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zone Business Head%' } } + ] + } + }); + if (zbhRole) { - await db.UserRole.update({ isActive: false }, { where: { zoneId: zone.id, roleId: zbhRole.id } }); + // Deactivate any existing active ZBH roles for this zone (unlikely for new zone but safe) + await db.UserRole.update({ isActive: false }, { where: { zoneId: zone.id, roleId: zbhRole.id, isActive: true } }); + await db.UserRole.create({ userId: managerId, roleId: zbhRole.id, @@ -476,6 +516,10 @@ export const createZone = async (req: Request, res: Response) => { isActive: true, isPrimary: true }); + + // Sync to Zone model + const { syncZoneManager } = await import('./syncHierarchy.service.js'); + await syncZoneManager(zone.id as string); } } @@ -500,15 +544,26 @@ export const updateZone = async (req: Request, res: Response) => { await zone.update({ name, code }); - // 1. Update ZBH - if (managerId) { - const zbhRole = await db.Role.findOne({ where: { roleCode: 'ZBH' } }); + const { syncZoneManager } = await import('./syncHierarchy.service.js'); + + if (managerId && managerId !== 'none') { + const zbhRole = await db.Role.findOne({ + where: { + [db.Sequelize.Op.or]: [ + { roleCode: ROLES.ZBH }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zonal Business Head%' } }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zone Business Head%' } } + ] + } + }); + if (zbhRole) { - // Deactivate old ZBHs for this zone + // 1. Deactivate old ZBHs for this zone await db.UserRole.update({ isActive: false }, { - where: { zoneId: id, roleId: zbhRole.id } + where: { zoneId: id, roleId: zbhRole.id, isActive: true } }); - // Assign new ZBH + + // 2. Assign new ZBH role await db.UserRole.create({ userId: managerId, roleId: zbhRole.id, @@ -516,7 +571,30 @@ export const updateZone = async (req: Request, res: Response) => { isActive: true, isPrimary: true }); + + // 3. Sync to Zone model + await syncZoneManager(id as string); } + } else if (managerId === null || managerId === 'none') { + // Find ZBH role to deactivate + const zbhRole = await db.Role.findOne({ + where: { + [db.Sequelize.Op.or]: [ + { roleCode: ROLES.ZBH }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zonal Business Head%' } }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zone Business Head%' } } + ] + } + }); + + if (zbhRole) { + await db.UserRole.update({ isActive: false }, { + where: { zoneId: id, roleId: zbhRole.id, isActive: true } + }); + } + + // Sync to Zone model (will set zbhId to null) + await syncZoneManager(id as string); } if (Array.isArray(stateIds)) { @@ -866,6 +944,15 @@ export const saveZM = async (req: Request, res: Response) => { // Create new role assignments for each region if (Array.isArray(regionIds) && regionIds.length > 0) { for (const regionId of regionIds) { + // Ensure exclusivity: Deactivate ANY existing ZM role assigned to this region + await db.UserRole.update({ isActive: false }, { + where: { + regionId: regionId, + roleId: zmRole.id, + isActive: true + } + }); + await db.UserRole.create({ userId, roleId: zmRole.id, @@ -892,6 +979,13 @@ export const saveZM = async (req: Request, res: Response) => { // Cleanup: ZMs no longer manage districts directly await db.District.update({ zmId: null, zmCode: null }, { where: { zmId: userId } }); + // Trigger sync for all affected regions to update district.zmId + if (Array.isArray(regionIds) && regionIds.length > 0) { + for (const regionId of regionIds) { + await syncDistrictsByRegion(regionId); + } + } + res.json({ success: true, message: 'Zonal Manager saved successfully' }); } catch (error) { console.error('Save ZM error:', error); diff --git a/src/modules/master/syncHierarchy.service.ts b/src/modules/master/syncHierarchy.service.ts index 4ff587c..8698ff6 100644 --- a/src/modules/master/syncHierarchy.service.ts +++ b/src/modules/master/syncHierarchy.service.ts @@ -1,4 +1,5 @@ import db from '../../database/models/index.js'; +import { ROLES } from '../../common/config/constants.js'; /** * Synchronizes the Location (District) table's manager IDs with the UserRole table. @@ -14,12 +15,17 @@ export const syncLocationManagers = async (districtId: string) => { if (!district) return; // Fetch active assignments for this district PLUS any region-level assignments for its parent region + const orConditions: any[] = [{ districtId, isActive: true }]; + if (district.regionId) { + orConditions.push({ regionId: district.regionId, isActive: true }); + } + if (district.zoneId) { + orConditions.push({ zoneId: district.zoneId, isActive: true }); + } + const activeAssignments = await UserRole.findAll({ where: { - [Op.or]: [ - { districtId, isActive: true }, - { regionId: district.regionId, isActive: true } - ] + [Op.or]: orConditions }, include: [ { model: Role, as: 'role', attributes: ['roleCode'] }, @@ -28,14 +34,24 @@ export const syncLocationManagers = async (districtId: string) => { }); // Find primary/last assigned manager for each type - const asm = activeAssignments.find((a: any) => (a.role as any)?.roleCode === 'ASM' || (a.role as any)?.roleCode === 'AREA SALES MANAGER'); - const ddAm = activeAssignments.find((a: any) => (a.role as any)?.roleCode === 'DD-AM' || (a.role as any)?.roleCode === 'AREA MANAGER'); + // ASM and DD-AM are STRICTLY mapped to the District level to prevent territory explosion + const asm = activeAssignments.find((a: any) => + ((a.role as any)?.roleCode === 'ASM' || (a.role as any)?.roleCode === 'AREA SALES MANAGER') && + a.districtId === districtId + ); + const ddAm = activeAssignments.find((a: any) => + ((a.role as any)?.roleCode === 'DD-AM' || (a.role as any)?.roleCode === 'AREA MANAGER') && + a.districtId === districtId + ); - // ZM can be assigned to the District (legacy) or the Region (new) - // We prioritize the Region-level assignment if multiple exist + // ZM can be assigned to the District (legacy/override) or the Region (standard) or Zone (broad) + // Order of priority: 1. District level, 2. Region level, 3. Zone level const zm = activeAssignments.find((a: any) => ((a.role as any)?.roleCode === 'DD-ZM' || (a.role as any)?.roleCode === 'ZM' || (a.role as any)?.roleCode === 'ZONAL MANAGER') && - a.regionId === district.regionId + a.districtId === districtId + ) || activeAssignments.find((a: any) => + ((a.role as any)?.roleCode === 'DD-ZM' || (a.role as any)?.roleCode === 'ZM' || (a.role as any)?.roleCode === 'ZONAL MANAGER') && + a.regionId === district.regionId && !a.districtId ) || activeAssignments.find((a: any) => ((a.role as any)?.roleCode === 'DD-ZM' || (a.role as any)?.roleCode === 'ZM' || (a.role as any)?.roleCode === 'ZONAL MANAGER') ); @@ -100,7 +116,13 @@ export const syncZoneManager = async (zoneId: string) => { { model: db.Role, as: 'role', - where: { roleCode: { [db.Sequelize.Op.in]: ['ZBH', 'ZONE BUSINESS HEAD', 'ZONAL BUSINESS HEAD'] } } + where: { + [db.Sequelize.Op.or]: [ + { roleCode: ROLES.ZBH }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zonal Business Head%' } }, + { roleName: { [db.Sequelize.Op.iLike]: '%Zone Business Head%' } } + ] + } }, { model: db.User, as: 'user', attributes: ['employeeId'] } ], @@ -119,3 +141,33 @@ export const syncZoneManager = async (zoneId: string) => { console.error(`[Sync] Error synchronizing Zone ${zoneId}:`, error); } }; + +/** + * Synchronizes all districts within a specific region. + */ +export const syncDistrictsByRegion = async (regionId: string) => { + try { + const districts = await db.District.findAll({ where: { regionId }, attributes: ['id'] }); + for (const district of districts) { + await syncLocationManagers(district.id); + } + console.log(`[Sync] All districts in Region ${regionId} synchronized successfully`); + } catch (error) { + console.error(`[Sync] Error synchronizing districts for Region ${regionId}:`, error); + } +}; + +/** + * Synchronizes all districts within a specific zone. + */ +export const syncDistrictsByZone = async (zoneId: string) => { + try { + const districts = await db.District.findAll({ where: { zoneId }, attributes: ['id'] }); + for (const district of districts) { + await syncLocationManagers(district.id); + } + console.log(`[Sync] All districts in Zone ${zoneId} synchronized successfully`); + } catch (error) { + console.error(`[Sync] Error synchronizing districts for Zone ${zoneId}:`, error); + } +}; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index b2ea6c2..d79d3c1 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -1,18 +1,26 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; -const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region } = db; +const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region, Zone } = db; import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { v4 as uuidv4 } from 'uuid'; import { Op } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; import { sendOpportunityEmail, sendNonOpportunityEmail } from '../../common/utils/email.service.js'; +import { syncLocationManagers } from '../master/syncHierarchy.service.js'; -const normalizeLocationType = (rawType?: string | null): string | null => { - if (!rawType) return null; - const normalized = String(rawType).trim().toLowerCase(); - const supportedTypes = new Set(['area', 'district', 'state', 'region', 'zone']); - return supportedTypes.has(normalized) ? normalized : null; +// Helper to find district by name and state name combination +const findDistrictByName = async (districtName: string, stateName?: string) => { + if (!districtName) return null; + + return await District.findOne({ + where: { name: { [Op.iLike]: districtName.trim() } }, + include: stateName ? [{ + model: State, + as: 'state', + where: { name: { [Op.iLike]: stateName.trim() } } + }] : [] + }); }; export const submitApplication = async (req: AuthRequest, res: Response) => { @@ -42,52 +50,27 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { } const applicationId = `APP-${new Date().getFullYear()}-${uuidv4().substring(0, 6).toUpperCase()}`; - - // Resolve location using canonical id/type first, then backward-compatible state+district names. - let locationId = null; - let isOpportunityAvailable = false; - const normalizedType = normalizeLocationType(locationType); - - if (req.body.locationId && normalizedType === 'district') { - const selectedDistrict = await District.findByPk(req.body.locationId); - if (selectedDistrict) { - locationId = selectedDistrict.id; - isOpportunityAvailable = true; - } - } else if (req.body.locationId && normalizedType === 'state') { - const selectedState = await State.findByPk(req.body.locationId); - if (selectedState) { - locationId = selectedState.id; - isOpportunityAvailable = true; - } - } - - // Backward-compatible fallback path for older payloads that send only names. - if (!locationId && req.body.district) { - const districtRecord: any = await District.findOne({ - where: { name: { [Op.iLike]: req.body.district } }, - include: req.body.state ? [{ - model: State, - as: 'state', - where: { name: { [Op.iLike]: req.body.state } } - }] : [] - }); + let districtId = null; + + // Primary Mapping: Resolve district by Name (State + District combination) + // This is robust for external sources where ID mapping is difficult. + if (req.body.district) { + const districtRecord: any = await findDistrictByName(req.body.district, req.body.state); if (districtRecord) { - locationId = districtRecord.id; - isOpportunityAvailable = true; + districtId = districtRecord.id; } } - if (!locationId && req.body.state) { - const stateRecord = await State.findOne({ - where: { name: { [Op.iLike]: req.body.state } } - }); - if (stateRecord) { - locationId = stateRecord.id; - isOpportunityAvailable = true; + // Secondary Fallback: If ID is explicitly provided (Legacy/Internal use) + if (!districtId && req.body.districtId) { + const selectedDistrict = await District.findByPk(req.body.districtId); + if (selectedDistrict) { + districtId = selectedDistrict.id; } } + const isOpportunityAvailable = !!districtId; + const application = await Application.create({ opportunityId: null, // De-coupled from Opportunity table as per user request applicationId, @@ -104,8 +87,10 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { currentStage: APPLICATION_STAGES.DD, overallStatus: isOpportunityAvailable ? APPLICATION_STATUS.QUESTIONNAIRE_PENDING : APPLICATION_STATUS.SUBMITTED, progressPercentage: isOpportunityAvailable ? 10 : 0, - locationId, - districtId: locationId + districtId, + score: 0, + documents: [], + timeline: [] }); // Log Status History @@ -413,6 +398,7 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { ddLeadShortlisted: true, isShortlisted: true, overallStatus: 'Shortlisted', + progressPercentage: 30, assignedTo: primaryAssigneeId, updatedAt: new Date(), }, { @@ -436,6 +422,9 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { } }); } + + // AUTO-FILL Interview Evaluators for all 3 levels + await assignStageEvaluators(appId); } // Create Status History Entries @@ -468,6 +457,174 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { } }; +/** +/** + * Helper to assign default evaluators for all 3 interview levels based on location + */ +/** + * Helper to assign default evaluators for all 3 interview levels, LOI, and LOA based on location + */ +const assignStageEvaluators = async (applicationId: string) => { + try { + console.log(`[debug] Starting stage evaluator assignment for App: ${applicationId}`); + const application = await Application.findByPk(applicationId, { + include: [ + { + model: District, + as: 'district', + include: [ + { model: Region, as: 'region' }, + { model: Zone, as: 'zone' } + ] + } + ] + }); + + if (!application) { + console.log(`[debug] Application ${applicationId} not found`); + return; + } + + if (!application.district) { + console.log(`[debug] Application ${applicationId} has NO district linked. Skipping auto-assign.`); + return; + } + + const district = application.district; + const region = district.region; + const zone = district.zone; + + console.log(`[debug] Mapping for District: ${district.name}, Region: ${region?.name}, Zone: ${zone?.name}`); + + const evaluatorMappings: any = { + 1: [], // Level 1 Interview: DD-ZM + RBM + 2: [], // Level 2 Interview: DD Lead + ZBH + 3: [], // Level 3 Interview: NBH + DD Head + 'LOI_APPROVAL': [], // LOI: Finance, DD Head, NBH + 'LOA_APPROVAL': [] // LOA: DD Head, NBH + }; + + // --- INTERVIEWS --- + + // Level 1: DD-ZM (District manager) + RBM (Region manager) + if (district.zmId) evaluatorMappings[1].push({ id: district.zmId, role: 'DD-ZM' }); + if (region && region.rbmId) evaluatorMappings[1].push({ id: region.rbmId, role: 'RBM' }); + + // Level 2: ZBH (Zone manager) + DD Lead (Filtered by Zone) + if (zone && zone.zbhId) evaluatorMappings[2].push({ id: zone.zbhId, role: 'ZBH' }); + if (zone) { + const ddLead = await db.User.findOne({ + where: { roleCode: 'DD Lead', status: 'active' }, + include: [{ + model: db.UserRole, + as: 'userRoles', + where: { zoneId: zone.id, isActive: true } + }] + }); + if (ddLead) evaluatorMappings[2].push({ id: ddLead.id, role: 'DD Lead' }); + } + + // Level 3: NBH + DD Head (National Level Roles) + const level3Roles = ['NBH', 'DD Head']; + for (const roleCode of level3Roles) { + const user = await db.User.findOne({ where: { roleCode, status: 'active' } }); + if (user) evaluatorMappings[3].push({ id: user.id, role: roleCode }); + } + + // --- LOI & LOA --- + + // National roles for LOI / LOA + const nationalRoles = ['NBH', 'DD Head', 'Finance']; + const nationalUsers: Record = {}; + for (const r of nationalRoles) { + const user = await db.User.findOne({ where: { roleCode: r, status: 'active' } }); + if (user) nationalUsers[r] = user.id; + } + + // LOI: Finance, DD Head, NBH + if (nationalUsers['Finance']) evaluatorMappings['LOI_APPROVAL'].push({ id: nationalUsers['Finance'], role: 'Finance' }); + if (nationalUsers['DD Head']) evaluatorMappings['LOI_APPROVAL'].push({ id: nationalUsers['DD Head'], role: 'DD Head' }); + if (nationalUsers['NBH']) evaluatorMappings['LOI_APPROVAL'].push({ id: nationalUsers['NBH'], role: 'NBH' }); + + // LOA: DD Head, NBH + if (nationalUsers['DD Head']) evaluatorMappings['LOA_APPROVAL'].push({ id: nationalUsers['DD Head'], role: 'DD Head' }); + if (nationalUsers['NBH']) evaluatorMappings['LOA_APPROVAL'].push({ id: nationalUsers['NBH'], role: 'NBH' }); + + // Persistence logic: Store in RequestParticipant with metadata + const allStages = [1, 2, 3, 'LOI_APPROVAL', 'LOA_APPROVAL']; + for (const stage of allStages) { + const assignments = evaluatorMappings[stage]; + for (const assign of assignments) { + const { id: userId, role } = assign; + + const whereClause: any = { + requestId: applicationId, + requestType: 'application', + userId, + participantType: 'contributor' + }; + + const existing = await db.RequestParticipant.findOne({ where: whereClause }); + + // If interview level, check metadata match. If stageCode, check metadata match. + const isInterview = typeof stage === 'number'; + if (existing) { + const match = isInterview + ? (existing.metadata?.interviewLevel === stage) + : (existing.metadata?.stageCode === stage); + if (match) continue; + } + + await db.RequestParticipant.create({ + requestId: applicationId, + requestType: 'application', + userId, + participantType: 'contributor', + joinedMethod: 'auto', + metadata: isInterview + ? { interviewLevel: stage, role, autoMapped: true } + : { stageCode: stage, role, autoMapped: true } + }); + } + } + } catch (error) { + console.error(`Error assigning stage evaluators for application ${applicationId}:`, error); + } +}; + +export const retriggerEvaluators = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const application = await Application.findByPk(id); + if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); + + // Remove existing auto-mapped participants (Interviews, LOI, LOA) + // Using a more robust Postgres-compatible JSON path check + await db.RequestParticipant.destroy({ + where: { + requestId: id, + requestType: 'application', + joinedMethod: 'auto', + [Op.and]: [ + db.sequelize.literal(`"metadata"->>'autoMapped' = 'true'`) + ] + } + }); + + // Sync district data before re-assignment to ensure fresh manager mapping + if (application.districtId) { + await syncLocationManagers(application.districtId); + } + + await assignStageEvaluators(id as string); + + res.json({ success: true, message: 'All stage evaluators (Interviews, LOI, LOA) have been re-assigned successfully.' }); + } catch (error) { + console.error('Retrigger evaluators error:', error); + res.status(500).json({ success: false, message: 'Error re-triggering evaluator assignment' }); + } +}; + export const assignArchitectureTeam = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts index 8cea16a..a927ed0 100644 --- a/src/modules/onboarding/onboarding.routes.ts +++ b/src/modules/onboarding/onboarding.routes.ts @@ -3,7 +3,8 @@ const router = express.Router(); import { submitApplication, getApplications, getApplicationById, updateApplicationStatus, uploadDocuments, getApplicationDocuments, bulkShortlist, - assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes + assignArchitectureTeam, updateArchitectureStatus, generateDealerCodes, + retriggerEvaluators } from './onboarding.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; @@ -27,6 +28,7 @@ router.get('/applications/:id/documents', getApplicationDocuments); // Existing router.post('/applications/:id/assign-architecture', assignArchitectureTeam); router.put('/applications/:id/architecture-status', updateArchitectureStatus); router.post('/applications/:id/generate-codes', generateDealerCodes); +router.post('/applications/:id/retrigger-evaluators', retriggerEvaluators); // Questionnaire Routes diff --git a/src/scripts/seedQuestionnaire.ts b/src/scripts/seedQuestionnaire.ts index 78cef92..92c3944 100644 --- a/src/scripts/seedQuestionnaire.ts +++ b/src/scripts/seedQuestionnaire.ts @@ -119,7 +119,7 @@ const seedQuestionnaire = async () => { }, { text: "Are you an existing dealer/vendor of Royal Enfield?", - type: "radio", + type: "yesno", section: "Basic Information", options: [ { text: "Yes", score: 0 }, @@ -132,7 +132,7 @@ const seedQuestionnaire = async () => { // Section 2: Profile & Background (Scoring Starts) { text: "Educational Qualification", - type: "radio", + type: "select", section: "Profile & Background", options: [ { text: "Under Graduate", score: 2 }, @@ -158,7 +158,7 @@ const seedQuestionnaire = async () => { }, { text: "Are you a native of the Proposed Location?", - type: "radio", + type: "select", section: "Location", options: [ { text: "Native", score: 10 }, @@ -168,17 +168,9 @@ const seedQuestionnaire = async () => { weight: 10, order: 10 }, - { - text: "Proposed Location Photos (If any)", - type: "file", - section: "Location", - options: null, - weight: 0, - order: 11 - }, { text: "Why do you want to partner with Royal Enfield?", - type: "radio", + type: "select", section: "Strategy", options: [ { text: "Absence of Royal Enfield in the particular location and presence of opportunity", score: 2 }, @@ -190,7 +182,7 @@ const seedQuestionnaire = async () => { }, { text: "Who will be the partners in proposed company?", - type: "radio", + type: "select", section: "Business Structure", options: [ { text: "Immediate Family", score: 5 }, @@ -203,7 +195,7 @@ const seedQuestionnaire = async () => { }, { text: "Who will be managing the Royal Enfield dealership", - type: "radio", + type: "select", section: "Business Structure", options: [ { text: "I will be managing full time", score: 10 }, @@ -215,7 +207,7 @@ const seedQuestionnaire = async () => { }, { text: "Proposed Firm Type", - type: "radio", + type: "select", section: "Business Structure", options: [ { text: "Proprietorship", score: 3 }, @@ -228,7 +220,7 @@ const seedQuestionnaire = async () => { }, { text: "What are you currently doing?", - type: "radio", + type: "select", section: "Experience", options: [ { text: "Running automobile dealership", score: 10 }, @@ -241,7 +233,7 @@ const seedQuestionnaire = async () => { }, { text: "Do you own a property in proposed location?", - type: "radio", + type: "select", section: "Location", options: [ { text: "Yes", score: 10 }, @@ -252,7 +244,7 @@ const seedQuestionnaire = async () => { }, { text: "How are you planning to invest in the Royal Enfield business", - type: "radio", + type: "select", section: "Financials", options: [ { text: "I will be investing my own funds", score: 10 }, @@ -264,7 +256,7 @@ const seedQuestionnaire = async () => { }, { text: "What are your plans of expansion with RE?", - type: "radio", + type: "select", section: "Strategy", options: [ { text: "Willing to expand with the help of partners", score: 2 }, @@ -276,7 +268,7 @@ const seedQuestionnaire = async () => { }, { text: "Will you be expanding to any other automobile OEM in the future?", - type: "radio", + type: "yesno", section: "Strategy", options: [ { text: "Yes", score: 0 }, @@ -287,7 +279,7 @@ const seedQuestionnaire = async () => { }, { text: "Do you own a Royal Enfield ?", - type: "radio", + type: "select", section: "Brand Loyalty", options: [ { text: "Yes, it is registered in my name", score: 3 }, @@ -300,7 +292,7 @@ const seedQuestionnaire = async () => { }, { text: "Do you go for long leisure rides", - type: "radio", + type: "select", section: "Brand Loyalty", options: [ { text: "Yes, with the Royal Enfield riders", score: 3 },