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
|
* 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.
|
* 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
|
* 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 = {
|
export const CONSTITUTIONAL_CHANGE_TYPES = {
|
||||||
PROPRIETORSHIP: 'Proprietorship',
|
PROPRIETORSHIP: 'Proprietorship',
|
||||||
PARTNERSHIP: 'Partnership',
|
PARTNERSHIP: 'Partnership',
|
||||||
LLP_CONVERSION: 'LLP Conversion',
|
|
||||||
LLP: 'LLP',
|
LLP: 'LLP',
|
||||||
PRIVATE_LIMITED: 'Private Limited',
|
PRIVATE_LIMITED: 'Private Limited'
|
||||||
COMPANY_FORMATION: 'Company Formation',
|
|
||||||
OWNERSHIP_TRANSFER: 'Ownership Transfer',
|
|
||||||
PARTNERSHIP_CHANGE: 'Partnership Change',
|
|
||||||
DIRECTOR_CHANGE: 'Director Change'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
|
/** 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',
|
DD_ZM_REVIEW: 'DD ZM Review',
|
||||||
ZBH_REVIEW: 'ZBH Review',
|
ZBH_REVIEW: 'ZBH Review',
|
||||||
DD_LEAD_REVIEW: 'DD Lead Review',
|
DD_LEAD_REVIEW: 'DD Lead Review',
|
||||||
DD_HEAD_APPROVAL: 'DD Head Approval',
|
|
||||||
NBH_APPROVAL: 'NBH Approval',
|
NBH_APPROVAL: 'NBH Approval',
|
||||||
LEGAL_CLEARANCE: 'Legal Clearance',
|
LEGAL_CLEARANCE: 'Legal Clearance',
|
||||||
NBH_CLEARANCE_EOR: 'NBH Clearance with EOR',
|
|
||||||
COMPLETED: 'Completed',
|
COMPLETED: 'Completed',
|
||||||
REJECTED: 'Rejected'
|
REJECTED: 'Rejected'
|
||||||
} as const;
|
} as const;
|
||||||
@ -570,7 +563,7 @@ export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITU
|
|||||||
export const STAGES_MAP = {
|
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'],
|
'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'],
|
'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'],
|
'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']
|
'TERMINATION': ['Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'NBH Evaluation', 'SCN', 'Personal Hearing', 'Completed']
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -25,30 +25,15 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
|
|||||||
) {
|
) {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
|
return CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED;
|
||||||
}
|
}
|
||||||
if (compact.includes('llp') && compact.includes('conversion')) {
|
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION;
|
|
||||||
}
|
|
||||||
if (compact.includes('llp')) {
|
if (compact.includes('llp')) {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||||
}
|
}
|
||||||
if (compact.includes('partnership') && compact.includes('change')) {
|
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE;
|
|
||||||
}
|
|
||||||
if (compact.includes('partnership')) {
|
if (compact.includes('partnership')) {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
|
return CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP;
|
||||||
}
|
}
|
||||||
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
|
if (compact.includes('proprietorship') || compact === 'sole proprietorship') {
|
||||||
return CONSTITUTIONAL_CHANGE_TYPES.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());
|
const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
|
||||||
return exact || null;
|
return exact || null;
|
||||||
}
|
}
|
||||||
@ -61,8 +46,6 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
|||||||
const t = String(changeType || '').trim();
|
const t = String(changeType || '').trim();
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
|
|
||||||
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
|
||||||
|
|
||||||
const structureTargets = [
|
const structureTargets = [
|
||||||
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
||||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
||||||
@ -71,13 +54,5 @@ export function mapConstitutionalChangeTypeToDealerProfile(changeType: string):
|
|||||||
];
|
];
|
||||||
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t;
|
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;
|
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.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_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_LEAD_REVIEW,
|
||||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
[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;
|
return flow[currentStage] || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
ROLES
|
ROLES
|
||||||
} from '../config/constants.js';
|
} 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';
|
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
@ -150,7 +150,12 @@ export async function notifyRelocationSubmittedEmails(
|
|||||||
const outlet = await Outlet.findByPk(request.outletId, {
|
const outlet = await Outlet.findByPk(request.outletId, {
|
||||||
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
|
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;
|
if (!asmId) return;
|
||||||
|
|
||||||
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
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 Lead Review': [ROLES.DD_LEAD],
|
||||||
'DD Head': [ROLES.DD_HEAD],
|
'DD Head': [ROLES.DD_HEAD],
|
||||||
'DD Head Review': [ROLES.DD_HEAD],
|
'DD Head Review': [ROLES.DD_HEAD],
|
||||||
'DD Head Approval': [ROLES.DD_HEAD],
|
|
||||||
'NBH': [ROLES.NBH],
|
'NBH': [ROLES.NBH],
|
||||||
'NBH Approval': [ROLES.NBH],
|
'NBH Approval': [ROLES.NBH],
|
||||||
'NBH Evaluation': [ROLES.NBH],
|
'NBH Evaluation': [ROLES.NBH],
|
||||||
@ -238,7 +242,6 @@ export async function resolveNextActors(requestId: string, requestType: string,
|
|||||||
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
'Architecture Document Upload': [ROLES.ARCHITECTURE],
|
||||||
|
|
||||||
// --- Relocation/Constitutional Specific ---
|
// --- Relocation/Constitutional Specific ---
|
||||||
'NBH Clearance with EOR': [ROLES.NBH],
|
|
||||||
'Submitted': [ROLES.ASM],
|
'Submitted': [ROLES.ASM],
|
||||||
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
|
'ZM/RBM Review': [ROLES.DD_ZM, ROLES.RBM],
|
||||||
|
|
||||||
@ -315,6 +318,7 @@ export async function notifyStakeholdersOnTransition(
|
|||||||
action: string;
|
action: string;
|
||||||
remarks: string;
|
remarks: string;
|
||||||
link: string;
|
link: string;
|
||||||
|
changeType?: string;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -412,7 +416,7 @@ export async function notifyStakeholdersOnTransition(
|
|||||||
// Override for Constitutional Change Completion
|
// Override for Constitutional Change Completion
|
||||||
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
|
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && requestType === REQUEST_TYPES.CONSTITUTIONAL) {
|
||||||
templateCode = 'CONSTITUTIONAL_CHANGE_APPROVED';
|
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
|
// 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';
|
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
|
||||||
|
|
||||||
export interface ConstitutionalChangeAttributes {
|
export interface ConstitutionalChangeAttributes {
|
||||||
@ -101,7 +101,18 @@ export default (sequelize: Sequelize) => {
|
|||||||
{ fields: ['requestId'] },
|
{ fields: ['requestId'] },
|
||||||
{ fields: ['outletId'] },
|
{ fields: ['outletId'] },
|
||||||
{ fields: ['dealerId'] },
|
{ 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>(
|
const STRUCTURE_TARGET_VALUES = new Set<string>(
|
||||||
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as 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 resolveConstitutionalUuid = async (id: string) => {
|
||||||
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'constitutional');
|
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 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 = {
|
const metadata = {
|
||||||
newPartnersDetails,
|
newPartnersDetails,
|
||||||
shareholdingPattern,
|
shareholdingPattern,
|
||||||
@ -215,6 +240,15 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
await ParticipantService.assignConstitutionalParticipants(request.id);
|
await ParticipantService.assignConstitutionalParticipants(request.id);
|
||||||
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
|
const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer';
|
||||||
@ -222,12 +256,7 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error assigning participants or sending constitutional submit emails:', 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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit constitutional change error:', error);
|
console.error('Submit constitutional change error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error submitting request' });
|
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 (!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 });
|
res.json({ success: true, request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -423,6 +455,9 @@ const actionSuccessMessage = (raw: string): string => {
|
|||||||
export const takeAction = async (req: AuthRequest, res: Response) => {
|
export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
|
if (req.user.roleCode === ROLES.DEALER) {
|
||||||
|
return res.status(403).json({ success: false, message: 'Dealers cannot perform review actions.' });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const rawAction = String(req.body.action || '').trim();
|
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 isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK);
|
||||||
const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE);
|
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) {
|
if (isReject) {
|
||||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
|
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.' });
|
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;
|
const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW;
|
||||||
if (isZmRbmJointStage && isApprove) {
|
if (isZmRbmJointStage && isApprove) {
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Response } from 'express';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
|
import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js';
|
||||||
import { NomenclatureService } from '../../common/utils/nomenclature.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 { 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 { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.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 });
|
evaluators.push({ id: ddLead.id, role: 'DD Lead', stage: RELOCATION_STAGES.DD_LEAD_REVIEW });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 6: DD Head (national)
|
// Stage 6: NBH Approval (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)
|
|
||||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||||
if (nbh) {
|
if (nbh) {
|
||||||
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_APPROVAL });
|
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' } });
|
const legal = await User.findOne({ where: { roleCode: 'Legal Admin', status: 'active' } });
|
||||||
if (legal) {
|
if (legal) {
|
||||||
evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
|
evaluators.push({ id: legal.id, role: 'Legal', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 9: NBH Clearance with EOR (national)
|
|
||||||
if (nbh) {
|
|
||||||
evaluators.push({ id: nbh.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`);
|
console.log(`[debug] Found ${evaluators.length} evaluators for relocation request`);
|
||||||
|
|
||||||
const evaluatorInfo = evaluators.map(e => ({
|
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}`
|
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;
|
const roleCode = req.user.roleCode as string;
|
||||||
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) {
|
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({
|
const openExisting = await RelocationRequest.findOne({
|
||||||
where: {
|
where: {
|
||||||
outletId,
|
outletId,
|
||||||
status: { [Op.notIn]: ['Completed', 'Rejected'] }
|
status: { [Op.notIn]: ['Completed', 'Rejected', 'Revoked'] }
|
||||||
},
|
},
|
||||||
attributes: ['id', 'requestId', 'status', 'currentStage']
|
attributes: ['id', 'requestId', 'status', 'currentStage']
|
||||||
});
|
});
|
||||||
@ -441,7 +445,10 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
attributes: ['fullName']
|
attributes: ['fullName'],
|
||||||
|
include: [
|
||||||
|
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
@ -483,12 +490,14 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
return true;
|
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 =
|
const isAssigned =
|
||||||
district.asmId === userId || // ASM
|
outletAsmId === userId ||
|
||||||
region?.rbmId === userId || // RBM
|
district.asmId === userId ||
|
||||||
district.zmId === userId || // DD-ZM
|
region?.rbmId === userId ||
|
||||||
zone?.zbhId === userId; // ZBH
|
district.zmId === userId ||
|
||||||
|
zone?.zbhId === userId;
|
||||||
|
|
||||||
return isAssigned;
|
return isAssigned;
|
||||||
});
|
});
|
||||||
@ -553,7 +562,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
|||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
attributes: ['fullName', 'email']
|
attributes: ['fullName', 'email'],
|
||||||
|
include: [
|
||||||
|
{ model: Dealer, as: 'dealerProfile', attributes: ['asmId'] }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Worknote,
|
model: Worknote,
|
||||||
@ -575,8 +587,10 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
|||||||
const region = district.region;
|
const region = district.region;
|
||||||
const zone = district.zone;
|
const zone = district.zone;
|
||||||
|
|
||||||
|
const outletLevelAsmId = (request as any).dealer?.dealerProfile?.asmId ?? null;
|
||||||
|
const asmReviewerId = outletLevelAsmId || district.asmId;
|
||||||
const evaluatorRoles: any[] = [
|
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: region?.rbmId, roleCode: 'RBM', stage: RELOCATION_STAGES.RBM_REVIEW },
|
||||||
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
|
{ id: district.zmId, roleCode: 'DD-ZM', stage: RELOCATION_STAGES.DD_ZM_REVIEW },
|
||||||
{ id: zone?.zbhId, roleCode: 'ZBH', stage: RELOCATION_STAGES.ZBH_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 });
|
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
|
// Get NBH (national) - Approval Stage
|
||||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||||
if (nbh) {
|
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' } });
|
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 });
|
if (legal) evaluatorRoles.push({ id: legal.id, roleCode: 'Legal Admin', stage: RELOCATION_STAGES.LEGAL_CLEARANCE });
|
||||||
|
|
||||||
// Get NBH (national) - Final Clearance Stage
|
|
||||||
if (nbh) {
|
|
||||||
evaluatorRoles.push({ id: nbh.id, roleCode: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE_EOR });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch user details for each evaluator
|
// Fetch user details for each evaluator
|
||||||
for (const evaluator of evaluatorRoles) {
|
for (const evaluator of evaluatorRoles) {
|
||||||
if (evaluator.id) {
|
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.RBM_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_LEAD_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.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
|
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.LEGAL_CLEARANCE,
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_CLEARANCE_EOR,
|
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.COMPLETED
|
||||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const reverseStageFlow: Record<string, string> = {
|
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.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_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_LEAD_REVIEW,
|
||||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
[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). */
|
/** 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.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.NBH_APPROVAL,
|
RELOCATION_STAGES.NBH_APPROVAL,
|
||||||
RELOCATION_STAGES.LEGAL_CLEARANCE,
|
RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||||
RELOCATION_STAGES.NBH_CLEARANCE_EOR
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */
|
/** ~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
|
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
|
// 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
|
||||||
const shouldWriteWorknote =
|
const shouldWriteWorknote =
|
||||||
Boolean(String(reviewComments).trim()) &&
|
Boolean(String(reviewComments).trim()) &&
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const checkInterviews = async () => {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`--- Interviews ---`);
|
// console.log(`--- Interviews ---`);
|
||||||
interviews.forEach((i: any) => {
|
interviews.forEach((i: any) => {
|
||||||
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
|
console.log(`ID: ${i.id}, AppUUID: ${i.applicationId}, Level: ${i.level}, Status: ${i.status}`);
|
||||||
i.participants?.forEach((p: any) => {
|
i.participants?.forEach((p: any) => {
|
||||||
|
|||||||
@ -6,6 +6,55 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
|
|||||||
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||||
|
|
||||||
export class ConstitutionalWorkflowService {
|
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
|
* Transitions a constitutional change request to a new stage
|
||||||
*/
|
*/
|
||||||
@ -118,7 +167,8 @@ export class ConstitutionalWorkflowService {
|
|||||||
actionUserFullName: userFullName || 'System',
|
actionUserFullName: userFullName || 'System',
|
||||||
action: action || `Moved to ${targetStage}`,
|
action: action || `Moved to ${targetStage}`,
|
||||||
remarks: remarkText,
|
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
|
// 2. National roles
|
||||||
const nationalRoles = [
|
const nationalRoles = [
|
||||||
ROLES.DD_LEAD,
|
ROLES.DD_LEAD,
|
||||||
ROLES.DD_HEAD,
|
|
||||||
ROLES.NBH,
|
ROLES.NBH,
|
||||||
ROLES.CCO,
|
ROLES.CCO,
|
||||||
ROLES.CEO,
|
ROLES.CEO,
|
||||||
@ -347,6 +346,9 @@ export class ParticipantService {
|
|||||||
const outlet = (relocation as any).outlet;
|
const outlet = (relocation as any).outlet;
|
||||||
if (outlet && outlet.district) {
|
if (outlet && outlet.district) {
|
||||||
const district = 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) {
|
if (relocation.dealerId) {
|
||||||
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
|
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
|
||||||
if (dealerUser?.dealerId) {
|
if (dealerUser?.dealerId) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import db from '../database/models/index.js';
|
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 { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.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.DD_ZM_REVIEW]: ROLES.DD_ZM,
|
||||||
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
|
[RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH,
|
||||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
|
[RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD,
|
||||||
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: ROLES.DD_HEAD,
|
|
||||||
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
|
[RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH,
|
||||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN,
|
[RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN
|
||||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: ROLES.NBH
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requiredRole = stageMapping[request.currentStage];
|
const requiredRole = stageMapping[request.currentStage];
|
||||||
@ -143,10 +141,65 @@ export class RelocationWorkflowService {
|
|||||||
// Role-based check
|
// Role-based check
|
||||||
if (user.roleCode !== requiredRole) return false;
|
if (user.roleCode !== requiredRole) return false;
|
||||||
|
|
||||||
// Optional: Hierarchy check
|
// Stage-specific participant assignment enforcement: actor must be mapped on this request.
|
||||||
// We could verify if the user is the SPECIFIC person assigned in participants
|
const participant = await RequestParticipant.findOne({
|
||||||
// but for now, any user with the correct role can act (consistent with simple RBAC)
|
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) {
|
async function mockUploadDocument(appId, token, docType) {
|
||||||
const formData = new FormData();
|
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' });
|
const blob = new Blob([fileBuffer], { type: 'image/png' });
|
||||||
formData.append('file', blob, 'screenshot.png');
|
formData.append('file', blob, 'screenshot.png');
|
||||||
formData.append('documentType', docType);
|
formData.append('documentType', docType);
|
||||||
|
|||||||
@ -63,12 +63,7 @@ async function run() {
|
|||||||
console.log(`Created Test Request: ${request.requestId}`);
|
console.log(`Created Test Request: ${request.requestId}`);
|
||||||
|
|
||||||
// Now call the logic that calculates participants (similar to getRequestById)
|
// 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.
|
// We'll just look at the DB for now to see if NBH exists for the approval stage.
|
||||||
|
|
||||||
// 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
|
// Check NBH
|
||||||
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
const nbh = await User.findOne({ where: { roleCode: 'NBH', status: 'active' } });
|
||||||
console.log(`NBH found in DB: ${nbh ? nbh.fullName : 'NO'}`);
|
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)
|
// Verify Evaluator Assignment Logic (Re-running a piece of it)
|
||||||
const evaluators = [];
|
const evaluators = [];
|
||||||
evaluators.push({ id: outlet.district.asmId, role: 'ASM', stage: RELOCATION_STAGES.ASM_REVIEW });
|
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_APPROVAL });
|
||||||
evaluators.push({ id: nbh?.id, role: 'NBH', stage: RELOCATION_STAGES.NBH_CLEARANCE });
|
|
||||||
|
|
||||||
console.log('Expected Evaluators for this request:');
|
console.log('Expected Evaluators for this request:');
|
||||||
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));
|
evaluators.forEach(e => console.log(`- Role: ${e.role}, Stage: ${e.stage}, ID: ${e.id}`));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user