contitutional and relocation changes done based on document alignment
This commit is contained in:
parent
5ddbe525e6
commit
0ab90ee356
@ -1,30 +0,0 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function fixEnum() {
|
||||
const enumName = 'enum_constitutional_changes_changeType';
|
||||
const newValues = ['Proprietorship', 'Partnership', 'LLP', 'Private Limited'];
|
||||
|
||||
console.log(`--- Patching DB ENUM: ${enumName} ---`);
|
||||
|
||||
for (const val of newValues) {
|
||||
try {
|
||||
// Sequelize does not have a direct method for ADD VALUE to ENUM in all dialects, using raw query
|
||||
// Using check to avoid "already exists" error
|
||||
await db.sequelize.query(`ALTER TYPE "${enumName}" ADD VALUE IF NOT EXISTS '${val}'`);
|
||||
console.log(`✅ Added '${val}' to ${enumName}`);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes('already exists')) {
|
||||
console.log(`ℹ️ '${val}' already exists in ${enumName}`);
|
||||
} else {
|
||||
console.log(`❌ Failed to add '${val}':`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('--- ENUM Patching Complete ---');
|
||||
}
|
||||
|
||||
fixEnum().catch(err => {
|
||||
console.error('Migration failed:', err);
|
||||
process.exit(1);
|
||||
}).then(() => process.exit(0));
|
||||
@ -1,8 +1,12 @@
|
||||
/**
|
||||
* Database Migration Script
|
||||
* Synchronizes all Sequelize models with the database
|
||||
* Synchronizes all Sequelize models with the database (PostgreSQL).
|
||||
* This script will DROP all existing tables and recreate them.
|
||||
*
|
||||
* Schema for modules such as constitutional change (ENUM values, partial unique indexes,
|
||||
* columns) is defined only on Sequelize models — no separate "table alteration" scripts are
|
||||
* required after a fresh `migrate` + `seed:all` (see package.json `setup:fresh`).
|
||||
*
|
||||
* Run: npx tsx scripts/migrate.ts
|
||||
*/
|
||||
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function migrate() {
|
||||
const queryInterface = db.sequelize.getQueryInterface();
|
||||
|
||||
// Using describeTable to check existence
|
||||
const tableDefinition = await queryInterface.describeTable('constitutional_changes');
|
||||
|
||||
console.log('--- Migrating constitutional_changes table ---');
|
||||
|
||||
if (!tableDefinition.currentConstitution) {
|
||||
console.log('Adding currentConstitution column...');
|
||||
await queryInterface.addColumn('constitutional_changes', 'currentConstitution', {
|
||||
type: db.Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableDefinition.metadata) {
|
||||
console.log('Adding metadata column...');
|
||||
await queryInterface.addColumn('constitutional_changes', 'metadata', {
|
||||
type: db.Sequelize.DataTypes.JSON,
|
||||
defaultValue: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Update outletId to be nullable
|
||||
console.log('Updating outletId to be nullable...');
|
||||
await queryInterface.changeColumn('constitutional_changes', 'outletId', {
|
||||
type: db.Sequelize.DataTypes.UUID,
|
||||
allowNull: true
|
||||
});
|
||||
|
||||
console.log('✅ Migration complete!');
|
||||
}
|
||||
|
||||
migrate();
|
||||
35
src/__tests__/constitutional-alignment.test.ts
Normal file
35
src/__tests__/constitutional-alignment.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { normalizeToConstitutionalChangeType, mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||
import { ConstitutionalWorkflowService } from '../services/ConstitutionalWorkflowService.js';
|
||||
|
||||
describe('Constitutional alignment', () => {
|
||||
it('rejects legacy non-structure change types after scope tightening', () => {
|
||||
expect(normalizeToConstitutionalChangeType('Director Change')).toBeNull();
|
||||
expect(normalizeToConstitutionalChangeType('Ownership Transfer')).toBeNull();
|
||||
expect(normalizeToConstitutionalChangeType('Company Formation')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps supported structure change types to dealer profile', () => {
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('Proprietorship')).toBe('Proprietorship');
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('Partnership')).toBe('Partnership');
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('LLP')).toBe('LLP');
|
||||
expect(mapConstitutionalChangeTypeToDealerProfile('Private Limited')).toBe('Private Limited');
|
||||
});
|
||||
|
||||
it('computes missing mandatory documents from uploaded checklist payload', () => {
|
||||
const completeDocs = [
|
||||
{ documentType: 'GST Certificate' },
|
||||
{ documentType: 'PAN Card' },
|
||||
{ documentType: 'Aadhaar' },
|
||||
{ documentType: 'Certificate of Incorporation' },
|
||||
{ documentType: 'Business Purchase Agreement (BPA)' },
|
||||
{ documentType: 'LLP Agreement' },
|
||||
{ documentType: 'Cancelled Check' },
|
||||
{ documentType: 'Declaration / Authorization Letter' }
|
||||
];
|
||||
expect(ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', completeDocs)).toEqual([]);
|
||||
|
||||
const missingOne = completeDocs.filter((d) => d.documentType !== 'Business Purchase Agreement (BPA)');
|
||||
const missing = ConstitutionalWorkflowService.getMissingMandatoryDocuments('LLP', missingOne);
|
||||
expect(missing).toContain('Business Purchase Agreement (BPA)');
|
||||
});
|
||||
});
|
||||
@ -223,13 +223,8 @@ export const RESIGNATION_TYPES = {
|
||||
export const CONSTITUTIONAL_CHANGE_TYPES = {
|
||||
PROPRIETORSHIP: 'Proprietorship',
|
||||
PARTNERSHIP: 'Partnership',
|
||||
LLP_CONVERSION: 'LLP Conversion',
|
||||
LLP: 'LLP',
|
||||
PRIVATE_LIMITED: 'Private Limited',
|
||||
COMPANY_FORMATION: 'Company Formation',
|
||||
OWNERSHIP_TRANSFER: 'Ownership Transfer',
|
||||
PARTNERSHIP_CHANGE: 'Partnership Change',
|
||||
DIRECTOR_CHANGE: 'Director Change'
|
||||
PRIVATE_LIMITED: 'Private Limited'
|
||||
} as const;
|
||||
|
||||
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
|
||||
@ -270,10 +265,8 @@ export const RELOCATION_STAGES = {
|
||||
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;
|
||||
@ -570,7 +563,7 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU
|
||||
export const STAGES_MAP = {
|
||||
'ONBOARDING': ['General', 'KYC', 'Level 1 Interview', 'Level 2 Interview', 'Level 3 Interview', 'FDD', 'LOI Approval', 'LOA Approval', 'LOI Issue', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion', 'EOR', 'Inauguration'],
|
||||
'RESIGNATION': ['Submission', 'Regional Review', 'RBM + DD-ZM Review', 'ZBH Review', 'Finance Review', 'DDL Review', 'Approved'],
|
||||
'RELOCATION': ['Initiated', 'ASM Review', 'ZM Review', 'ZBH Review', 'Completed'],
|
||||
'RELOCATION': ['Initiated', 'ASM Review', 'RBM Review', 'DD ZM Review', 'ZBH Review', 'DD Lead Review', 'NBH Approval', 'Legal Clearance', 'Completed'],
|
||||
'CONSTITUTIONAL_CHANGE': ['Draft', 'Legal Review', 'Approved'],
|
||||
'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed']
|
||||
} as const;
|
||||
|
||||
@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
|
||||
) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
|
||||
}
|
||||
if (compact.includes('llp') && compact.includes('conversion')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION;
|
||||
}
|
||||
if (compact.includes('llp')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||
}
|
||||
if (compact.includes('partnership') && compact.includes('change')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE;
|
||||
}
|
||||
if (compact.includes('partnership')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
|
||||
}
|
||||
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP;
|
||||
}
|
||||
if (compact.includes('director')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE;
|
||||
}
|
||||
if (compact.includes('ownership') && compact.includes('transfer')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER;
|
||||
}
|
||||
if (compact.includes('company') && compact.includes('formation')) {
|
||||
return CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION;
|
||||
}
|
||||
const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
|
||||
return exact || null;
|
||||
}
|
||||
@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
||||
const t = String(changeType || '').trim();
|
||||
if (!t) return null;
|
||||
|
||||
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||
|
||||
const structureTargets = [
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
||||
@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
||||
];
|
||||
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t;
|
||||
|
||||
const skipAutoUpdate = [
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION
|
||||
];
|
||||
if (skipAutoUpdate.includes(t as (typeof skipAutoUpdate)[number])) return null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -67,10 +67,9 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
|
||||
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
||||
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
[RELOCATION_STAGES.COMPLETED]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
ROLES
|
||||
} from '../config/constants.js';
|
||||
|
||||
const { RequestParticipant, User, Outlet, District } = db;
|
||||
const { RequestParticipant, User, Outlet, District, Dealer } = db;
|
||||
|
||||
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
@ -150,7 +150,12 @@ export async function notifyRelocationSubmittedEmails(
|
||||
const outlet = await Outlet.findByPk(request.outletId, {
|
||||
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
|
||||
});
|
||||
const asmId = (outlet as any)?.district?.asmId;
|
||||
const dealerAccount = await User.findByPk(request.dealerId, {
|
||||
attributes: ['id'],
|
||||
include: [{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }]
|
||||
});
|
||||
const outletLevelAsmId = (dealerAccount as any)?.dealerProfile?.asmId ?? null;
|
||||
const asmId = outletLevelAsmId || (outlet as any)?.district?.asmId;
|
||||
if (!asmId) return;
|
||||
|
||||
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
||||
@ -202,7 +207,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
'DD Lead Review': [ROLES.DD_LEAD],
|
||||
'DD Head': [ROLES.DD_HEAD],
|
||||
'DD Head Review': [ROLES.DD_HEAD],
|
||||
'DD Head Approval': [ROLES.DD_HEAD],
|
||||
'NBH': [ROLES.NBH],
|
||||
'NBH Approval': [ROLES.NBH],
|
||||
'NBH Evaluation': [ROLES.NBH],
|
||||
@ -238,7 +242,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
||||
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
||||
|
||||
// --- Relocation/Constitutional Specific ---
|
||||
'NBH Clearance with EOR': [ROLES.NBH],
|
||||
'Submitted': [ROLES.ASM],
|
||||
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
|
||||
|
||||
@ -315,6 +318,7 @@ export async function notifyStakeholdersOnTransition(
|
||||
action: string;
|
||||
remarks: string;
|
||||
link: string;
|
||||
changeType?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
@ -412,7 +416,7 @@ export async function notifyStakeholdersOnTransition(
|
||||
// Override for Constitutional Change Completion
|
||||
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
|
||||
templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED';
|
||||
placeholders.proposedConstitution = metadata.remarks || 'Approved Structure'; // Remarks often contain the final structure or approval note
|
||||
placeholders.proposedConstitution = metadata.changeType || 'Approved Structure';
|
||||
}
|
||||
|
||||
// Override for Relocation Completion
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { Model, DataTypes, Sequelize, Op } from 'sequelize';
|
||||
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
|
||||
|
||||
export interface ConstitutionalChangeAttributes {
|
||||
@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => {
|
||||
{ fields: ['requestId'] },
|
||||
{ fields: ['outletId'] },
|
||||
{ fields: ['dealerId'] },
|
||||
{ fields: ['currentStage'] }
|
||||
{ fields: ['currentStage'] },
|
||||
/** SRS §12.2 — at most one non-terminal request per dealer (PostgreSQL partial unique index). */
|
||||
{
|
||||
name: 'uq_constitutional_open_per_dealer',
|
||||
unique: true,
|
||||
fields: ['dealerId'],
|
||||
where: {
|
||||
status: {
|
||||
[Op.notIn]: ['Completed', 'Closed', 'Rejected', 'Revoked']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow
|
||||
const STRUCTURE_TARGET_VALUES = new Set<string>(
|
||||
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string)
|
||||
);
|
||||
const CLOSED_CONSTITUTIONAL_STATUSES = ['Completed', 'Closed', 'Rejected', 'Revoked'];
|
||||
|
||||
const resolveConstitutionalUuid = async (id: string) => {
|
||||
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional');
|
||||
@ -166,6 +167,30 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
const requestId = await NomenclatureService.generateConstitutionalChangeId();
|
||||
|
||||
const dealerProfileStatus = String(subjectDealerProfile?.status || '').toLowerCase();
|
||||
const isDealerActive = !dealerProfileStatus || dealerProfileStatus === 'active';
|
||||
if (!isDealerActive || !subjectDealerProfile?.onboardedAt) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Constitutional change can be initiated only for active onboarded dealers.'
|
||||
});
|
||||
}
|
||||
|
||||
const existingOpenRequest = await ConstitutionalChange.findOne({
|
||||
where: {
|
||||
dealerId: dealerUserId,
|
||||
status: { [Op.notIn]: CLOSED_CONSTITUTIONAL_STATUSES }
|
||||
},
|
||||
attributes: ['id', 'requestId', 'status', 'currentStage'],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
if (existingOpenRequest) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `Open constitutional request ${existingOpenRequest.requestId} already exists at ${existingOpenRequest.currentStage}. Complete it before creating a new one.`
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
newPartnersDetails,
|
||||
shareholdingPattern,
|
||||
@ -215,19 +240,23 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
});
|
||||
|
||||
try {
|
||||
await ParticipantService.assignConstitutionalParticipants(request.id);
|
||||
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
|
||||
await notifyConstitutionalSubmittedEmails(request, displayName);
|
||||
} catch (e) {
|
||||
console.error('Error assigning participants or sending constitutional submit emails:', e);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Constitutional change request submitted successfully',
|
||||
requestId: request.requestId
|
||||
});
|
||||
|
||||
// Run participant assignment + notifications asynchronously to keep API responsive.
|
||||
// With Redis disabled, email sending is synchronous and can otherwise block for many seconds.
|
||||
void (async () => {
|
||||
try {
|
||||
await ParticipantService.assignConstitutionalParticipants(request.id);
|
||||
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
|
||||
await notifyConstitutionalSubmittedEmails(request, displayName);
|
||||
} catch (e) {
|
||||
console.error('Error assigning participants or sending constitutional submit emails:', e);
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
console.error('Submit constitutional change error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error submitting request' });
|
||||
@ -366,6 +395,9 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
|
||||
if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
|
||||
if (req.user?.roleCode === ROLES.DEALER && String(request.dealerId) !== String(req.user.id)) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden: You can only access your own request.' });
|
||||
}
|
||||
|
||||
res.json({ success: true, request });
|
||||
} catch (error) {
|
||||
@ -423,6 +455,9 @@ const actionSuccessMessage = (raw: string): string => {
|
||||
export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
if (req.user.roleCode === ROLES.DEALER) {
|
||||
return res.status(403).json({ success: false, message: 'Dealers cannot perform review actions.' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const rawAction = String(req.body.action || '').trim();
|
||||
@ -446,6 +481,23 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
const isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK);
|
||||
const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE);
|
||||
|
||||
const stageRoleMap: Record<string, string[]> = {
|
||||
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: [ROLES.ASM, ROLES.SUPER_ADMIN],
|
||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: [ROLES.DD_ZM, ROLES.RBM, ROLES.SUPER_ADMIN],
|
||||
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: [ROLES.ZBH, ROLES.SUPER_ADMIN],
|
||||
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: [ROLES.DD_LEAD, ROLES.SUPER_ADMIN],
|
||||
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: [ROLES.DD_HEAD, ROLES.SUPER_ADMIN],
|
||||
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: [ROLES.NBH, ROLES.SUPER_ADMIN],
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: [ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN]
|
||||
};
|
||||
const allowedRoles = stageRoleMap[sourceStage] || [ROLES.SUPER_ADMIN];
|
||||
if (!allowedRoles.includes(req.user.roleCode as any)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: `Role ${req.user.roleCode} cannot act at stage ${sourceStage}. Allowed: ${allowedRoles.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (isReject) {
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
|
||||
@ -498,6 +550,17 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(400).json({ success: false, message: 'Unsupported action. Use Approve, Reject, Send Back, or Revoke.' });
|
||||
}
|
||||
|
||||
const missingDocs = ConstitutionalWorkflowService.getMissingMandatoryDocuments(
|
||||
request.changeType,
|
||||
request.documents || []
|
||||
);
|
||||
if (missingDocs.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Mandatory documents missing for ${request.changeType}: ${missingDocs.join(', ')}. Upload all required documents before approval.`
|
||||
});
|
||||
}
|
||||
|
||||
const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW;
|
||||
if (isZmRbmJointStage && isApprove) {
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog } = db;
|
||||
const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog, Dealer } = db;
|
||||
import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS, OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
||||
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||
@ -167,29 +167,18 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
|
||||
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_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: NBH Approval (national)
|
||||
// Stage 6: 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)
|
||||
// Stage 7: Legal Clearance (national)
|
||||
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
|
||||
if (legal) {
|
||||
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`);
|
||||
|
||||
const evaluatorInfo = evaluators.map(e => ({
|
||||
@ -298,6 +287,21 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
message: `Relocation can only be requested for active outlets. Current outlet status: ${outlet.status}`
|
||||
});
|
||||
}
|
||||
const subjectDealerUser = await User.findByPk(outlet.dealerId, {
|
||||
attributes: ['id', 'status', 'dealerId']
|
||||
});
|
||||
const subjectDealerProfile = subjectDealerUser?.dealerId
|
||||
? await db.Dealer.findByPk(subjectDealerUser.dealerId, { attributes: ['id', 'status', 'onboardedAt'] })
|
||||
: null;
|
||||
const isDealerActive = String(subjectDealerUser?.status || '').toLowerCase() === 'active';
|
||||
const dealerProfileStatus = String(subjectDealerProfile?.status || '').toLowerCase();
|
||||
const isDealerProfileEligible = dealerProfileStatus === '' || dealerProfileStatus === 'active';
|
||||
if (!isDealerActive || !subjectDealerProfile?.onboardedAt || !isDealerProfileEligible) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Relocation can be initiated only for active onboarded dealers.'
|
||||
});
|
||||
}
|
||||
|
||||
const roleCode = req.user.roleCode as string;
|
||||
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) {
|
||||
@ -317,7 +321,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
const openExisting = await RelocationRequest.findOne({
|
||||
where: {
|
||||
outletId,
|
||||
status: { [Op.notIn]: ['Completed', 'Rejected'] }
|
||||
status: { [Op.notIn]: ['Completed', 'Rejected', 'Revoked'] }
|
||||
},
|
||||
attributes: ['id', 'requestId', 'status', 'currentStage']
|
||||
});
|
||||
@ -441,7 +445,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['fullName']
|
||||
attributes: ['fullName'],
|
||||
include: [
|
||||
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']],
|
||||
@ -483,12 +490,14 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user is assigned to any evaluator role for this outlet
|
||||
// ASM is tied to the dealer/outlet (Dealer.asmId); district.asmId kept as fallback for legacy data.
|
||||
const outletAsmId = (request as any).dealer?.dealerProfile?.asmId as string | undefined;
|
||||
const isAssigned =
|
||||
district.asmId === userId || // ASM
|
||||
region?.rbmId === userId || // RBM
|
||||
district.zmId === userId || // DD-ZM
|
||||
zone?.zbhId === userId; // ZBH
|
||||
outletAsmId === userId ||
|
||||
district.asmId === userId ||
|
||||
region?.rbmId === userId ||
|
||||
district.zmId === userId ||
|
||||
zone?.zbhId === userId;
|
||||
|
||||
return isAssigned;
|
||||
});
|
||||
@ -553,7 +562,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
{
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['fullName', 'email']
|
||||
attributes: ['fullName', 'email'],
|
||||
include: [
|
||||
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
model: Worknote,
|
||||
@ -575,8 +587,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
const region = district.region;
|
||||
const zone = district.zone;
|
||||
|
||||
const outletLevelAsmId = (request as any).dealer?.dealerProfile?.asmId ?? null;
|
||||
const asmReviewerId = outletLevelAsmId || district.asmId;
|
||||
const evaluatorRoles: any[] = [
|
||||
{ id: district.asmId, roleCode: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW },
|
||||
{ id: asmReviewerId, 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 }
|
||||
@ -593,10 +607,6 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
if (ddLead) evaluatorRoles.push({ id: ddLead.id, roleCode: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_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 });
|
||||
|
||||
// Get NBH (national) - Approval Stage
|
||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||
if (nbh) {
|
||||
@ -607,11 +617,6 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
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) {
|
||||
if (evaluator.id) {
|
||||
@ -718,11 +723,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
[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.DD_LEAD_REVIEW]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR,
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED
|
||||
};
|
||||
|
||||
const reverseStageFlow: Record<string, string> = {
|
||||
@ -731,10 +734,9 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
||||
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
[RELOCATION_STAGES.COMPLETED]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
};
|
||||
|
||||
/** Canonical order for progress % (must stay aligned with stageFlow chain, excluding terminal). */
|
||||
@ -744,10 +746,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||
RELOCATION_STAGES.ZBH_REVIEW,
|
||||
RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
||||
RELOCATION_STAGES.NBH_APPROVAL,
|
||||
RELOCATION_STAGES.LEGAL_CLEARANCE,
|
||||
RELOCATION_STAGES.NBH_CLEARANCE_EOR
|
||||
RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
];
|
||||
|
||||
/** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */
|
||||
@ -833,25 +833,6 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
progressPercentage: newProgress
|
||||
});
|
||||
|
||||
// 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR (header row + default checklist lines + doc map)
|
||||
if (newCurrentStage === RELOCATION_STAGES.NBH_CLEARANCE_EOR && normalizedAction === 'APPROVE') {
|
||||
try {
|
||||
await db.EorChecklist.findOrCreate({
|
||||
where: { relocationId: request.id },
|
||||
defaults: {
|
||||
status: 'In Progress',
|
||||
relocationId: request.id,
|
||||
applicationId: null
|
||||
}
|
||||
});
|
||||
const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
|
||||
await ensureRelocationEorChecklistSeeded(request.id);
|
||||
console.log(`[RelocationController] EOR Checklist initiated/synced for ${request.requestId}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to auto-initiate EOR checklist:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
|
||||
const shouldWriteWorknote =
|
||||
Boolean(String(reviewComments).trim()) &&
|
||||
|
||||
@ -10,7 +10,7 @@ const checkInterviews = async () => {
|
||||
}]
|
||||
});
|
||||
|
||||
console.log(`--- Interviews ---`);
|
||||
// console.log(`--- Interviews ---`);
|
||||
interviews.forEach((i: any) => {
|
||||
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
|
||||
i.participants?.forEach((p: any) => {
|
||||
|
||||
@ -6,6 +6,55 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
|
||||
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||
|
||||
export class ConstitutionalWorkflowService {
|
||||
private static normalizeDocLabel(input: string): string {
|
||||
return String(input || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private static extractUploadedDocumentLabels(documents: any[]): string[] {
|
||||
if (!Array.isArray(documents)) return [];
|
||||
return documents
|
||||
.flatMap((doc: any) => [
|
||||
doc?.documentType,
|
||||
doc?.type,
|
||||
doc?.name,
|
||||
doc?.title,
|
||||
doc?.label,
|
||||
doc?.fileName,
|
||||
typeof doc === 'string' ? doc : null
|
||||
])
|
||||
.filter((v: any) => typeof v === 'string' && v.trim().length > 0)
|
||||
.map((v: string) => this.normalizeDocLabel(v));
|
||||
}
|
||||
|
||||
static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] {
|
||||
const checklist = this.getDocumentChecklist(targetConstitution);
|
||||
const uploaded = this.extractUploadedDocumentLabels(documents);
|
||||
const hasToken = (aliases: string[]) => aliases.some((a) => uploaded.some((u) => u.includes(a)));
|
||||
|
||||
const aliasesByRequirement: Record<string, string[]> = {
|
||||
[DOCUMENT_TYPES.GST_CERTIFICATE]: ['gst'],
|
||||
[DOCUMENT_TYPES.PAN_CARD]: ['pan', 'firm pan'],
|
||||
[DOCUMENT_TYPES.AADHAAR]: ['aadhaar', 'kyc'],
|
||||
[DOCUMENT_TYPES.CANCELLED_CHECK]: ['cancelled cheque', 'canceled cheque', 'cancelled check', 'cancelled'],
|
||||
[DOCUMENT_TYPES.OTHER]: ['declaration', 'authorization', 'authorisation'],
|
||||
[DOCUMENT_TYPES.PARTNERSHIP_DEED]: ['partnership deed', 'partnership agreement'],
|
||||
[DOCUMENT_TYPES.FIRM_REGISTRATION]: ['firm registration'],
|
||||
[DOCUMENT_TYPES.LLP_AGREEMENT]: ['llp agreement'],
|
||||
[DOCUMENT_TYPES.INCORPORATION_CERTIFICATE]: ['incorporation', 'coi'],
|
||||
[DOCUMENT_TYPES.MOA]: ['moa'],
|
||||
[DOCUMENT_TYPES.AOA]: ['aoa'],
|
||||
'Business Purchase Agreement (BPA)': ['business purchase agreement', 'bpa']
|
||||
};
|
||||
|
||||
return checklist.filter((required) => {
|
||||
const tokens = aliasesByRequirement[required] || [this.normalizeDocLabel(required)];
|
||||
return !hasToken(tokens.map((t) => this.normalizeDocLabel(t)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a constitutional change request to a new stage
|
||||
*/
|
||||
@ -118,7 +167,8 @@ export class ConstitutionalWorkflowService {
|
||||
actionUserFullName: userFullName || 'System',
|
||||
action: action || `Moved to ${targetStage}`,
|
||||
remarks: remarkText,
|
||||
link: `${portalBase}/constitutional-change/${request.id}`
|
||||
link: `${portalBase}/constitutional-change/${request.id}`,
|
||||
changeType: request.changeType
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -129,7 +129,6 @@ export class ParticipantService {
|
||||
// 2. National roles
|
||||
const nationalRoles = [
|
||||
ROLES.DD_LEAD,
|
||||
ROLES.DD_HEAD,
|
||||
ROLES.NBH,
|
||||
ROLES.CCO,
|
||||
ROLES.CEO,
|
||||
@ -347,6 +346,9 @@ export class ParticipantService {
|
||||
const outlet = (relocation as any).outlet;
|
||||
if (outlet && outlet.district) {
|
||||
const district = outlet.district;
|
||||
// Canonical ASM for relocation visibility/actions is district.asmId (see relocation.controller getRequests / evaluators).
|
||||
// Dealer.asmId may differ or be unset while district.asmId is set.
|
||||
if (district.asmId) participantIds.add(district.asmId);
|
||||
if (relocation.dealerId) {
|
||||
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
|
||||
if (dealerUser?.dealerId) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { RelocationRequest, AuditLog, User } = db;
|
||||
const { RelocationRequest, AuditLog, User, RequestParticipant, Outlet } = db;
|
||||
import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||
@ -131,10 +131,8 @@ export class RelocationWorkflowService {
|
||||
[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
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN
|
||||
};
|
||||
|
||||
const requiredRole = stageMapping[request.currentStage];
|
||||
@ -143,10 +141,65 @@ export class RelocationWorkflowService {
|
||||
// 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)
|
||||
// Stage-specific participant assignment enforcement: actor must be mapped on this request.
|
||||
const participant = await RequestParticipant.findOne({
|
||||
where: {
|
||||
requestId: request.id,
|
||||
requestType: REQUEST_TYPES.RELOCATION,
|
||||
userId: user.id
|
||||
},
|
||||
attributes: ['id']
|
||||
});
|
||||
if (participant) return true;
|
||||
const anyParticipant = await RequestParticipant.findOne({
|
||||
where: { requestId: request.id, requestType: REQUEST_TYPES.RELOCATION },
|
||||
attributes: ['id']
|
||||
});
|
||||
// Backward compatibility for legacy requests created before participant auto-assignment.
|
||||
if (!anyParticipant) return true;
|
||||
|
||||
return true;
|
||||
// Match relocation.controller getRequests: territory actors must act even if RequestParticipant
|
||||
// omitted them (e.g. ASM was taken only from Dealer.asmId while list uses district.asmId).
|
||||
return await RelocationWorkflowService.userMatchesRelocationOutletHierarchy(request.id, user);
|
||||
}
|
||||
|
||||
/** Same outlet hierarchy checks as internal-user filter on relocation list (ASM/RBM/DD-ZM/ZBH). */
|
||||
static async userMatchesRelocationOutletHierarchy(requestId: string, user: any): Promise<boolean> {
|
||||
const row = await RelocationRequest.findByPk(requestId, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'dealer',
|
||||
attributes: ['id'],
|
||||
include: [{ model: db.Dealer, as: 'dealerProfile', attributes: ['asmId'] }]
|
||||
},
|
||||
{
|
||||
model: Outlet,
|
||||
as: 'outlet',
|
||||
include: [
|
||||
{
|
||||
model: db.District,
|
||||
as: 'district',
|
||||
include: [
|
||||
{ model: db.Region, as: 'region' },
|
||||
{ model: db.Zone, as: 'zone' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!row) return false;
|
||||
const outlet = (row as any).outlet;
|
||||
const district = outlet?.district;
|
||||
if (!district) return false;
|
||||
const uid = user.id;
|
||||
const rc = user.roleCode;
|
||||
const outletLevelAsmId = (row as any).dealer?.dealerProfile?.asmId ?? null;
|
||||
if (rc === ROLES.ASM && (outletLevelAsmId === uid || district.asmId === uid)) return true;
|
||||
if (rc === ROLES.RBM && district.region?.rbmId === uid) return true;
|
||||
if (rc === ROLES.DD_ZM && district.zmId === uid) return true;
|
||||
if (rc === ROLES.ZBH && district.zone?.zbhId === uid) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,7 +123,7 @@ async function prospectLogin(phone) {
|
||||
|
||||
async function mockUploadDocument(appId, token, docType) {
|
||||
const formData = new FormData();
|
||||
const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-24 19-16-05.png');
|
||||
const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG');
|
||||
const blob = new Blob([fileBuffer], { type: 'image/png' });
|
||||
formData.append('file', blob, 'screenshot.png');
|
||||
formData.append('documentType', docType);
|
||||
|
||||
@ -63,12 +63,7 @@ async function run() {
|
||||
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'}`);
|
||||
|
||||
// We'll just look at the DB for now to see if NBH exists for the approval stage.
|
||||
// Check NBH
|
||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||
console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`);
|
||||
@ -76,9 +71,7 @@ async function run() {
|
||||
// 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}`));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user