onboarding flow made stable end to end also fincance team verification implementation done relocation stated

This commit is contained in:
laxman h 2026-04-02 19:30:45 +05:30
parent e64b64380d
commit 8d2a7874de
20 changed files with 699 additions and 61 deletions

View File

@ -15,7 +15,8 @@
"seed:permissions": "tsx scripts/seed-permissions.ts", "seed:permissions": "tsx scripts/seed-permissions.ts",
"seed:approval-policies": "tsx scripts/seed-approval-policies.ts", "seed:approval-policies": "tsx scripts/seed-approval-policies.ts",
"seed:email-templates": "tsx src/scripts/seed-master-emails.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", "seed:configs": "tsx scripts/seed-system-configs.ts",
"seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates && npm run seed:configs",
"setup:fresh": "npm run migrate && npm run seed:real-geo && 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", "seed:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy",
"sync:hierarchy": "tsx scripts/sync-all-hierarchy.ts", "sync:hierarchy": "tsx scripts/sync-all-hierarchy.ts",

View File

@ -0,0 +1,53 @@
/**
* Script to delete a test relocation request by requestId
* Usage: npx tsx scripts/delete-test-relocation.ts REL-1775129490244-5B9C
*/
import db from '../src/database/models/index.js';
async function deleteRelocationRequest(requestId: string) {
try {
console.log(`Deleting relocation request: ${requestId}`);
// Find the request
const request = await db.RelocationRequest.findOne({
where: { requestId }
});
if (!request) {
console.log(`Request ${requestId} not found`);
process.exit(0);
}
// Delete associated RequestParticipants
await db.RequestParticipant.destroy({
where: { requestId: request.id, requestType: 'relocation' }
});
console.log('Deleted associated participants');
// Delete associated Worknotes
await db.Worknote.destroy({
where: { requestId: request.id, requestType: 'relocation' }
});
console.log('Deleted associated worknotes');
// Delete the request
await request.destroy();
console.log(`Deleted relocation request: ${requestId}`);
console.log('✅ Done!');
process.exit(0);
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
} finally {
await db.sequelize.close();
}
}
const requestId = process.argv[2];
if (!requestId) {
console.log('Usage: npx tsx scripts/delete-test-relocation.ts <requestId>');
process.exit(1);
}
deleteRelocationRequest(requestId);

View File

@ -0,0 +1,74 @@
/**
* Migration Script: Add newDistrictId and newStateId to RelocationRequest
* Run: npx ts-node scripts/migrate-relocation-schema.ts
*/
import db from '../src/database/models/index.js';
async function migrate() {
const queryInterface = db.sequelize.getQueryInterface();
try {
console.log('Starting relocation schema migration...');
// Get table description to check existing columns
const tableInfo = await queryInterface.describeTable('relocation_requests');
// Add newDistrictId column if not exists
if (!tableInfo.newDistrictId) {
console.log('Adding newDistrictId column...');
await queryInterface.addColumn('relocation_requests', 'newDistrictId', {
type: db.Sequelize.DataTypes.UUID,
allowNull: true,
references: {
model: 'districts',
key: 'id'
}
});
console.log('✓ newDistrictId column added');
} else {
console.log('- newDistrictId column already exists');
}
// Add newStateId column if not exists
if (!tableInfo.newStateId) {
console.log('Adding newStateId column...');
await queryInterface.addColumn('relocation_requests', 'newStateId', {
type: db.Sequelize.DataTypes.UUID,
allowNull: true,
references: {
model: 'states',
key: 'id'
}
});
console.log('✓ newStateId column added');
} else {
console.log('- newStateId column already exists');
}
// Update enum to include 'Intercity' if not already present
console.log('Checking relocationType enum...');
try {
await db.sequelize.query(`
ALTER TYPE "enum_relocation_requests_relocationType"
ADD VALUE IF NOT EXISTS 'Intercity';
`);
console.log('✓ Intercity added to enum (if not already present)');
} catch (enumError: any) {
// PostgreSQL doesn't support IF NOT EXISTS for enum values in some versions
if (enumError.code === '42710') {
console.log('- Intercity already exists in enum');
} else {
console.log('Warning: Could not update enum:', enumError.message);
}
}
console.log('\n✅ Migration completed successfully!');
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
} finally {
await db.sequelize.close();
}
}
migrate();

View File

@ -0,0 +1,46 @@
import db from '../src/database/models/index.js';
const seedSystemConfigs = async () => {
try {
console.log('Seeding system configurations...');
const configs = [
{
key: 'INITIAL_SECURITY_DEPOSIT',
value: { amount: 500000, currency: 'INR' },
category: 'SECURITY_DEPOSIT',
description: 'Default Initial Security Deposit amount for new dealer onboarding'
},
{
key: 'FINAL_SECURITY_DEPOSIT',
value: { amount: 1500000, currency: 'INR' },
category: 'SECURITY_DEPOSIT',
description: 'Default Final Security Deposit amount for new dealer onboarding'
}
];
for (const config of configs) {
await db.SystemConfiguration.findOrCreate({
where: { key: config.key },
defaults: {
...config,
isActive: true
}
});
}
console.log('System configurations seeded successfully.');
} catch (error) {
console.error('Error seeding system configurations:', error);
} finally {
// Only close if this is the main module
// db.sequelize.close();
}
};
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
seedSystemConfigs().then(() => process.exit(0));
}
export default seedSystemConfigs;

View File

@ -336,6 +336,8 @@ export const DOCUMENT_TYPES = {
STATUTORY_AUDIT: 'Statutory Approval Certificate', STATUTORY_AUDIT: 'Statutory Approval Certificate',
BANK_GUARANTEE: 'Bank Guarantee Document', BANK_GUARANTEE: 'Bank Guarantee Document',
SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt', SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt',
SECURITY_DEPOSIT_INITIAL: 'Initial Security Deposit Receipt',
SECURITY_DEPOSIT_FINAL: 'Final Security Deposit Receipt',
OTHER: 'Other' OTHER: 'Other'
} as const; } as const;

View File

@ -266,6 +266,7 @@ export default (sequelize: Sequelize) => {
Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' }); Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' });
Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' }); Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' });
Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' }); Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' });
Application.hasMany(models.SecurityDeposit, { foreignKey: 'applicationId', as: 'securityDeposits' });
}; };
return Application; return Application;

View File

@ -10,6 +10,8 @@ export interface RelocationRequestAttributes {
newAddress: string; newAddress: string;
newCity: string; newCity: string;
newState: string; newState: string;
newDistrictId: string | null;
newStateId: string | null;
reason: string; reason: string;
currentStage: typeof RELOCATION_STAGES[keyof typeof RELOCATION_STAGES]; currentStage: typeof RELOCATION_STAGES[keyof typeof RELOCATION_STAGES];
status: string; status: string;
@ -64,6 +66,22 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false allowNull: false
}, },
newDistrictId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'districts',
key: 'id'
}
},
newStateId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'states',
key: 'id'
}
},
reason: { reason: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false allowNull: false
@ -108,12 +126,22 @@ export default (sequelize: Sequelize) => {
foreignKey: 'dealerId', foreignKey: 'dealerId',
as: 'dealer' as: 'dealer'
}); });
RelocationRequest.belongsTo(models.District, {
foreignKey: 'newDistrictId',
as: 'newDistrict'
});
RelocationRequest.belongsTo(models.State, {
foreignKey: 'newStateId',
as: 'newStateRef'
});
RelocationRequest.hasMany(models.Worknote, { RelocationRequest.hasMany(models.Worknote, {
foreignKey: 'requestId', foreignKey: 'requestId',
as: 'worknotes', as: 'worknotes',
scope: { requestType: 'relocation' }, scope: { requestType: 'relocation' },
constraints: false constraints: false
}); });
// Note: Participants are computed dynamically based on outlet location hierarchy
// See getRequestById in relocation.controller.ts
}; };
return RelocationRequest; return RelocationRequest;

View File

@ -7,6 +7,7 @@ export interface SecurityDepositAttributes {
paymentReference: string | null; paymentReference: string | null;
proofDocumentId: string | null; proofDocumentId: string | null;
status: string; status: string;
depositType: 'INITIAL' | 'FINAL';
verifiedAt: Date | null; verifiedAt: Date | null;
verifiedBy: string | null; verifiedBy: string | null;
} }
@ -48,6 +49,11 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: 'pending' defaultValue: 'pending'
}, },
depositType: {
type: DataTypes.ENUM('INITIAL', 'FINAL'),
allowNull: false,
defaultValue: 'INITIAL'
},
verifiedAt: { verifiedAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true allowNull: true

View File

@ -0,0 +1,52 @@
import { Model, DataTypes, Sequelize } from 'sequelize';
export interface SystemConfigurationAttributes {
id: string;
key: string;
value: any;
category: string;
description?: string;
isActive: boolean;
}
export interface SystemConfigurationInstance extends Model<SystemConfigurationAttributes>, SystemConfigurationAttributes { }
export default (sequelize: Sequelize) => {
const SystemConfiguration = sequelize.define<SystemConfigurationInstance>('SystemConfiguration', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
key: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
value: {
type: DataTypes.JSONB,
allowNull: false
},
category: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true
}
}, {
tableName: 'system_configurations',
timestamps: true,
indexes: [
{ fields: ['key'] },
{ fields: ['category'] }
]
});
return SystemConfiguration;
};

View File

@ -18,6 +18,7 @@ import createSLAConfiguration from './SLAConfiguration.js';
import createSLAReminder from './SLAReminder.js'; import createSLAReminder from './SLAReminder.js';
import createSLAEscalationConfig from './SLAEscalationConfig.js'; import createSLAEscalationConfig from './SLAEscalationConfig.js';
import createWorkflowStageConfig from './WorkflowStageConfig.js'; import createWorkflowStageConfig from './WorkflowStageConfig.js';
import createSystemConfiguration from './SystemConfiguration.js';
import createNotification from './Notification.js'; import createNotification from './Notification.js';
import createDistrict from './District.js'; import createDistrict from './District.js';
import createLocation from './Location.js'; import createLocation from './Location.js';
@ -190,6 +191,7 @@ db.SLATracking = createSLATracking(sequelize);
db.SLABreach = createSLABreach(sequelize); db.SLABreach = createSLABreach(sequelize);
db.StageApprovalPolicy = createStageApprovalPolicy(sequelize); db.StageApprovalPolicy = createStageApprovalPolicy(sequelize);
db.StageApprovalAction = createStageApprovalAction(sequelize); db.StageApprovalAction = createStageApprovalAction(sequelize);
db.SystemConfiguration = createSystemConfiguration(sequelize);
// Define associations // Define associations
Object.keys(db).forEach((modelName) => { Object.keys(db).forEach((modelName) => {

View File

@ -695,11 +695,13 @@ export const getInterviews = async (req: Request, res: Response) => {
{ {
model: InterviewParticipant, model: InterviewParticipant,
as: 'participants', as: 'participants',
include: [{ model: User, as: 'user' }] // Assuming association exists separate: true,
include: [{ model: User, as: 'user' }]
}, },
{ {
model: InterviewEvaluation, model: InterviewEvaluation,
as: 'evaluations', as: 'evaluations',
separate: true,
include: [{ include: [{
model: User, model: User,
as: 'evaluator', as: 'evaluator',

View File

@ -33,11 +33,13 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
const application = await Application.findByPk(applicationId); const application = await Application.findByPk(applicationId);
if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
// SRS Validation: Only allow onboarding at the 'Inauguration' stage // SRS Validation: Allow onboarding at 'Inauguration' or 'Approved' stage
if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION) { // 'Approved' is accepted because frontend may update status before calling createDealer
if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION &&
application.overallStatus !== APPLICATION_STATUS.APPROVED) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' stage before final onboarding. Current status: ${application.overallStatus}` message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' or '${APPLICATION_STATUS.APPROVED}' stage before final onboarding. Current status: ${application.overallStatus}`
}); });
} }

View File

@ -102,6 +102,19 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
if (!currentApproval) return res.status(400).json({ success: false, message: 'No pending approval found' }); if (!currentApproval) return res.status(400).json({ success: false, message: 'No pending approval found' });
// MANDATORY FINANCIAL CHECK
if (action === 'Approved') {
const finalDeposit = await SecurityDeposit.findOne({
where: { applicationId: request.applicationId, depositType: 'FINAL', status: 'Verified' }
});
if (!finalDeposit) {
return res.status(400).json({
success: false,
message: 'LOA Approval Blocked: Final Security Deposit (₹15L) must be verified by Finance team before proceeding.'
});
}
}
await currentApproval.update({ await currentApproval.update({
action, action,
remarks, remarks,
@ -229,9 +242,18 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => { export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => {
try { try {
const { applicationId, amount, paymentReference, proofDocumentId, status } = req.body; const { applicationId, amount, paymentReference, proofDocumentId, status, depositType } = req.body;
let deposit = await SecurityDeposit.findOne({ where: { applicationId } }); 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(applicationId);
const application = await db.Application.findOne({
where: isUUID ? { [db.Sequelize.Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId }
});
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
let deposit = await SecurityDeposit.findOne({
where: { applicationId: application.id, depositType: depositType || 'INITIAL' }
});
if (deposit) { if (deposit) {
await deposit.update({ await deposit.update({
@ -241,11 +263,12 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) =>
}); });
} else { } else {
deposit = await SecurityDeposit.create({ deposit = await SecurityDeposit.create({
applicationId, applicationId: application.id,
amount, amount,
paymentReference, paymentReference,
proofDocumentId, proofDocumentId,
status: status || 'Pending' status: status || 'Pending',
depositType: depositType || 'INITIAL'
}); });
} }
@ -258,10 +281,24 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) =>
export const getSecurityDeposit = async (req: Request, res: Response) => { export const getSecurityDeposit = async (req: Request, res: Response) => {
try { try {
const { applicationId } = req.params; const applicationId = req.params.applicationId as string;
const deposit = await SecurityDeposit.findOne({ where: { applicationId } });
res.json({ success: true, data: deposit }); 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(applicationId);
const application = await db.Application.findOne({
where: isUUID ? { [db.Sequelize.Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId }
});
if (!application) {
return res.json({ success: true, data: [] });
}
const deposits = await SecurityDeposit.findAll({
where: { applicationId: application.id },
order: [['createdAt', 'ASC']]
});
res.json({ success: true, data: deposits });
} catch (error) { } catch (error) {
console.error('Fetch Security Deposit error:', error);
res.status(500).json({ success: false, message: 'Error fetching security deposit' }); res.status(500).json({ success: false, message: 'Error fetching security deposit' });
} }
} };

View File

@ -210,6 +210,15 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
filePath: `/uploads/loi/${mockFile}` filePath: `/uploads/loi/${mockFile}`
}); });
// Create Initial Security Deposit record (Advance Payment)
await db.SecurityDeposit.findOrCreate({
where: { applicationId: request.applicationId, depositType: 'INITIAL' },
defaults: {
amount: 200000, // 2 Lakhs Advance
status: 'Pending'
}
});
const application = await db.Application.findByPk(request.applicationId); const application = await db.Application.findByPk(request.applicationId);
if (application) { if (application) {
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, {

View File

@ -1104,3 +1104,49 @@ export const deleteArea = deleteLocation;
export const createDistrictLegacy = createDistrict; export const createDistrictLegacy = createDistrict;
// --- System Configuration ---
export const getSystemConfigs = async (req: Request, res: Response) => {
try {
const { category, key } = req.query;
const where: any = { isActive: true };
if (category) where.category = category;
if (key) where.key = key;
const configs = await db.SystemConfiguration.findAll({ where });
// Transform into a key-value map for easier frontend consumption if requested
if (req.query.format === 'map') {
const configMap: any = {};
configs.forEach((c: any) => {
configMap[c.key] = c.value;
});
return res.json({ success: true, data: configMap });
}
res.json({ success: true, data: configs });
} catch (error) {
console.error('Get system configs error:', error);
res.status(500).json({ success: false, message: 'Error fetching system configurations' });
}
};
export const saveSystemConfig = async (req: Request, res: Response) => {
try {
const { id, key, value, category, description, isActive } = req.body;
let config;
if (id) {
config = await db.SystemConfiguration.findByPk(id);
if (!config) return res.status(404).json({ success: false, message: 'Configuration not found' });
await config.update({ key, value, category, description, isActive });
} else {
config = await db.SystemConfiguration.create({ key, value, category, description, isActive: isActive !== undefined ? isActive : true });
}
res.json({ success: true, data: config });
} catch (error) {
console.error('Save system config error:', error);
res.status(500).json({ success: false, message: 'Error saving system configuration' });
}
};

View File

@ -24,7 +24,9 @@ import {
getZonalManagers, getZonalManagers,
saveZM, saveZM,
getDDLeads, getDDLeads,
saveDDLead saveDDLead,
getSystemConfigs,
saveSystemConfig
} from './master.controller.js'; } from './master.controller.js';
@ -62,7 +64,8 @@ router.get('/area-managers', getAreaManagers);
router.get('/asms', getASMs); router.get('/asms', getASMs);
router.get('/zonal-managers', getZonalManagers); router.get('/zonal-managers', getZonalManagers);
router.post('/zonal-managers', saveZM); router.post('/zonal-managers', saveZM);
router.get('/dd-leads', getDDLeads);
router.post('/dd-leads', saveDDLead); router.post('/dd-leads', saveDDLead);
router.get('/system-configs', getSystemConfigs);
router.post('/system-configs', saveSystemConfig);
export default router; export default router;

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 { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region, Zone } = db; const { Application, Opportunity, ApplicationStatusHistory, ApplicationProgress, AuditLog, District, State, Region, Zone, SecurityDeposit } = db;
import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
@ -138,7 +138,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
const applications = await Application.findAll({ const applications = await Application.findAll({
where: whereClause, where: whereClause,
include: [{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] }], include: [
{ model: Opportunity, as: 'opportunity', attributes: ['opportunityType', 'city', 'id'] },
{ model: SecurityDeposit, as: 'securityDeposits' }
],
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });
@ -152,20 +155,27 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
export const getApplicationById = async (req: AuthRequest, res: Response) => { export const getApplicationById = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const targetId = id as string;
const where: any = {};
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(targetId);
if (isUUID) {
where[Op.or] = [{ id: targetId }, { applicationId: targetId }];
} else {
where.applicationId = targetId;
}
const application = await Application.findOne({ const application = await Application.findOne({
where: { where,
[Op.or]: [
{ id },
{ applicationId: id }
]
},
include: [ include: [
{ model: ApplicationStatusHistory, as: 'statusHistory' }, { model: ApplicationStatusHistory, as: 'statusHistory', separate: true, order: [['createdAt', 'DESC']] },
{ model: ApplicationProgress, as: 'progressTracking' }, { model: ApplicationProgress, as: 'progressTracking', separate: true, order: [['stageOrder', 'ASC']] },
{ model: SecurityDeposit, as: 'securityDeposits' },
{ {
model: db.QuestionnaireResponse, model: db.QuestionnaireResponse,
as: 'questionnaireResponses', as: 'questionnaireResponses',
separate: true,
include: [ include: [
{ {
model: db.QuestionnaireQuestion, model: db.QuestionnaireQuestion,
@ -174,8 +184,19 @@ export const getApplicationById = async (req: AuthRequest, res: Response) => {
} }
] ]
}, },
{ model: db.RequestParticipant, as: 'participants', include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }] }, {
{ model: db.StageApprovalAction, as: 'stageApprovals' }, model: db.RequestParticipant,
as: 'participants',
separate: true,
include: [{ model: db.User, as: 'user', attributes: ['id', ['fullName', 'name'], 'email', ['roleCode', 'role']] }]
},
{
model: db.Document,
as: 'uploadedDocuments',
separate: true,
order: [['createdAt', 'DESC']]
},
{ model: db.StageApprovalAction, as: 'stageApprovals', separate: true },
{ model: db.DealerCode, as: 'dealerCode' }, { model: db.DealerCode, as: 'dealerCode' },
{ model: db.Dealer, as: 'dealer' } { model: db.Dealer, as: 'dealer' }
] ]
@ -706,6 +727,15 @@ export const generateDealerCodes = async (req: AuthRequest, res: Response) => {
generatedBy: req.user?.id generatedBy: req.user?.id
}); });
// Create Final Security Deposit record (Blocker for LOA)
await db.SecurityDeposit.findOrCreate({
where: { applicationId: id, depositType: 'FINAL' },
defaults: {
amount: 1500000, // 15 Lakhs Final
status: 'Pending'
}
});
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, { await WorkflowService.transitionApplication(application, APPLICATION_STATUS.DEALER_CODE_GENERATION, req.user?.id || null, {
reason: 'SAP Dealer Codes Generated', reason: 'SAP Dealer Codes Generated',
progressPercentage: 80 progressPercentage: 80

View File

@ -1,31 +1,156 @@
import { Response } from 'express'; import { Response } from 'express';
import db from '../../database/models/index.js'; import db from '../../database/models/index.js';
const { RelocationRequest, Outlet, User, Worknote } = db; const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone } = db;
import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES } from '../../common/config/constants.js'; import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES } 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';
/**
* Helper to assign evaluators for relocation requests based on outlet location hierarchy
* Similar to assignStageEvaluators in onboarding
*/
const assignRelocationEvaluators = async (requestId: string, outletId: string) => {
try {
console.log(`[debug] Starting relocation evaluator assignment for Request: ${requestId}`);
// Get outlet with full location hierarchy
const outlet = await Outlet.findByPk(outletId, {
include: [
{
model: District,
as: 'district',
include: [
{ model: Region, as: 'region' },
{ model: Zone, as: 'zone' }
]
}
]
});
if (!outlet) {
console.log(`[debug] Outlet ${outletId} not found`);
return;
}
if (!outlet.district) {
console.log(`[debug] Outlet ${outletId} has NO district linked. Skipping auto-assign.`);
return;
}
const district = outlet.district;
const region = district.region;
const zone = district.zone;
console.log(`[debug] Mapping for District: ${district.name}, Region: ${region?.name}, Zone: ${zone?.name}`);
const evaluators: { id: string; role: string; stage: string }[] = [];
// Stage 1: DD ASM (from district)
if (district.asmId) {
evaluators.push({ id: district.asmId, role: 'ASM', stage: 'ASM_REVIEW' });
}
// Stage 2: RBM (from region)
if (region && region.rbmId) {
evaluators.push({ id: region.rbmId, role: 'RBM', stage: 'RBM_REVIEW' });
}
// Stage 3: DD ZM (from district)
if (district.zmId) {
evaluators.push({ id: district.zmId, role: 'DD-ZM', stage: 'DD_ZM_REVIEW' });
}
// Stage 4: ZBH (from zone)
if (zone && zone.zbhId) {
evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: 'ZBH_REVIEW' });
}
// Stage 5: DD Lead (zone-scoped)
if (zone) {
const ddLead = await User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { zoneId: zone.id, isActive: true }
}]
});
if (ddLead) {
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: 'DD_LEAD_REVIEW' });
}
}
// Stage 6: NBH (national)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' });
}
// Stage 7: Legal (national)
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
if (legal) {
evaluators.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' });
}
console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`);
// Note: RequestParticipant table has FK to applications, not relocation_requests
// So we store evaluators directly in the relocation request's timeline/metadata
// and return them via the outlet's location hierarchy lookup
// Store evaluator info in a separate table or return via API
// For now, log and store in request metadata via timeline
const evaluatorInfo = evaluators.map(e => ({
userId: e.id,
role: e.role,
stage: e.stage
}));
console.log(`[debug] Evaluators assigned:`, evaluatorInfo);
console.log(`[debug] Successfully assigned ${evaluators.length} evaluators to relocation request`);
// Return evaluator info in response
return evaluatorInfo;
} catch (error) {
console.error('[debug] Error assigning relocation evaluators:', error);
// Don't throw - assignment is non-critical
}
};
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' });
// Accept both 'proposed*' and 'new*' field naming conventions
const { const {
outletId, relocationType, currentAddress, currentCity, currentState, outletId, relocationType, currentAddress, currentCity, currentState,
currentLatitude, currentLongitude, proposedAddress, proposedCity, currentLatitude, currentLongitude, proposedAddress, proposedCity,
proposedState, proposedLatitude, proposedLongitude, reason, proposedDate proposedState, proposedLatitude, proposedLongitude, reason, proposedDate,
// Frontend may send 'new*' fields directly
newAddress, newCity, newState,
// IDs for traceability
newDistrictId, newStateId
} = req.body; } = req.body;
// Use proposed* fields if available, otherwise fall back to new* fields
const finalAddress = proposedAddress || newAddress;
const finalCity = proposedCity || newCity;
const finalState = proposedState || newState;
const finalRelocationType = relocationType || 'Intercity';
const requestId = `REL-${Date.now()}-${uuidv4().substring(0, 4).toUpperCase()}`; const requestId = `REL-${Date.now()}-${uuidv4().substring(0, 4).toUpperCase()}`;
const request = await RelocationRequest.create({ const request = await RelocationRequest.create({
requestId, requestId,
outletId, outletId,
dealerId: req.user.id, dealerId: req.user.id,
relocationType, relocationType: finalRelocationType,
newAddress: proposedAddress, newAddress: finalAddress,
newCity: proposedCity, newCity: finalCity,
newState: proposedState, newState: finalState,
newDistrictId: newDistrictId || null,
newStateId: newStateId || null,
reason, reason,
currentStage: RELOCATION_STAGES.DD_ADMIN_REVIEW as any, currentStage: RELOCATION_STAGES.DD_ADMIN_REVIEW as any,
status: 'Pending', status: 'Pending',
@ -39,6 +164,9 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
}] }]
}); });
// Auto-assign evaluators based on outlet location hierarchy
await assignRelocationEvaluators(request.id, outletId);
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: 'Relocation request submitted successfully', message: 'Relocation request submitted successfully',
@ -65,7 +193,18 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
{ {
model: Outlet, model: Outlet,
as: 'outlet', as: 'outlet',
attributes: ['code', 'name'] attributes: ['code', 'name'],
include: [
{
model: District,
as: 'district',
attributes: ['id', 'name', 'asmId', 'zmId'],
include: [
{ model: Region, as: 'region', attributes: ['id', 'name', 'rbmId'] },
{ model: Zone, as: 'zone', attributes: ['id', 'name', 'zbhId'] }
]
}
]
}, },
{ {
model: User, model: User,
@ -76,7 +215,40 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
order: [['createdAt', 'DESC']] order: [['createdAt', 'DESC']]
}); });
res.json({ success: true, requests }); // Filter requests based on user's role and location assignments
const filteredRequests = requests.filter((request: any) => {
// Dealers see only their own requests
if (req.user?.role === 'Dealer') {
return request.dealerId === req.user.id;
}
// For internal users, check if they are assigned to this outlet's location
const outlet = request.outlet;
if (!outlet?.district) return false;
const district = outlet.district;
const region = district.region;
const zone = district.zone;
const userId = req.user?.id;
const userRoleCode = req.user?.roleCode;
// National roles see all requests
const nationalRoles = ['NBH', 'DD Lead', 'DD Head', 'Legal Admin'];
if (userRoleCode && nationalRoles.includes(userRoleCode)) {
return true;
}
// Check if user is assigned to any evaluator role for this outlet
const isAssigned =
district.asmId === userId || // ASM
region?.rbmId === userId || // RBM
district.zmId === userId || // DD-ZM
zone?.zbhId === userId; // ZBH
return isAssigned;
});
res.json({ success: true, requests: filteredRequests });
} catch (error) { } catch (error) {
console.error('Get relocation requests error:', error); console.error('Get relocation requests error:', error);
res.status(500).json({ success: false, message: 'Error fetching requests' }); res.status(500).json({ success: false, message: 'Error fetching requests' });
@ -85,19 +257,28 @@ 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.id as string;
// Check if id is a UUID or a requestId string
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(id);
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: isUUID ? { id } : { requestId: id },
[Op.or]: [
{ id },
{ requestId: id }
]
},
include: [ include: [
{ {
model: Outlet, model: Outlet,
as: 'outlet' as: 'outlet',
include: [
{
model: District,
as: 'district',
attributes: ['id', 'name', 'asmId', 'zmId'],
include: [
{ model: Region, as: 'region', attributes: ['id', 'name', 'rbmId'] },
{ model: Zone, as: 'zone', attributes: ['id', 'name', 'zbhId'] }
]
}
]
}, },
{ {
model: User, model: User,
@ -116,7 +297,63 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
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 }); // Compute participants dynamically based on outlet location hierarchy
const participants: any[] = [];
const outlet = (request as any).outlet;
if (outlet?.district) {
const district = outlet.district;
const region = district.region;
const zone = district.zone;
const evaluatorRoles = [
{ id: district.asmId, role: 'ASM', stage: 'ASM_REVIEW' },
{ id: region?.rbmId, role: 'RBM', stage: 'RBM_REVIEW' },
{ id: district.zmId, role: 'DD-ZM', stage: 'DD_ZM_REVIEW' },
{ id: zone?.zbhId, role: 'ZBH', stage: 'ZBH_REVIEW' }
];
// Get DD Lead (zone-scoped)
if (zone) {
const ddLead = await User.findOne({
where: { roleCode: 'DD Lead', status: 'active' },
include: [{
model: db.UserRole,
as: 'userRoles',
where: { zoneId: zone.id, isActive: true }
}],
attributes: ['id', 'fullName', 'email', 'roleCode']
});
if (ddLead) evaluatorRoles.push({ id: ddLead.id, role: 'DD Lead', stage: 'DD_LEAD_REVIEW' });
}
// Get NBH and Legal (national)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' }, attributes: ['id', 'fullName', 'email', 'roleCode'] });
if (nbh) evaluatorRoles.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' });
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' }, attributes: ['id', 'fullName', 'email', 'roleCode'] });
if (legal) evaluatorRoles.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' });
// Fetch user details for each evaluator
for (const evaluator of evaluatorRoles) {
if (evaluator.id) {
const user = await User.findByPk(evaluator.id, { attributes: ['id', 'fullName', 'email', 'roleCode'] });
if (user) {
participants.push({
id: `eval-${evaluator.stage}`,
userId: evaluator.id,
participantType: 'reviewer',
metadata: { stage: evaluator.stage, role: evaluator.role, autoAssigned: true },
user
});
}
}
}
}
const response = request.toJSON();
(response as any).participants = participants;
res.json({ success: true, request: response });
} catch (error) { } catch (error) {
console.error('Get relocation details error:', error); console.error('Get relocation details error:', error);
res.status(500).json({ success: false, message: 'Error fetching details' }); res.status(500).json({ success: false, message: 'Error fetching details' });
@ -130,12 +367,10 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
const { id } = req.params; const { id } = req.params;
const { action, comments } = req.body; const { action, comments } = req.body;
// Only search by requestId since frontend sends requestId, not UUID
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: {
[Op.or]: [ requestId: id
{ id },
{ requestId: id }
]
} }
}); });
@ -190,12 +425,10 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
const { id } = req.params; const { id } = req.params;
const { documents } = req.body; const { documents } = req.body;
// Only search by requestId since frontend sends requestId, not UUID
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: {
[Op.or]: [ requestId: id
{ id },
{ requestId: id }
]
} }
}); });

View File

@ -0,0 +1,14 @@
import express from 'express';
const router = express.Router();
import * as relocationController from './relocation.controller.js';
import { authenticate } from '../../common/middleware/auth.js';
// Relocation routes
router.post('/', authenticate as any, relocationController.submitRequest);
router.get('/', authenticate as any, relocationController.getRequests);
router.get('/:id', authenticate as any, relocationController.getRequestById);
router.put('/:id/action', authenticate as any, relocationController.takeAction);
router.post('/:id/documents', authenticate as any, relocationController.uploadDocuments);
export default router;

View File

@ -21,6 +21,7 @@ import masterRoutes from './modules/master/master.routes.js';
import settlementRoutes from './modules/settlement/settlement.routes.js'; import settlementRoutes from './modules/settlement/settlement.routes.js';
import collaborationRoutes from './modules/collaboration/collaboration.routes.js'; import collaborationRoutes from './modules/collaboration/collaboration.routes.js';
import resignationRoutes from './modules/self-service/resignation.routes.js'; import resignationRoutes from './modules/self-service/resignation.routes.js';
import relocationRoutes from './modules/self-service/relocation.routes.js';
import outletRoutes from './modules/master/outlet.routes.js'; import outletRoutes from './modules/master/outlet.routes.js';
// New Modules // New Modules
import adminRoutes from './modules/admin/admin.routes.js'; import adminRoutes from './modules/admin/admin.routes.js';
@ -136,14 +137,10 @@ app.use('/api/constitutional-change', (req: Request, res: Response, next: NextFu
req.url = '/constitutional' + (req.url === '/' ? '' : req.url); req.url = '/constitutional' + (req.url === '/' ? '' : req.url);
next(); next();
}, selfServiceRoutes); }, selfServiceRoutes);
app.use('/api/relocation', (req: Request, res: Response, next: NextFunction) => {
req.url = '/relocation' + (req.url === '/' ? '' : req.url); // Relocation routes - direct mount
next(); app.use('/api/relocation', relocationRoutes);
}, selfServiceRoutes); app.use('/api/relocations', relocationRoutes);
app.use('/api/relocations', (req: Request, res: Response, next: NextFunction) => {
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
next();
}, selfServiceRoutes);
app.use('/api/outlets', outletRoutes); app.use('/api/outlets', outletRoutes);
app.use('/api/finance', settlementRoutes); app.use('/api/finance', settlementRoutes);
app.use('/api/worknotes', collaborationRoutes); app.use('/api/worknotes', collaborationRoutes);