relocatin

flow integrated at cetrtain extent douments mapping , assigns mapping done documents application constrain removed
This commit is contained in:
laxmanhalaki 2026-04-03 02:32:54 +05:30
parent 8d2a7874de
commit 28580b7fb0
17 changed files with 750 additions and 113 deletions

View File

@ -166,10 +166,15 @@ export const RELOCATION_TYPES = {
// Relocation Stages // Relocation Stages
export const RELOCATION_STAGES = { export const RELOCATION_STAGES = {
DD_ADMIN_REVIEW: 'DD Admin Review', ASM_REVIEW: 'ASM Review',
RBM_REVIEW: 'RBM Review', RBM_REVIEW: 'RBM Review',
DD_ZM_REVIEW: 'DD ZM Review',
ZBH_REVIEW: 'ZBH Review',
DD_LEAD_REVIEW: 'DD Lead Review',
DD_HEAD_APPROVAL: 'DD Head Approval',
NBH_APPROVAL: 'NBH Approval', NBH_APPROVAL: 'NBH Approval',
LEGAL_CLEARANCE: 'Legal Clearance', LEGAL_CLEARANCE: 'Legal Clearance',
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
COMPLETED: 'Completed', COMPLETED: 'Completed',
REJECTED: 'Rejected' REJECTED: 'Rejected'
} as const; } as const;
@ -338,6 +343,18 @@ export const DOCUMENT_TYPES = {
SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt', SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt',
SECURITY_DEPOSIT_INITIAL: 'Initial Security Deposit Receipt', SECURITY_DEPOSIT_INITIAL: 'Initial Security Deposit Receipt',
SECURITY_DEPOSIT_FINAL: 'Final Security Deposit Receipt', SECURITY_DEPOSIT_FINAL: 'Final Security Deposit Receipt',
RELOCATION_PROPERTY_DOCS: 'Property documents for new location',
RELOCATION_LEASE_AGREEMENT: 'Lease/Rental agreement for new location',
RELOCATION_NOC_LANDLORD: 'NOC from current landlord',
RELOCATION_MUNICIPAL_APPROVALS: 'Municipal approvals',
RELOCATION_FIRE_SAFETY: 'Fire safety certificate',
RELOCATION_POLLUTION_CLEARANCE: 'Pollution clearance',
RELOCATION_LAYOUT_PLAN: 'Layout/Floor plan of new location',
RELOCATION_PHOTOS: 'Photos of new location',
RELOCATION_LOCALITY_MAP: 'Locality map',
RELOCATION_BUILDING_PLAN: 'Building plan approval',
RELOCATION_ELECTRICITY_DOCS: 'Electricity connection documents',
RELOCATION_WATER_DOCS: 'Water supply documents',
OTHER: 'Other' OTHER: 'Other'
} as const; } as const;

View File

@ -0,0 +1,34 @@
/**
* Common date formatting utility for backend modules
*/
export function formatDateTime(date: string | Date | number, format: 'full' | 'date' | 'time' = 'full') {
const d = new Date(date);
// Check for invalid date
if (isNaN(d.getTime())) return 'Invalid Date';
const options: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: 'short',
year: 'numeric',
};
if (format === 'full' || format === 'time') {
options.hour = '2-digit';
options.minute = '2-digit';
options.hour12 = true;
}
if (format === 'time') {
return d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
}
return d.toLocaleDateString('en-IN', options);
}
/**
* Returns current timestamp in YYYY-MM-DD HH:mm:ss format
*/
export function getCurrentTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 19);
}

View File

@ -248,7 +248,8 @@ export default (sequelize: Sequelize) => {
Application.hasMany(models.Document, { Application.hasMany(models.Document, {
foreignKey: 'requestId', foreignKey: 'requestId',
as: 'uploadedDocuments', as: 'uploadedDocuments',
scope: { requestType: 'application' } scope: { requestType: 'application' },
constraints: false
}); });
Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' }); Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' });

View File

@ -2,7 +2,8 @@ import { Model, DataTypes, Sequelize } from 'sequelize';
export interface EorChecklistAttributes { export interface EorChecklistAttributes {
id: string; id: string;
applicationId: string; applicationId: string | null;
relocationId: string | null;
auditorId: string | null; auditorId: string | null;
auditDate: Date | null; auditDate: Date | null;
status: string; status: string;
@ -20,12 +21,20 @@ export default (sequelize: Sequelize) => {
}, },
applicationId: { applicationId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: true,
references: { references: {
model: 'applications', model: 'applications',
key: 'id' key: 'id'
} }
}, },
relocationId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'relocation_requests',
key: 'id'
}
},
auditorId: { auditorId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: true, allowNull: true,
@ -53,6 +62,7 @@ export default (sequelize: Sequelize) => {
(EorChecklist as any).associate = (models: any) => { (EorChecklist as any).associate = (models: any) => {
EorChecklist.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' }); EorChecklist.belongsTo(models.Application, { foreignKey: 'applicationId', as: 'application' });
EorChecklist.belongsTo(models.RelocationRequest, { foreignKey: 'relocationId', as: 'relocation' });
EorChecklist.belongsTo(models.User, { foreignKey: 'auditorId', as: 'auditor' }); EorChecklist.belongsTo(models.User, { foreignKey: 'auditorId', as: 'auditor' });
EorChecklist.hasMany(models.EorChecklistItem, { foreignKey: 'checklistId', as: 'items' }); EorChecklist.hasMany(models.EorChecklistItem, { foreignKey: 'checklistId', as: 'items' });

View File

@ -88,7 +88,7 @@ export default (sequelize: Sequelize) => {
}, },
currentStage: { currentStage: {
type: DataTypes.ENUM(...Object.values(RELOCATION_STAGES)), type: DataTypes.ENUM(...Object.values(RELOCATION_STAGES)),
defaultValue: RELOCATION_STAGES.DD_ADMIN_REVIEW defaultValue: RELOCATION_STAGES.ASM_REVIEW
}, },
status: { status: {
type: DataTypes.STRING, type: DataTypes.STRING,
@ -140,6 +140,16 @@ export default (sequelize: Sequelize) => {
scope: { requestType: 'relocation' }, scope: { requestType: 'relocation' },
constraints: false constraints: false
}); });
RelocationRequest.hasMany(models.Document, {
foreignKey: 'requestId',
as: 'uploadedDocuments',
scope: { requestType: 'relocation' },
constraints: false
});
RelocationRequest.hasOne(models.EorChecklist, {
foreignKey: 'relocationId',
as: 'eorChecklist'
});
// Note: Participants are computed dynamically based on outlet location hierarchy // Note: Participants are computed dynamically based on outlet location hierarchy
// See getRequestById in relocation.controller.ts // See getRequestById in relocation.controller.ts
}; };

View File

@ -5,9 +5,9 @@ import { AuthRequest } from '../../types/express.types.js';
export const getChecklist = async (req: Request, res: Response) => { export const getChecklist = async (req: Request, res: Response) => {
try { try {
const { applicationId } = req.params; const { applicationId, relocationId } = req.params;
let checklist = await EorChecklist.findOne({ let checklist = await EorChecklist.findOne({
where: { applicationId }, where: relocationId ? { relocationId } : { applicationId },
include: [{ model: EorChecklistItem, as: 'items', include: ['proofDocument'] }] include: [{ model: EorChecklistItem, as: 'items', include: ['proofDocument'] }]
}); });
@ -25,32 +25,62 @@ export const getChecklist = async (req: Request, res: Response) => {
export const createChecklist = async (req: AuthRequest, res: Response) => { export const createChecklist = async (req: AuthRequest, res: Response) => {
try { try {
const { applicationId } = req.body; const { applicationId, relocationId } = req.body;
const application = await db.Application.findByPk(applicationId); if (!applicationId && !relocationId) {
if (!application) return res.status(404).json({ success: false, message: 'Application not found' }); return res.status(400).json({ success: false, message: 'applicationId or relocationId is required' });
}
if (applicationId) {
const application = await db.Application.findByPk(applicationId);
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
} else if (relocationId) {
const relocation = await db.RelocationRequest.findByPk(relocationId);
if (!relocation) return res.status(404).json({ success: false, message: 'Relocation request not found' });
}
const [checklist, created] = await EorChecklist.findOrCreate({ const [checklist, created] = await EorChecklist.findOrCreate({
where: { applicationId }, where: relocationId ? { relocationId } : { applicationId },
defaults: { status: 'In Progress' } defaults: {
status: 'In Progress',
applicationId: applicationId || null,
relocationId: relocationId || null
}
}); });
if (created) { if (created) {
// Define Default Mandatory Items per SRS/Frontend // Define Default Mandatory Items per SRS/Frontend
const defaultItems = [ let defaultItems = [];
{ itemType: 'Sales', description: 'Sales Standards' },
{ itemType: 'Service', description: 'Service & Spares' }, if (relocationId) {
{ itemType: 'IT', description: 'DMS infra' }, // Strictly per SRS Section 12.2.8 for Relocation
{ itemType: 'Training', description: 'Manpower Training' }, defaultItems = [
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' }, { itemType: 'Property', description: 'Property documents for new location' },
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' }, { itemType: 'Property', description: 'Lease / Rental agreement' },
{ itemType: 'Finance', description: 'Inventory Funding' }, { itemType: 'Property', description: 'Layout / Floor plan of new location' },
{ itemType: 'IT', description: 'Virtual code availability' }, { itemType: 'Infrastructure', description: 'Photos of new location' },
{ itemType: 'Finance', description: 'Vendor payments' }, { itemType: 'Infrastructure', description: 'Locality map / Building plan approval' },
{ itemType: 'Marketing', description: 'Details for website submission' }, { itemType: 'Statutory', description: 'NOC from current landlord' },
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' }, { itemType: 'Statutory', description: 'Municipal approvals (Fire safety / Pollution clearance)' },
{ itemType: 'IT', description: 'Auto ordering' } { itemType: 'Utility', description: 'Electricity & Water supply documents' }
]; ];
} else {
// Onboarding Default
defaultItems = [
{ itemType: 'Sales', description: 'Sales Standards' },
{ itemType: 'Service', description: 'Service & Spares' },
{ itemType: 'IT', description: 'DMS infra' },
{ itemType: 'Training', description: 'Manpower Training' },
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
{ itemType: 'Finance', description: 'Inventory Funding' },
{ itemType: 'IT', description: 'Virtual code availability' },
{ itemType: 'Finance', description: 'Vendor payments' },
{ itemType: 'Marketing', description: 'Details for website submission' },
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
{ itemType: 'IT', description: 'Auto ordering' }
];
}
const itemsData = defaultItems.map(item => ({ const itemsData = defaultItems.map(item => ({
...item, ...item,
@ -113,15 +143,25 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
if (status === 'Completed') { if (status === 'Completed') {
const checklist = await EorChecklist.findByPk(checklistId); const checklist = await EorChecklist.findByPk(checklistId);
if (checklist) { if (checklist) {
await db.Application.update({ if (checklist.applicationId) {
overallStatus: 'Approved', await db.Application.update({
progressPercentage: 100 overallStatus: 'Approved',
}, { where: { id: checklist.applicationId } }); progressPercentage: 100
}, { where: { id: checklist.applicationId } });
// Update Progress Tracking // Update Progress Tracking
const { updateApplicationProgress } = await import('../../common/utils/progress.js'); const { updateApplicationProgress } = await import('../../common/utils/progress.js');
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100); await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
await updateApplicationProgress(checklist.applicationId, 'Inauguration', 'active', 50); await updateApplicationProgress(checklist.applicationId, 'Inauguration', 'active', 50);
} else if (checklist.relocationId) {
await db.RelocationRequest.update({
status: 'Completed',
progressPercentage: 100,
currentStage: 'Completed'
}, { where: { id: checklist.relocationId } });
// The workflow service can handle timeline/audit but here we just finalized the status
}
} }
} }

View File

@ -5,7 +5,8 @@ import { authenticate } from '../../common/middleware/auth.js';
router.use(authenticate as any); router.use(authenticate as any);
router.get('/:applicationId', eorController.getChecklist); router.get('/application/:applicationId', eorController.getChecklist);
router.get('/relocation/:relocationId', eorController.getChecklist);
router.post('/', eorController.createChecklist); router.post('/', eorController.createChecklist);
router.post('/item/:checklistId', eorController.updateItem); router.post('/item/:checklistId', eorController.updateItem);
router.post('/audit/:checklistId', eorController.submitAudit); router.post('/audit/:checklistId', eorController.submitAudit);

View File

@ -272,6 +272,68 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) =>
}); });
} }
// AUTOMATION: If Initial Payment is Verified, auto-approve "Finance" role in LOI Stage
if (depositType === 'INITIAL' && status === 'Verified') {
const LoiRequest = db.LoiRequest;
const LoiApproval = db.LoiApproval;
const StageApprovalAction = db.StageApprovalAction;
const loiReq = await LoiRequest.findOne({ where: { applicationId: application.id } });
if (loiReq) {
// 1. Update module-specific approval table
const financeApproval = await LoiApproval.findOne({
where: { requestId: loiReq.id, approverRole: 'Finance', action: 'Pending' }
});
if (financeApproval) {
await financeApproval.update({
action: 'Approved',
remarks: 'Auto-approved via Finance payment verification',
approverId: req.user?.id,
approvedAt: new Date()
});
}
// 2. Update generic StageApprovalAction table
await StageApprovalAction.upsert({
applicationId: application.id,
stageCode: 'LOI_APPROVAL',
actorUserId: req.user?.id,
actorRole: 'Finance',
decision: 'Approved',
remarks: 'Auto-approved via Finance payment verification'
});
// 3. Check if LOI can now be fully approved (copied logic from loi.controller)
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode: 'LOI_APPROVAL' } });
const requiredRoles = policy?.requiredRoles || ['Finance', 'DD Head', 'NBH'];
const stageActions = await StageApprovalAction.findAll({
where: { applicationId: application.id, stageCode: 'LOI_APPROVAL' }
});
const approvedRoles = new Set(stageActions.filter((a: any) => a.decision === 'Approved').map((a: any) => a.actorRole));
const meetsMinApprovals = approvedRoles.size >= (policy?.minApprovals || 3);
const hasAllRequired = requiredRoles.every((r: string) => approvedRoles.has(r));
if (hasAllRequired && meetsMinApprovals && loiReq.status !== 'Approved') {
await loiReq.update({ status: 'Approved', approvedBy: req.user?.id, approvedAt: new Date() });
const mockFile = `LOI_${loiReq.id}.pdf`;
await db.LoiDocumentGenerated.findOrCreate({
where: { requestId: loiReq.id, documentType: 'LOI' },
defaults: {
fileName: mockFile,
filePath: `/uploads/loi/${mockFile}`
}
});
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user?.id, {
reason: 'LOI Request fully approved via automated finance verification',
progressPercentage: 80
});
}
}
}
res.json({ success: true, message: 'Security Deposit updated', data: deposit }); res.json({ success: true, message: 'Security Deposit updated', data: deposit });
} catch (error) { } catch (error) {
console.error('Update Security Deposit error:', error); console.error('Update Security Deposit error:', error);

View File

@ -64,6 +64,7 @@ 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.get('/system-configs', getSystemConfigs);
router.post('/system-configs', saveSystemConfig); router.post('/system-configs', saveSystemConfig);

View File

@ -50,7 +50,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const where: any = {}; const where: any = {};
if (req.user.role === 'Dealer') { if (req.user.roleCode === 'Dealer') {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} }

View File

@ -1,10 +1,12 @@
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, District, Region, Zone } = db; import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, Document } = 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';
import { formatDateTime } from '../../common/utils/dateUtils.js';
/** /**
* Helper to assign evaluators for relocation requests based on outlet location hierarchy * Helper to assign evaluators for relocation requests based on outlet location hierarchy
@ -48,22 +50,22 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
// Stage 1: DD ASM (from district) // Stage 1: DD ASM (from district)
if (district.asmId) { if (district.asmId) {
evaluators.push({ id: district.asmId, role: 'ASM', stage: 'ASM_REVIEW' }); evaluators.push({ id: district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW });
} }
// Stage 2: RBM (from region) // Stage 2: RBM (from region)
if (region && region.rbmId) { if (region && region.rbmId) {
evaluators.push({ id: region.rbmId, role: 'RBM', stage: 'RBM_REVIEW' }); evaluators.push({ id: region.rbmId, role: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW });
} }
// Stage 3: DD ZM (from district) // Stage 3: DD ZM (from district)
if (district.zmId) { if (district.zmId) {
evaluators.push({ id: district.zmId, role: 'DD-ZM', stage: 'DD_ZM_REVIEW' }); evaluators.push({ id: district.zmId, role: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW });
} }
// Stage 4: ZBH (from zone) // Stage 4: ZBH (from zone)
if (zone && zone.zbhId) { if (zone && zone.zbhId) {
evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: 'ZBH_REVIEW' }); evaluators.push({ id: zone.zbhId, role: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW });
} }
// Stage 5: DD Lead (zone-scoped) // Stage 5: DD Lead (zone-scoped)
@ -77,30 +79,35 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
}] }]
}); });
if (ddLead) { if (ddLead) {
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: 'DD_LEAD_REVIEW' }); evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
} }
} }
// Stage 6: NBH (national) // Stage 6: DD Head (national)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } }); const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
if (nbh) { if (ddHead) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' }); evaluators.push({ id: ddHead.id, role: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL });
} }
// Stage 7: Legal (national) // Stage 7: NBH Approval (national)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
}
// Stage 8: Legal Clearance (national)
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } }); const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
if (legal) { if (legal) {
evaluators.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' }); evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
}
// Stage 9: NBH Clearance with EOR (national)
if (nbh) {
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR });
} }
console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`); 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 => ({ const evaluatorInfo = evaluators.map(e => ({
userId: e.id, userId: e.id,
role: e.role, role: e.role,
@ -108,13 +115,9 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
})); }));
console.log(`[debug] Evaluators assigned:`, evaluatorInfo); console.log(`[debug] Evaluators assigned:`, evaluatorInfo);
console.log(`[debug] Successfully assigned ${evaluators.length} evaluators to relocation request`);
// Return evaluator info in response
return evaluatorInfo; return evaluatorInfo;
} catch (error) { } catch (error) {
console.error('[debug] Error assigning relocation evaluators:', error); console.error('[debug] Error assigning relocation evaluators:', error);
// Don't throw - assignment is non-critical
} }
}; };
@ -152,9 +155,9 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
newDistrictId: newDistrictId || null, newDistrictId: newDistrictId || null,
newStateId: newStateId || null, newStateId: newStateId || null,
reason, reason,
currentStage: RELOCATION_STAGES.DD_ADMIN_REVIEW as any, currentStage: RELOCATION_STAGES.ASM_REVIEW,
status: 'Pending', status: 'Pending ASM Review',
progressPercentage: 20, progressPercentage: 10,
documents: [], documents: [],
timeline: [{ timeline: [{
stage: 'Submitted', stage: 'Submitted',
@ -183,7 +186,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
const where: any = {}; const where: any = {};
if (req.user.role === 'Dealer') { if (req.user.roleCode === 'Dealer') {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} }
@ -193,7 +196,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
{ {
model: Outlet, model: Outlet,
as: 'outlet', as: 'outlet',
attributes: ['code', 'name'], attributes: ['code', 'name', 'address', 'city', 'state', 'pincode'],
include: [ include: [
{ {
model: District, model: District,
@ -218,7 +221,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
// Filter requests based on user's role and location assignments // Filter requests based on user's role and location assignments
const filteredRequests = requests.filter((request: any) => { const filteredRequests = requests.filter((request: any) => {
// Dealers see only their own requests // Dealers see only their own requests
if (req.user?.role === 'Dealer') { if (req.user?.roleCode === 'Dealer') {
return request.dealerId === req.user.id; return request.dealerId === req.user.id;
} }
@ -248,7 +251,19 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
return isAssigned; return isAssigned;
}); });
res.json({ success: true, requests: filteredRequests }); // Enrich responses with currentLocation and proposedLocation for frontend
const enrichedRequests = filteredRequests.map((request: any) => {
const reqData = request.get({ plain: true });
const outlet = reqData.outlet;
return {
...reqData,
currentLocation: outlet ? `${outlet.address}, ${outlet.city}, ${outlet.state} - ${outlet.pincode}` : 'N/A',
proposedLocation: `${reqData.newAddress}, ${reqData.newCity}, ${reqData.newState}`
};
});
res.json({ success: true, requests: enrichedRequests });
} 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' });
@ -305,11 +320,11 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
const region = district.region; const region = district.region;
const zone = district.zone; const zone = district.zone;
const evaluatorRoles = [ const evaluatorRoles: any[] = [
{ id: district.asmId, role: 'ASM', stage: 'ASM_REVIEW' }, { id: district.asmId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW },
{ id: region?.rbmId, role: 'RBM', stage: 'RBM_REVIEW' }, { id: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW },
{ id: district.zmId, role: 'DD-ZM', stage: 'DD_ZM_REVIEW' }, { id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
{ id: zone?.zbhId, role: 'ZBH', stage: 'ZBH_REVIEW' } { id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW }
]; ];
// Get DD Lead (zone-scoped) // Get DD Lead (zone-scoped)
@ -320,18 +335,29 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
model: db.UserRole, model: db.UserRole,
as: 'userRoles', as: 'userRoles',
where: { zoneId: zone.id, isActive: true } 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' }); if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
} }
// Get NBH and Legal (national) // Get DD Head (national)
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' }, attributes: ['id', 'fullName', 'email', 'roleCode'] }); const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
if (nbh) evaluatorRoles.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' }); if (ddHead) evaluatorRoles.push({ id: ddHead.id, roleCode: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL });
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' }, attributes: ['id', 'fullName', 'email', 'roleCode'] }); // Get NBH (national) - Approval Stage
if (legal) evaluatorRoles.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' }); const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
if (nbh) {
evaluatorRoles.push({ id: nbh.id, roleCode: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
}
// Get Legal (national)
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
if (legal) evaluatorRoles.push({ id: legal.id, roleCode: 'Legal Admin', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
// Get NBH (national) - Final Clearance Stage
if (nbh) {
evaluatorRoles.push({ id: nbh.id, roleCode: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR });
}
// Fetch user details for each evaluator // Fetch user details for each evaluator
for (const evaluator of evaluatorRoles) { for (const evaluator of evaluatorRoles) {
@ -342,7 +368,7 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
id: `eval-${evaluator.stage}`, id: `eval-${evaluator.stage}`,
userId: evaluator.id, userId: evaluator.id,
participantType: 'reviewer', participantType: 'reviewer',
metadata: { stage: evaluator.stage, role: evaluator.role, autoAssigned: true }, metadata: { stage: evaluator.stage, role: evaluator.roleCode, autoAssigned: true },
user user
}); });
} }
@ -350,7 +376,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
} }
} }
// Enrich response with currentLocation and proposedLocation
const response = request.toJSON(); const response = request.toJSON();
(response as any).currentLocation = (response as any).outlet ? `${(response as any).outlet.address}, ${(response as any).outlet.city}, ${(response as any).outlet.state} - ${(response as any).outlet.pincode}` : 'N/A';
(response as any).proposedLocation = `${(response as any).newAddress}, ${(response as any).newCity}, ${(response as any).newState}`;
(response as any).participants = participants; (response as any).participants = participants;
res.json({ success: true, request: response }); res.json({ success: true, request: response });
@ -367,51 +396,99 @@ 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 // Normalize action to uppercase for service consistency (APPROVE/REJECT)
const normalizedAction = action?.toUpperCase() || '';
// Check if id is a UUID or a requestId string
const idStr = String(id);
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: isUUID ? { id: idStr } : { requestId: idStr }
requestId: id
}
}); });
if (!request) { if (!request) {
return res.status(404).json({ success: false, message: 'Request not found' }); return res.status(404).json({ success: false, message: 'Request not found' });
} }
// 1. Authorization Check via Workflow Service
const canAction = await RelocationWorkflowService.canUserAction(request, req.user);
if (!canAction) {
return res.status(403).json({
success: false,
message: `Forbidden: Your role (${req.user?.roleCode}) is not authorized to take actions for the current stage: ${request.currentStage}`
});
}
// Update status and current_stage based on action // Update status and current_stage based on action
let newStatus = request.status; let newStatus = request.status;
let newCurrentStage = request.currentStage; let newCurrentStage = request.currentStage;
const stageFlow: Record<string, string> = { const stageFlow: Record<string, string> = {
[RELOCATION_STAGES.DD_ADMIN_REVIEW]: RELOCATION_STAGES.RBM_REVIEW, [RELOCATION_STAGES.ASM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
[RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.NBH_APPROVAL, [RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.NBH_APPROVAL,
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE, [RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED [RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR,
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
}; };
if (action === 'Approved') { if (normalizedAction === 'APPROVE') {
newCurrentStage = stageFlow[request.currentStage] || request.currentStage; newCurrentStage = stageFlow[request.currentStage] || request.currentStage;
newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage.replace('_', ' ')}`; newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`;
} else if (action === 'Rejected') { } else if (normalizedAction === 'REJECT') {
newStatus = 'Rejected'; newStatus = 'Rejected';
newCurrentStage = RELOCATION_STAGES.REJECTED; newCurrentStage = RELOCATION_STAGES.REJECTED;
} }
// Create a worknote entry // 2. Perform transition via Workflow Service (handles request update, timeline, audit logs)
await Worknote.create({ const progressSteps = 9;
requestId: request.id, const currentStepIndex = Object.keys(stageFlow).indexOf(request.currentStage);
requestType: 'relocation' as any, const newProgress = normalizedAction === 'APPROVE'
userId: req.user.id, ? Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100)
content: comments, : request.progressPercentage;
isInternal: true
await RelocationWorkflowService.transitionRelocation(request, newStatus, req.user?.id || null, {
reason: comments || 'No remarks provided',
stage: newCurrentStage,
action: normalizedAction,
progressPercentage: newProgress
}); });
// Update the request status and current stage // 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR
await request.update({ if (newCurrentStage === RELOCATION_STAGES.NBH_CLEARANCE_EOR && normalizedAction === 'APPROVE') {
status: newStatus, try {
currentStage: newCurrentStage, // Internal call to EOR controller logic (or we could use a service)
updatedAt: new Date() // For now, simpler to just trigger the DB creation here or ensure controller handles it
}); const { createChecklist } = await import('../eor/eor.controller.js');
// We mock the req/res for internal call or just use the DB directly
await db.EorChecklist.findOrCreate({
where: { relocationId: request.id },
defaults: {
status: 'In Progress',
relocationId: request.id,
applicationId: null
}
});
console.log(`[RelocationController] EOR Checklist initiated for ${request.requestId}`);
} catch (e) {
console.error('Failed to auto-initiate EOR checklist:', e);
}
}
// 3. Create a worknote entry for the comment
if (comments) {
await Worknote.create({
requestId: request.id,
requestType: 'relocation' as any,
userId: req.user.id,
content: comments,
isInternal: true
});
}
res.json({ success: true, message: `Request ${action.toLowerCase()} successfully` }); res.json({ success: true, message: `Request ${action.toLowerCase()} successfully` });
} catch (error) { } catch (error) {
@ -423,31 +500,165 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
export const uploadDocuments = async (req: AuthRequest, res: Response) => { export const uploadDocuments = async (req: AuthRequest, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { documents } = req.body; const { documentType, stage } = req.body;
const file = req.file;
if (!file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
if (!documentType) {
return res.status(400).json({ success: false, message: 'Document type is required' });
}
// Only search by requestId since frontend sends requestId, not UUID // Only search by requestId since frontend sends requestId, not UUID
const request = await RelocationRequest.findOne({ const request = await RelocationRequest.findOne({
where: { where: {
requestId: id [Op.or]: [
{ id },
{ requestId: id }
]
} }
}); });
if (!request) { if (!request) {
return res.status(404).json({ success: false, message: 'Request not found' }); return res.status(404).json({ success: false, message: 'Relocation request not found' });
} }
// Create Document Record
const newDoc = await Document.create({
requestId: request.id,
requestType: 'relocation',
documentType,
stage: stage || request.currentStage,
fileName: file.originalname,
filePath: file.path,
fileSize: file.size,
mimeType: file.mimetype,
uploadedBy: req.user?.id,
status: 'active'
});
// Update the documents JSON array in RelocationRequest for quick access/legacy compatibility
const currentDocuments = request.documents || [];
const updatedDocuments = [...currentDocuments, {
id: newDoc.id,
name: file.originalname,
type: documentType,
url: newDoc.filePath, // Critical for frontend preview/download
mimeType: newDoc.mimeType, // Useful for previewer
uploadedOn: new Date(),
uploadedBy: req.user?.fullName || 'System',
status: 'Pending Verification',
category: 'Relocation'
}];
await request.update({ await request.update({
documents: documents, documents: updatedDocuments,
updatedAt: new Date() updatedAt: new Date()
}); });
res.json({ success: true, message: 'Documents uploaded successfully' }); res.status(201).json({
success: true,
message: 'Document uploaded successfully',
data: newDoc
});
} catch (error) { } catch (error) {
console.error('Upload documents error:', error); console.error('Upload documents error:', error);
res.status(500).json({ success: false, message: 'Error uploading documents' }); res.status(500).json({ success: false, message: 'Error uploading documents' });
} }
}; };
export const verifyDocument = async (req: AuthRequest, res: Response) => {
try {
const { id, documentId } = req.params;
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
// Search by UUID or requestId for the request
const request = await RelocationRequest.findOne({
where: {
[Op.or]: [
{ id },
{ requestId: id }
]
}
});
if (!request) {
return res.status(404).json({ success: false, message: 'Relocation request not found' });
}
// Authorization: Non-dealers only, and ideally matching the current stage
const isInternal = req.user.roleCode !== 'Dealer';
if (!isInternal) {
return res.status(403).json({ success: false, message: 'Forbidden: Dealers cannot verify documents' });
}
// Find and update the Document record
const docRecord = await Document.findByPk(documentId);
if (docRecord) {
await docRecord.update({ status: 'Verified' });
}
// Update the document entry in the request's JSON JSON array
const currentDocuments = request.documents || [];
const documentIndex = currentDocuments.findIndex((d: any) => d.id === documentId);
if (documentIndex !== -1) {
currentDocuments[documentIndex].status = 'Verified';
currentDocuments[documentIndex].verifiedBy = req.user.fullName;
currentDocuments[documentIndex].verifiedOn = new Date();
// Add simple timeline log for document verification in same update
const updatedTimeline = [...(request.timeline || []), {
stage: request.currentStage,
timestamp: new Date(),
user: req.user.fullName,
action: 'Document Verified',
remarks: `Verified document: ${currentDocuments[documentIndex].name}`
}];
// Calculate progress percentage
const totalRequired = 12; // Standard relocation requirement
const verifiedCount = currentDocuments.filter((d: any) => d.status === 'Verified').length;
const progressPercentage = Math.min(Math.round((verifiedCount / totalRequired) * 100), 100);
// Update request status to 'In Progress' if it was 'Pending'
let newStatus = request.status;
if (request.status === 'Pending') {
newStatus = 'In Progress';
}
await request.update({
documents: currentDocuments,
timeline: updatedTimeline,
progressPercentage,
status: newStatus,
updatedAt: new Date()
});
// Force Sequelize to detect JSON changes
request.changed('documents', true);
request.changed('timeline', true);
await request.save();
return res.json({
success: true,
message: 'Document verified successfully',
document: currentDocuments[documentIndex],
progressPercentage,
status: newStatus
});
}
res.status(404).json({ success: false, message: 'Document not found in request tracker' });
} catch (error) {
console.error('Verify document error:', error);
res.status(500).json({ success: false, message: 'Error verifying document' });
}
};
// Helper function to calculate distance between two coordinates // Helper function to calculate distance between two coordinates
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Radius of Earth in km const R = 6371; // Radius of Earth in km

View File

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

View File

@ -139,7 +139,7 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
// Build where clause based on user role // Build where clause based on user role
let where: any = {}; let where: any = {};
if (req.user.role === ROLES.DEALER) { if (req.user.roleCode === ROLES.DEALER) {
where.dealerId = req.user.id; where.dealerId = req.user.id;
} }
@ -219,7 +219,7 @@ export const getResignationById = async (req: AuthRequest, res: Response, next:
} }
// Check access permissions // Check access permissions
if (req.user.role === ROLES.DEALER && resignation.dealerId !== req.user.id) { if (req.user.roleCode === ROLES.DEALER && resignation.dealerId !== req.user.id) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
message: 'Access denied' message: 'Access denied'

View File

@ -13,14 +13,14 @@ router.use('/resignations', resignationRoutes);
router.post('/constitutional', authenticate as any, constitutionalController.submitRequest); router.post('/constitutional', authenticate as any, constitutionalController.submitRequest);
router.get('/constitutional', authenticate as any, constitutionalController.getRequests); router.get('/constitutional', authenticate as any, constitutionalController.getRequests);
router.get('/constitutional/:id', authenticate as any, constitutionalController.getRequestById); router.get('/constitutional/:id', authenticate as any, constitutionalController.getRequestById);
router.put('/constitutional/:id/action', authenticate as any, constitutionalController.takeAction); router.post('/constitutional/:id/action', authenticate as any, constitutionalController.takeAction);
router.post('/constitutional/:id/documents', authenticate as any, constitutionalController.uploadDocuments); router.post('/constitutional/:id/documents', authenticate as any, constitutionalController.uploadDocuments);
// Relocation submodule // Relocation submodule
router.post('/relocation', authenticate as any, relocationController.submitRequest); router.post('/relocation', authenticate as any, relocationController.submitRequest);
router.get('/relocation', authenticate as any, relocationController.getRequests); router.get('/relocation', authenticate as any, relocationController.getRequests);
router.get('/relocation/:id', authenticate as any, relocationController.getRequestById); router.get('/relocation/:id', authenticate as any, relocationController.getRequestById);
router.put('/relocation/:id/action', authenticate as any, relocationController.takeAction); router.post('/relocation/:id/action', authenticate as any, relocationController.takeAction);
router.post('/relocation/:id/documents', authenticate as any, relocationController.uploadDocuments); router.post('/relocation/:id/documents', authenticate as any, relocationController.uploadDocuments);
export default router; export default router;

View File

@ -0,0 +1,91 @@
import db from '../database/models/index.js';
const { RelocationRequest, AuditLog, User } = db;
import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES } from '../common/config/constants.js';
export class RelocationWorkflowService {
/**
* Standardized method to transition a relocation request status
*/
static async transitionRelocation(request: any, targetStatus: string, userId: string | null = null, metadata: any = {}) {
const previousStatus = request.status;
const { reason, stage, progressPercentage, action } = metadata;
const updateData: any = {
status: targetStatus,
updatedAt: new Date()
};
// Update stage if provided and valid
if (stage && Object.values(RELOCATION_STAGES).includes(stage)) {
updateData.currentStage = stage;
}
// Update progress percentage if explicitly provided
if (progressPercentage !== undefined) {
updateData.progressPercentage = progressPercentage;
}
// 1. Update Request Record
await request.update(updateData);
// 2. Update Timeline (JSON array)
const user = userId ? await User.findByPk(userId) : null;
const timelineEntry = {
stage: stage || request.currentStage,
timestamp: new Date(),
user: user ? user.fullName : 'System',
action: action || `Transitioned to ${targetStatus}`,
remarks: reason || ''
};
const updatedTimeline = [...(request.timeline || []), timelineEntry];
await request.update({ timeline: updatedTimeline });
// 3. Create Audit Log
await AuditLog.create({
userId: userId,
action: action === 'REJECT' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.APPROVED,
entityType: 'relocation',
entityId: request.id,
newData: { status: targetStatus, stage: stage || request.currentStage, reason }
});
console.log(`[RelocationWorkflowService] Transitioned Request ${request.requestId} to ${targetStatus}`);
return request;
}
/**
* Checks if a user is authorized to perform an action in the current stage
*/
static async canUserAction(request: any, user: any) {
if (!user) return false;
// Super Admin bypass
if (user.roleCode === ROLES.SUPER_ADMIN) return true;
const stageMapping: Record<string, string> = {
[RELOCATION_STAGES.ASM_REVIEW]: ROLES.ASM,
[RELOCATION_STAGES.RBM_REVIEW]: ROLES.RBM,
[RELOCATION_STAGES.DD_ZM_REVIEW]: ROLES.DD_ZM,
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: ROLES.DD_HEAD,
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN,
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: ROLES.NBH
};
const requiredRole = stageMapping[request.currentStage];
if (!requiredRole) return false;
// Role-based check
if (user.roleCode !== requiredRole) return false;
// Optional: Hierarchy check
// We could verify if the user is the SPECIFIC person assigned in participants
// but for now, any user with the correct role can act (consistent with simple RBAC)
return true;
}
}

55
verify_relocation_auth.ts Normal file
View File

@ -0,0 +1,55 @@
import db from './src/database/models/index.js';
import { RELOCATION_STAGES, ROLES } from './src/common/config/constants.js';
import { RelocationWorkflowService } from './src/services/RelocationWorkflowService.js';
const { RelocationRequest, User } = db;
async function run() {
try {
console.log('--- Relocation Authorization Verification ---');
// 1. Find a test request
const request = await RelocationRequest.findOne({
order: [['createdAt', 'DESC']]
});
if (!request) {
console.log('No relocation request found to test.');
process.exit(0);
}
console.log(`Testing Request: ${request.requestId}, Current Stage: ${request.currentStage}`);
// 2. Find users with different roles
const asmUser = await User.findOne({ where: { roleCode: ROLES.ASM, status: 'active' } });
const rbmUser = await User.findOne({ where: { roleCode: ROLES.RBM, status: 'active' } });
const dealerUser = await User.findOne({ where: { roleCode: ROLES.DEALER, status: 'active' } });
const superAdmin = await User.findOne({ where: { roleCode: ROLES.SUPER_ADMIN, status: 'active' } });
// 3. Test ASM approval on ASM_REVIEW stage
if (request.currentStage === RELOCATION_STAGES.ASM_REVIEW) {
console.log('Testing ASM_REVIEW stage:');
const canASM = await RelocationWorkflowService.canUserAction(request, asmUser);
console.log(`- ASM can action: ${canASM} (Expected: true)`);
const canRBM = await RelocationWorkflowService.canUserAction(request, rbmUser);
console.log(`- RBM can action: ${canRBM} (Expected: false)`);
const canDealer = await RelocationWorkflowService.canUserAction(request, dealerUser);
console.log(`- Dealer can action: ${canDealer} (Expected: false)`);
const canSuperAdmin = await RelocationWorkflowService.canUserAction(request, superAdmin);
console.log(`- Super Admin can action: ${canSuperAdmin} (Expected: true)`);
} else {
console.log(`Request is in ${request.currentStage}. Please create a new request to test ASM_REVIEW.`);
}
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}
run();

View File

@ -0,0 +1,102 @@
import db from './src/database/models/index.js';
import { RELOCATION_STAGES } from './src/common/config/constants.js';
const { RelocationRequest, Outlet, User, District, Region, Zone } = db;
async function run() {
try {
console.log('--- Relocation Workflow Verification ---');
// 1. Find a test outlet
const outlet = await Outlet.findOne({
include: [
{
model: District,
as: 'district',
include: [
{ model: Region, as: 'region' },
{ model: Zone, as: 'zone' }
]
}
]
});
if (!outlet || !outlet.district) {
console.log('No outlet with district found to test.');
process.exit(1);
}
console.log(`Testing with Outlet: ${outlet.code}, District: ${outlet.district.name}`);
// Create a mock user if needed (the dealer)
const dealer = await User.findOne({ where: { roleCode: 'Dealer' } });
if (!dealer) {
console.log('No dealer found for testing.');
process.exit(1);
}
const { submitRequest } = await import('./src/modules/self-service/relocation.controller.js');
// We'll mock the Request/Response if we wanted to call the controller directly,
// but here we just want to verify if the evaluator logic would work.
// Let's just manually run the assignRelocationEvaluators logic (internal)
// Actually, I'll just check if the Controller's getRequestById would return participants correctly.
// Let's create a relocation request directly to test
const request = await RelocationRequest.create({
requestId: 'TEST-REL-' + Date.now(),
outletId: outlet.id,
dealerId: dealer.id,
relocationType: 'Within City',
newAddress: 'New Test Address',
newCity: 'Test City',
newState: 'Test State',
reason: 'Testing workflow assignment',
currentStage: RELOCATION_STAGES.ASM_REVIEW,
status: 'Pending ASM Review',
progressPercentage: 10,
documents: [],
timeline: []
});
console.log(`Created Test Request: ${request.requestId}`);
// Now call the logic that calculates participants (similar to getRequestById)
// We'll just look at the DB for now to see if DD Head and NBH (dual) would be assigned.
// Check DD Head
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
console.log(`DD Head found in DB: ${ddHead ? ddHead.fullName : 'NO'}`);
// Check NBH
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`);
// Verify Evaluator Assignment Logic (Re-running a piece of it)
const evaluators = [];
evaluators.push({ id: outlet.district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW });
evaluators.push({ id: ddHead?.id, role: 'DD Head', stage: RELOCATION_STAGES.DD_HEAD_APPROVAL });
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE });
console.log('Expected Evaluators for this request:');
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));
// Check for success: All must have IDs
const missing = evaluators.filter(e => !e.id);
if (missing.length > 0) {
console.warn('WARNING: Missing some evaluators in the DB. Ensure they are seeded!');
missing.forEach(m => console.log(` Missing: ${m.role}`));
} else {
console.log('SUCCESS: All hierarchy and national evaluators identified.');
}
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}
run();