relocatin
flow integrated at cetrtain extent douments mapping , assigns mapping done documents application constrain removed
This commit is contained in:
parent
8d2a7874de
commit
28580b7fb0
@ -166,10 +166,15 @@ export const RELOCATION_TYPES = {
|
||||
|
||||
// Relocation Stages
|
||||
export const RELOCATION_STAGES = {
|
||||
DD_ADMIN_REVIEW: 'DD Admin Review',
|
||||
ASM_REVIEW: 'ASM 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',
|
||||
LEGAL_CLEARANCE: 'Legal Clearance',
|
||||
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
|
||||
COMPLETED: 'Completed',
|
||||
REJECTED: 'Rejected'
|
||||
} as const;
|
||||
@ -338,6 +343,18 @@ export const DOCUMENT_TYPES = {
|
||||
SECURITY_DEPOSIT_RECEIPT: 'Security Deposit Receipt',
|
||||
SECURITY_DEPOSIT_INITIAL: 'Initial 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'
|
||||
} as const;
|
||||
|
||||
|
||||
34
src/common/utils/dateUtils.ts
Normal file
34
src/common/utils/dateUtils.ts
Normal 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);
|
||||
}
|
||||
@ -248,7 +248,8 @@ export default (sequelize: Sequelize) => {
|
||||
Application.hasMany(models.Document, {
|
||||
foreignKey: 'requestId',
|
||||
as: 'uploadedDocuments',
|
||||
scope: { requestType: 'application' }
|
||||
scope: { requestType: 'application' },
|
||||
constraints: false
|
||||
});
|
||||
|
||||
Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' });
|
||||
|
||||
@ -2,7 +2,8 @@ import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
|
||||
export interface EorChecklistAttributes {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
applicationId: string | null;
|
||||
relocationId: string | null;
|
||||
auditorId: string | null;
|
||||
auditDate: Date | null;
|
||||
status: string;
|
||||
@ -20,12 +21,20 @@ export default (sequelize: Sequelize) => {
|
||||
},
|
||||
applicationId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'applications',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
relocationId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'relocation_requests',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
auditorId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
@ -53,6 +62,7 @@ export default (sequelize: Sequelize) => {
|
||||
|
||||
(EorChecklist as any).associate = (models: any) => {
|
||||
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.hasMany(models.EorChecklistItem, { foreignKey: 'checklistId', as: 'items' });
|
||||
|
||||
@ -88,7 +88,7 @@ export default (sequelize: Sequelize) => {
|
||||
},
|
||||
currentStage: {
|
||||
type: DataTypes.ENUM(...Object.values(RELOCATION_STAGES)),
|
||||
defaultValue: RELOCATION_STAGES.DD_ADMIN_REVIEW
|
||||
defaultValue: RELOCATION_STAGES.ASM_REVIEW
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
@ -140,6 +140,16 @@ export default (sequelize: Sequelize) => {
|
||||
scope: { requestType: 'relocation' },
|
||||
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
|
||||
// See getRequestById in relocation.controller.ts
|
||||
};
|
||||
|
||||
@ -5,9 +5,9 @@ import { AuthRequest } from '../../types/express.types.js';
|
||||
|
||||
export const getChecklist = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { applicationId } = req.params;
|
||||
const { applicationId, relocationId } = req.params;
|
||||
let checklist = await EorChecklist.findOne({
|
||||
where: { applicationId },
|
||||
where: relocationId ? { relocationId } : { applicationId },
|
||||
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) => {
|
||||
try {
|
||||
const { applicationId } = req.body;
|
||||
const { applicationId, relocationId } = req.body;
|
||||
|
||||
const application = await db.Application.findByPk(applicationId);
|
||||
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
||||
if (!applicationId && !relocationId) {
|
||||
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({
|
||||
where: { applicationId },
|
||||
defaults: { status: 'In Progress' }
|
||||
where: relocationId ? { relocationId } : { applicationId },
|
||||
defaults: {
|
||||
status: 'In Progress',
|
||||
applicationId: applicationId || null,
|
||||
relocationId: relocationId || null
|
||||
}
|
||||
});
|
||||
|
||||
if (created) {
|
||||
// Define Default Mandatory Items per SRS/Frontend
|
||||
const 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' }
|
||||
];
|
||||
let defaultItems = [];
|
||||
|
||||
if (relocationId) {
|
||||
// Strictly per SRS Section 12.2.8 for Relocation
|
||||
defaultItems = [
|
||||
{ itemType: 'Property', description: 'Property documents for new location' },
|
||||
{ itemType: 'Property', description: 'Lease / Rental agreement' },
|
||||
{ itemType: 'Property', description: 'Layout / Floor plan of new location' },
|
||||
{ itemType: 'Infrastructure', description: 'Photos of new location' },
|
||||
{ itemType: 'Infrastructure', description: 'Locality map / Building plan approval' },
|
||||
{ itemType: 'Statutory', description: 'NOC from current landlord' },
|
||||
{ itemType: 'Statutory', description: 'Municipal approvals (Fire safety / Pollution clearance)' },
|
||||
{ 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 => ({
|
||||
...item,
|
||||
@ -113,15 +143,25 @@ export const submitAudit = async (req: AuthRequest, res: Response) => {
|
||||
if (status === 'Completed') {
|
||||
const checklist = await EorChecklist.findByPk(checklistId);
|
||||
if (checklist) {
|
||||
await db.Application.update({
|
||||
overallStatus: 'Approved',
|
||||
progressPercentage: 100
|
||||
}, { where: { id: checklist.applicationId } });
|
||||
if (checklist.applicationId) {
|
||||
await db.Application.update({
|
||||
overallStatus: 'Approved',
|
||||
progressPercentage: 100
|
||||
}, { where: { id: checklist.applicationId } });
|
||||
|
||||
// Update Progress Tracking
|
||||
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
|
||||
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
|
||||
await updateApplicationProgress(checklist.applicationId, 'Inauguration', 'active', 50);
|
||||
// Update Progress Tracking
|
||||
const { updateApplicationProgress } = await import('../../common/utils/progress.js');
|
||||
await updateApplicationProgress(checklist.applicationId, 'EOR Complete', 'completed', 100);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,8 @@ import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
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('/item/:checklistId', eorController.updateItem);
|
||||
router.post('/audit/:checklistId', eorController.submitAudit);
|
||||
|
||||
@ -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 });
|
||||
} catch (error) {
|
||||
console.error('Update Security Deposit error:', error);
|
||||
|
||||
@ -64,6 +64,7 @@ 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);
|
||||
|
||||
@ -50,7 +50,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const where: any = {};
|
||||
if (req.user.role === 'Dealer') {
|
||||
if (req.user.roleCode === 'Dealer') {
|
||||
where.dealerId = req.user.id;
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Response } from 'express';
|
||||
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 { Op, Transaction } from 'sequelize';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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
|
||||
@ -48,22 +50,22 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
|
||||
|
||||
// Stage 1: DD ASM (from district)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
@ -77,30 +79,35 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
|
||||
}]
|
||||
});
|
||||
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)
|
||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||
if (nbh) {
|
||||
evaluators.push({ id: nbh.id, role: 'NBH', stage: 'NBH_REVIEW' });
|
||||
// Stage 6: DD Head (national)
|
||||
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
|
||||
if (ddHead) {
|
||||
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' } });
|
||||
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`);
|
||||
|
||||
// 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,
|
||||
@ -108,13 +115,9 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
|
||||
}));
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@ -152,9 +155,9 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
newDistrictId: newDistrictId || null,
|
||||
newStateId: newStateId || null,
|
||||
reason,
|
||||
currentStage: RELOCATION_STAGES.DD_ADMIN_REVIEW as any,
|
||||
status: 'Pending',
|
||||
progressPercentage: 20,
|
||||
currentStage: RELOCATION_STAGES.ASM_REVIEW,
|
||||
status: 'Pending ASM Review',
|
||||
progressPercentage: 10,
|
||||
documents: [],
|
||||
timeline: [{
|
||||
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' });
|
||||
|
||||
const where: any = {};
|
||||
if (req.user.role === 'Dealer') {
|
||||
if (req.user.roleCode === 'Dealer') {
|
||||
where.dealerId = req.user.id;
|
||||
}
|
||||
|
||||
@ -193,7 +196,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
{
|
||||
model: Outlet,
|
||||
as: 'outlet',
|
||||
attributes: ['code', 'name'],
|
||||
attributes: ['code', 'name', 'address', 'city', 'state', 'pincode'],
|
||||
include: [
|
||||
{
|
||||
model: District,
|
||||
@ -218,7 +221,7 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
// 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') {
|
||||
if (req.user?.roleCode === 'Dealer') {
|
||||
return request.dealerId === req.user.id;
|
||||
}
|
||||
|
||||
@ -247,8 +250,20 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
return isAssigned;
|
||||
});
|
||||
|
||||
// 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: filteredRequests });
|
||||
res.json({ success: true, requests: enrichedRequests });
|
||||
} catch (error) {
|
||||
console.error('Get relocation requests error:', error);
|
||||
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 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' }
|
||||
const evaluatorRoles: any[] = [
|
||||
{ id: district.asmId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW },
|
||||
{ id: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW },
|
||||
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
|
||||
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_REVIEW }
|
||||
];
|
||||
|
||||
// Get DD Lead (zone-scoped)
|
||||
@ -320,18 +335,29 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
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' });
|
||||
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.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' });
|
||||
// Get DD Head (national)
|
||||
const ddHead = await User.findOne({ where: { roleCode: 'DD Head', status: 'active' } });
|
||||
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'] });
|
||||
if (legal) evaluatorRoles.push({ id: legal.id, role: 'Legal', stage: 'LEGAL_CLEARANCE' });
|
||||
// Get NBH (national) - Approval Stage
|
||||
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
|
||||
for (const evaluator of evaluatorRoles) {
|
||||
@ -342,7 +368,7 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
id: `eval-${evaluator.stage}`,
|
||||
userId: evaluator.id,
|
||||
participantType: 'reviewer',
|
||||
metadata: { stage: evaluator.stage, role: evaluator.role, autoAssigned: true },
|
||||
metadata: { stage: evaluator.stage, role: evaluator.roleCode, autoAssigned: true },
|
||||
user
|
||||
});
|
||||
}
|
||||
@ -350,7 +376,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich response with currentLocation and proposedLocation
|
||||
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;
|
||||
|
||||
res.json({ success: true, request: response });
|
||||
@ -367,51 +396,99 @@ 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
|
||||
// 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({
|
||||
where: {
|
||||
requestId: id
|
||||
}
|
||||
where: isUUID ? { id: idStr } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
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
|
||||
let newStatus = request.status;
|
||||
let newCurrentStage = request.currentStage;
|
||||
|
||||
const stageFlow: Record<string, string> = {
|
||||
[RELOCATION_STAGES.DD_ADMIN_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||
[RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.ASM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||
[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.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;
|
||||
newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage.replace('_', ' ')}`;
|
||||
} else if (action === 'Rejected') {
|
||||
newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`;
|
||||
} else if (normalizedAction === 'REJECT') {
|
||||
newStatus = 'Rejected';
|
||||
newCurrentStage = RELOCATION_STAGES.REJECTED;
|
||||
}
|
||||
|
||||
// Create a worknote entry
|
||||
await Worknote.create({
|
||||
requestId: request.id,
|
||||
requestType: 'relocation' as any,
|
||||
userId: req.user.id,
|
||||
content: comments,
|
||||
isInternal: true
|
||||
// 2. Perform transition via Workflow Service (handles request update, timeline, audit logs)
|
||||
const progressSteps = 9;
|
||||
const currentStepIndex = Object.keys(stageFlow).indexOf(request.currentStage);
|
||||
const newProgress = normalizedAction === 'APPROVE'
|
||||
? Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100)
|
||||
: request.progressPercentage;
|
||||
|
||||
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
|
||||
await request.update({
|
||||
status: newStatus,
|
||||
currentStage: newCurrentStage,
|
||||
updatedAt: new Date()
|
||||
});
|
||||
// 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR
|
||||
if (newCurrentStage === RELOCATION_STAGES.NBH_CLEARANCE_EOR && normalizedAction === 'APPROVE') {
|
||||
try {
|
||||
// Internal call to EOR controller logic (or we could use a service)
|
||||
// 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` });
|
||||
} catch (error) {
|
||||
@ -423,31 +500,165 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
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
|
||||
const request = await RelocationRequest.findOne({
|
||||
where: {
|
||||
requestId: id
|
||||
[Op.or]: [
|
||||
{ id },
|
||||
{ requestId: id }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
documents: documents,
|
||||
documents: updatedDocuments,
|
||||
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) {
|
||||
console.error('Upload documents error:', error);
|
||||
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
|
||||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371; // Radius of Earth in km
|
||||
|
||||
@ -3,12 +3,14 @@ const router = express.Router();
|
||||
|
||||
import * as relocationController from './relocation.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
import { uploadSingle } from '../../common/middleware/upload.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);
|
||||
router.post('/:id/action', authenticate as any, relocationController.takeAction);
|
||||
router.post('/:id/documents', authenticate as any, uploadSingle, relocationController.uploadDocuments);
|
||||
router.post('/:id/documents/:documentId/verify', authenticate as any, relocationController.verifyDocument);
|
||||
|
||||
export default router;
|
||||
@ -139,7 +139,7 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
|
||||
// Build where clause based on user role
|
||||
let where: any = {};
|
||||
|
||||
if (req.user.role === ROLES.DEALER) {
|
||||
if (req.user.roleCode === ROLES.DEALER) {
|
||||
where.dealerId = req.user.id;
|
||||
}
|
||||
|
||||
@ -219,7 +219,7 @@ export const getResignationById = async (req: AuthRequest, res: Response, next:
|
||||
}
|
||||
|
||||
// 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({
|
||||
success: false,
|
||||
message: 'Access denied'
|
||||
|
||||
@ -13,14 +13,14 @@ router.use('/resignations', resignationRoutes);
|
||||
router.post('/constitutional', authenticate as any, constitutionalController.submitRequest);
|
||||
router.get('/constitutional', authenticate as any, constitutionalController.getRequests);
|
||||
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);
|
||||
|
||||
// Relocation submodule
|
||||
router.post('/relocation', authenticate as any, relocationController.submitRequest);
|
||||
router.get('/relocation', authenticate as any, relocationController.getRequests);
|
||||
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);
|
||||
|
||||
export default router;
|
||||
|
||||
91
src/services/RelocationWorkflowService.ts
Normal file
91
src/services/RelocationWorkflowService.ts
Normal 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
55
verify_relocation_auth.ts
Normal 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();
|
||||
102
verify_relocation_workflow.ts
Normal file
102
verify_relocation_workflow.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user