onboarding flow made stable end to end also fincance team verification implementation done relocation stated
This commit is contained in:
parent
e64b64380d
commit
8d2a7874de
@ -15,7 +15,8 @@
|
||||
"seed:permissions": "tsx scripts/seed-permissions.ts",
|
||||
"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",
|
||||
"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",
|
||||
"seed:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy",
|
||||
"sync:hierarchy": "tsx scripts/sync-all-hierarchy.ts",
|
||||
|
||||
53
scripts/delete-test-relocation.ts
Normal file
53
scripts/delete-test-relocation.ts
Normal 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);
|
||||
74
scripts/migrate-relocation-schema.ts
Normal file
74
scripts/migrate-relocation-schema.ts
Normal 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();
|
||||
46
scripts/seed-system-configs.ts
Normal file
46
scripts/seed-system-configs.ts
Normal 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;
|
||||
@ -336,6 +336,8 @@ export const DOCUMENT_TYPES = {
|
||||
STATUTORY_AUDIT: 'Statutory Approval Certificate',
|
||||
BANK_GUARANTEE: 'Bank Guarantee Document',
|
||||
SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt',
|
||||
SECURITY_DEPOSIT_INITIAL: 'Initial Security Deposit Receipt',
|
||||
SECURITY_DEPOSIT_FINAL: 'Final Security Deposit Receipt',
|
||||
OTHER: 'Other'
|
||||
} as const;
|
||||
|
||||
|
||||
@ -266,6 +266,7 @@ export default (sequelize: Sequelize) => {
|
||||
Application.hasOne(models.DealerCode, { foreignKey: 'applicationId', as: 'dealerCode' });
|
||||
Application.hasMany(models.StageApprovalAction, { foreignKey: 'applicationId', as: 'stageApprovals' });
|
||||
Application.hasOne(models.Dealer, { foreignKey: 'applicationId', as: 'dealer' });
|
||||
Application.hasMany(models.SecurityDeposit, { foreignKey: 'applicationId', as: 'securityDeposits' });
|
||||
};
|
||||
|
||||
return Application;
|
||||
|
||||
@ -10,6 +10,8 @@ export interface RelocationRequestAttributes {
|
||||
newAddress: string;
|
||||
newCity: string;
|
||||
newState: string;
|
||||
newDistrictId: string | null;
|
||||
newStateId: string | null;
|
||||
reason: string;
|
||||
currentStage: typeof RELOCATION_STAGES[keyof typeof RELOCATION_STAGES];
|
||||
status: string;
|
||||
@ -64,6 +66,22 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
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: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
@ -108,12 +126,22 @@ export default (sequelize: Sequelize) => {
|
||||
foreignKey: 'dealerId',
|
||||
as: 'dealer'
|
||||
});
|
||||
RelocationRequest.belongsTo(models.District, {
|
||||
foreignKey: 'newDistrictId',
|
||||
as: 'newDistrict'
|
||||
});
|
||||
RelocationRequest.belongsTo(models.State, {
|
||||
foreignKey: 'newStateId',
|
||||
as: 'newStateRef'
|
||||
});
|
||||
RelocationRequest.hasMany(models.Worknote, {
|
||||
foreignKey: 'requestId',
|
||||
as: 'worknotes',
|
||||
scope: { requestType: 'relocation' },
|
||||
constraints: false
|
||||
});
|
||||
// Note: Participants are computed dynamically based on outlet location hierarchy
|
||||
// See getRequestById in relocation.controller.ts
|
||||
};
|
||||
|
||||
return RelocationRequest;
|
||||
|
||||
@ -7,6 +7,7 @@ export interface SecurityDepositAttributes {
|
||||
paymentReference: string | null;
|
||||
proofDocumentId: string | null;
|
||||
status: string;
|
||||
depositType: 'INITIAL' | 'FINAL';
|
||||
verifiedAt: Date | null;
|
||||
verifiedBy: string | null;
|
||||
}
|
||||
@ -48,6 +49,11 @@ export default (sequelize: Sequelize) => {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
depositType: {
|
||||
type: DataTypes.ENUM('INITIAL', 'FINAL'),
|
||||
allowNull: false,
|
||||
defaultValue: 'INITIAL'
|
||||
},
|
||||
verifiedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
|
||||
52
src/database/models/SystemConfiguration.ts
Normal file
52
src/database/models/SystemConfiguration.ts
Normal 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;
|
||||
};
|
||||
@ -18,6 +18,7 @@ import createSLAConfiguration from './SLAConfiguration.js';
|
||||
import createSLAReminder from './SLAReminder.js';
|
||||
import createSLAEscalationConfig from './SLAEscalationConfig.js';
|
||||
import createWorkflowStageConfig from './WorkflowStageConfig.js';
|
||||
import createSystemConfiguration from './SystemConfiguration.js';
|
||||
import createNotification from './Notification.js';
|
||||
import createDistrict from './District.js';
|
||||
import createLocation from './Location.js';
|
||||
@ -190,6 +191,7 @@ db.SLATracking = createSLATracking(sequelize);
|
||||
db.SLABreach = createSLABreach(sequelize);
|
||||
db.StageApprovalPolicy = createStageApprovalPolicy(sequelize);
|
||||
db.StageApprovalAction = createStageApprovalAction(sequelize);
|
||||
db.SystemConfiguration = createSystemConfiguration(sequelize);
|
||||
|
||||
// Define associations
|
||||
Object.keys(db).forEach((modelName) => {
|
||||
|
||||
@ -695,11 +695,13 @@ export const getInterviews = async (req: Request, res: Response) => {
|
||||
{
|
||||
model: InterviewParticipant,
|
||||
as: 'participants',
|
||||
include: [{ model: User, as: 'user' }] // Assuming association exists
|
||||
separate: true,
|
||||
include: [{ model: User, as: 'user' }]
|
||||
},
|
||||
{
|
||||
model: InterviewEvaluation,
|
||||
as: 'evaluations',
|
||||
separate: true,
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'evaluator',
|
||||
|
||||
@ -33,11 +33,13 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
||||
const application = await Application.findByPk(applicationId);
|
||||
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
||||
|
||||
// SRS Validation: Only allow onboarding at the 'Inauguration' stage
|
||||
if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION) {
|
||||
// SRS Validation: Allow onboarding at 'Inauguration' or 'Approved' stage
|
||||
// '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({
|
||||
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}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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' });
|
||||
|
||||
// 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({
|
||||
action,
|
||||
remarks,
|
||||
@ -229,9 +242,18 @@ export const generateDocument = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
export const updateSecurityDeposit = async (req: AuthRequest, res: Response) => {
|
||||
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) {
|
||||
await deposit.update({
|
||||
@ -241,11 +263,12 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) =>
|
||||
});
|
||||
} else {
|
||||
deposit = await SecurityDeposit.create({
|
||||
applicationId,
|
||||
applicationId: application.id,
|
||||
amount,
|
||||
paymentReference,
|
||||
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) => {
|
||||
try {
|
||||
const { applicationId } = req.params;
|
||||
const deposit = await SecurityDeposit.findOne({ where: { applicationId } });
|
||||
res.json({ success: true, data: deposit });
|
||||
const applicationId = req.params.applicationId as 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(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) {
|
||||
console.error('Fetch Security Deposit error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error fetching security deposit' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -210,6 +210,15 @@ export const approveRequest = async (req: AuthRequest, res: Response) => {
|
||||
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);
|
||||
if (application) {
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user.id, {
|
||||
|
||||
@ -1104,3 +1104,49 @@ export const deleteArea = deleteLocation;
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -24,7 +24,9 @@ import {
|
||||
getZonalManagers,
|
||||
saveZM,
|
||||
getDDLeads,
|
||||
saveDDLead
|
||||
saveDDLead,
|
||||
getSystemConfigs,
|
||||
saveSystemConfig
|
||||
} from './master.controller.js';
|
||||
|
||||
|
||||
@ -62,7 +64,8 @@ router.get('/area-managers', getAreaManagers);
|
||||
router.get('/asms', getASMs);
|
||||
router.get('/zonal-managers', getZonalManagers);
|
||||
router.post('/zonal-managers', saveZM);
|
||||
router.get('/dd-leads', getDDLeads);
|
||||
router.post('/dd-leads', saveDDLead);
|
||||
router.get('/system-configs', getSystemConfigs);
|
||||
router.post('/system-configs', saveSystemConfig);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
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 { v4 as uuidv4 } from 'uuid';
|
||||
import { Op } from 'sequelize';
|
||||
@ -138,7 +138,10 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
const applications = await Application.findAll({
|
||||
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']]
|
||||
});
|
||||
|
||||
@ -152,20 +155,27 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
||||
export const getApplicationById = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
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({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ applicationId: id }
|
||||
]
|
||||
},
|
||||
where,
|
||||
include: [
|
||||
{ model: ApplicationStatusHistory, as: 'statusHistory' },
|
||||
{ model: ApplicationProgress, as: 'progressTracking' },
|
||||
{ model: ApplicationStatusHistory, as: 'statusHistory', separate: true, order: [['createdAt', 'DESC']] },
|
||||
{ model: ApplicationProgress, as: 'progressTracking', separate: true, order: [['stageOrder', 'ASC']] },
|
||||
{ model: SecurityDeposit, as: 'securityDeposits' },
|
||||
{
|
||||
model: db.QuestionnaireResponse,
|
||||
as: 'questionnaireResponses',
|
||||
separate: true,
|
||||
include: [
|
||||
{
|
||||
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.Dealer, as: 'dealer' }
|
||||
]
|
||||
@ -706,6 +727,15 @@ export const generateDealerCodes = async (req: AuthRequest, res: Response) => {
|
||||
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, {
|
||||
reason: 'SAP Dealer Codes Generated',
|
||||
progressPercentage: 80
|
||||
|
||||
@ -1,31 +1,156 @@
|
||||
import { Response } from 'express';
|
||||
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 { Op, Transaction } from 'sequelize';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
// Accept both 'proposed*' and 'new*' field naming conventions
|
||||
const {
|
||||
outletId, relocationType, currentAddress, currentCity, currentState,
|
||||
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;
|
||||
|
||||
// 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 request = await RelocationRequest.create({
|
||||
requestId,
|
||||
outletId,
|
||||
dealerId: req.user.id,
|
||||
relocationType,
|
||||
newAddress: proposedAddress,
|
||||
newCity: proposedCity,
|
||||
newState: proposedState,
|
||||
relocationType: finalRelocationType,
|
||||
newAddress: finalAddress,
|
||||
newCity: finalCity,
|
||||
newState: finalState,
|
||||
newDistrictId: newDistrictId || null,
|
||||
newStateId: newStateId || null,
|
||||
reason,
|
||||
currentStage: RELOCATION_STAGES.DD_ADMIN_REVIEW as any,
|
||||
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({
|
||||
success: true,
|
||||
message: 'Relocation request submitted successfully',
|
||||
@ -65,7 +193,18 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
{
|
||||
model: 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,
|
||||
@ -76,7 +215,40 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
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) {
|
||||
console.error('Get relocation requests error:', error);
|
||||
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) => {
|
||||
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({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
},
|
||||
where: isUUID ? { id } : { requestId: id },
|
||||
include: [
|
||||
{
|
||||
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,
|
||||
@ -116,7 +297,63 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
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) {
|
||||
console.error('Get relocation details error:', error);
|
||||
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 { action, comments } = req.body;
|
||||
|
||||
// Only search by requestId since frontend sends requestId, not UUID
|
||||
const request = await RelocationRequest.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
requestId: id
|
||||
}
|
||||
});
|
||||
|
||||
@ -190,12 +425,10 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { documents } = req.body;
|
||||
|
||||
// Only search by requestId since frontend sends requestId, not UUID
|
||||
const request = await RelocationRequest.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
requestId: id
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
14
src/modules/self-service/relocation.routes.ts
Normal file
14
src/modules/self-service/relocation.routes.ts
Normal 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;
|
||||
@ -21,6 +21,7 @@ import masterRoutes from './modules/master/master.routes.js';
|
||||
import settlementRoutes from './modules/settlement/settlement.routes.js';
|
||||
import collaborationRoutes from './modules/collaboration/collaboration.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';
|
||||
// New Modules
|
||||
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);
|
||||
next();
|
||||
}, selfServiceRoutes);
|
||||
app.use('/api/relocation', (req: Request, res: Response, next: NextFunction) => {
|
||||
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
|
||||
next();
|
||||
}, selfServiceRoutes);
|
||||
app.use('/api/relocations', (req: Request, res: Response, next: NextFunction) => {
|
||||
req.url = '/relocation' + (req.url === '/' ? '' : req.url);
|
||||
next();
|
||||
}, selfServiceRoutes);
|
||||
|
||||
// Relocation routes - direct mount
|
||||
app.use('/api/relocation', relocationRoutes);
|
||||
app.use('/api/relocations', relocationRoutes);
|
||||
app.use('/api/outlets', outletRoutes);
|
||||
app.use('/api/finance', settlementRoutes);
|
||||
app.use('/api/worknotes', collaborationRoutes);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user