358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
import db from '../database/models/index.js';
|
|
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, DOCUMENT_TYPES, REQUEST_TYPES } from '../common/config/constants.js';
|
|
import { NotificationService } from './NotificationService.js';
|
|
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
|
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
|
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
|
import { getFrontendBaseUrl } from '../common/utils/frontendUrl.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));
|
|
}
|
|
|
|
private static aliasesByRequirement(): Record<string, string[]> {
|
|
return {
|
|
[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']
|
|
};
|
|
}
|
|
|
|
private static docMatchesRequirement(doc: any, required: string): boolean {
|
|
const aliases = this.aliasesByRequirement();
|
|
const tokens = aliases[required] || [this.normalizeDocLabel(required)];
|
|
const labels = [
|
|
doc?.documentType,
|
|
doc?.type,
|
|
doc?.name,
|
|
doc?.title,
|
|
doc?.label,
|
|
doc?.fileName
|
|
]
|
|
.filter((v: any) => typeof v === 'string' && v.trim().length > 0)
|
|
.map((v: string) => this.normalizeDocLabel(v));
|
|
return tokens.some((token) => labels.some((label) => label.includes(token) || token.includes(label)));
|
|
}
|
|
|
|
/** Readiness aligned with relocation: uploaded vs verified vs rejected. */
|
|
static getDocumentReadiness(targetConstitution: string, documents: any[]) {
|
|
const checklist = this.getDocumentChecklist(targetConstitution);
|
|
const docs = Array.isArray(documents) ? documents : [];
|
|
const missingUploads: string[] = [];
|
|
const pendingVerification: string[] = [];
|
|
|
|
for (const required of checklist) {
|
|
const matched = docs.filter((d) => this.docMatchesRequirement(d, required));
|
|
if (!matched.length) {
|
|
missingUploads.push(required);
|
|
continue;
|
|
}
|
|
const hasVerified = matched.some((d) => String(d.status || '').toLowerCase() === 'verified');
|
|
if (!hasVerified) {
|
|
pendingVerification.push(required);
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalRequired: checklist.length,
|
|
missingUploads,
|
|
pendingVerification
|
|
};
|
|
}
|
|
|
|
static getMissingMandatoryDocuments(targetConstitution: string, documents: any[]): string[] {
|
|
const readiness = this.getDocumentReadiness(targetConstitution, documents);
|
|
return [...readiness.missingUploads, ...readiness.pendingVerification];
|
|
}
|
|
|
|
/**
|
|
* Transitions a constitutional change request to a new stage
|
|
*/
|
|
static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) {
|
|
const { action, status, remarks, userFullName, metadata = {} } = options;
|
|
const sourceStage = request.currentStage;
|
|
const actionLower = String(action || '').toLowerCase();
|
|
|
|
// Audit Action resolution
|
|
// 3. Create Audit Log using standardized mapper
|
|
const { actionType } = metadata;
|
|
const resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.CONSTITUTIONAL);
|
|
|
|
const updatedTimeline = [
|
|
...(request.timeline || []),
|
|
{
|
|
stage: sourceStage, // Correctly Associate remark with the stage where action happened
|
|
targetStage: targetStage,
|
|
timestamp: new Date(),
|
|
user: userFullName || 'System',
|
|
action: action || `Moved to ${targetStage}`,
|
|
remarks: remarks || ''
|
|
}
|
|
];
|
|
|
|
const resolvedStatus =
|
|
status ||
|
|
(targetStage === CONSTITUTIONAL_STAGES.COMPLETED
|
|
? 'Completed'
|
|
: targetStage === CONSTITUTIONAL_STAGES.REJECTED
|
|
? 'Rejected'
|
|
: targetStage === CONSTITUTIONAL_STAGES.REVOKED
|
|
? 'Revoked'
|
|
: targetStage);
|
|
|
|
const updateData: any = {
|
|
currentStage: targetStage,
|
|
status: resolvedStatus,
|
|
progressPercentage: this.calculateProgress(targetStage),
|
|
timeline: updatedTimeline,
|
|
updatedAt: new Date()
|
|
};
|
|
|
|
// GAP CLOSURE: Reset joint approval metadata if sent back to or before ZM/RBM review stage
|
|
// Controller uses "Sent back to …" (past tense). `sent` does not include substring `send`, so the old check never fired and joint-approval metadata was not cleared on send-back from ZBH.
|
|
const isSendBack =
|
|
/\b(send|sent)\s*back\b/i.test(String(action || '')) ||
|
|
(actionLower.includes('send') && actionLower.includes('back'));
|
|
const resetTargetStages = [CONSTITUTIONAL_STAGES.ASM_REVIEW, CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW];
|
|
|
|
|
|
if (isSendBack && resetTargetStages.includes(targetStage as any)) {
|
|
const metadata = { ...(request.metadata || {}) };
|
|
if (metadata.jointApprovals?.zmRbm) {
|
|
console.log(`[ConstitutionalWorkflowService] Resetting joint approval metadata for ${request.requestId} on send-back to ${targetStage}`);
|
|
delete metadata.jointApprovals.zmRbm;
|
|
updateData.metadata = metadata;
|
|
// Explicitly inform Sequelize that metadata has changed
|
|
request.changed('metadata', true);
|
|
}
|
|
}
|
|
|
|
|
|
await request.update(updateData);
|
|
|
|
await syncSlaOnStageTransition({
|
|
entityType: 'constitutional',
|
|
entityId: request.id,
|
|
fromStage: sourceStage,
|
|
toStage: targetStage
|
|
});
|
|
|
|
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) {
|
|
await ConstitutionalWorkflowService.syncDealerProfileConstitution(request, userId);
|
|
}
|
|
|
|
// Audit Log
|
|
await db.ConstitutionalAudit.create({
|
|
userId,
|
|
constitutionalChangeId: request.id,
|
|
action: formatOffboardingAction(resolvedAuditAction),
|
|
remarks: remarks || '',
|
|
details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) }
|
|
});
|
|
|
|
// 5. Create Worknote for standardized communication trail (always when we have a user — empty modal comments still need a row)
|
|
if (userId) {
|
|
try {
|
|
const body = String(remarks ?? '').trim() || 'No remarks entered.';
|
|
await writeWorkflowActivityWorknote({
|
|
requestId: request.id,
|
|
requestType: 'constitutional',
|
|
userId: userId,
|
|
noteText: `${formatOffboardingAction(action || '')}: ${body}`,
|
|
noteType: isSendBack ? 'workflow' : 'internal'
|
|
});
|
|
} catch (wnErr) {
|
|
console.error('[ConstitutionalWorkflowService] failed to write worknote:', wnErr);
|
|
}
|
|
}
|
|
|
|
const dealerUser = await db.User.findByPk(request.dealerId, { attributes: ['id', 'email', 'fullName'] });
|
|
if (dealerUser?.email) {
|
|
const portalBase = getFrontendBaseUrl();
|
|
const remarkText = String(remarks ?? '').trim() || 'N/A';
|
|
|
|
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
|
|
|
await notifyStakeholdersOnTransition(
|
|
request.id,
|
|
REQUEST_TYPES.CONSTITUTIONAL,
|
|
targetStage,
|
|
{
|
|
code: request.requestId,
|
|
dealerName: dealerUser.fullName || 'Dealer',
|
|
dealerId: dealerUser.id,
|
|
actionUserFullName: userFullName || 'System',
|
|
action: action || `Moved to ${targetStage}`,
|
|
remarks: remarkText,
|
|
link: `${portalBase}/constitutional-change/${request.id}`,
|
|
changeType: request.changeType
|
|
}
|
|
);
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
/**
|
|
* SRS §12.2 — upon final approval, dealer master constitution must reflect the approved change.
|
|
* Logs to constitutional audit trail and Work Notes (workflow) — important compliance record.
|
|
*/
|
|
static async syncDealerProfileConstitution(request: any, actingUserId: string | null): Promise<void> {
|
|
const nextConstitution = mapConstitutionalChangeTypeToDealerProfile(request.changeType);
|
|
if (!nextConstitution) return;
|
|
|
|
try {
|
|
const user = await db.User.findByPk(request.dealerId, {
|
|
include: [{ model: db.Dealer, as: 'dealerProfile' }]
|
|
});
|
|
const dealer = (user as any)?.dealerProfile;
|
|
if (!dealer) {
|
|
console.warn('[ConstitutionalWorkflowService] Completed CC request but no dealer profile for user', request.dealerId);
|
|
return;
|
|
}
|
|
|
|
const previous = String(dealer.constitutionType || '').trim();
|
|
if (previous === nextConstitution) {
|
|
return;
|
|
}
|
|
|
|
await dealer.update({ constitutionType: nextConstitution });
|
|
|
|
const auditRemarks = `Dealer master constitution updated from "${previous}" to "${nextConstitution}" via request ${request.requestId}.`;
|
|
const auditDetails = {
|
|
dealerProfileId: dealer.id,
|
|
dealerUserId: request.dealerId,
|
|
previousConstitution: previous,
|
|
newConstitution: nextConstitution,
|
|
requestId: request.requestId,
|
|
changeType: request.changeType,
|
|
stage: CONSTITUTIONAL_STAGES.COMPLETED
|
|
};
|
|
|
|
try {
|
|
await db.ConstitutionalAudit.create({
|
|
userId: actingUserId || null,
|
|
constitutionalChangeId: request.id,
|
|
action: formatOffboardingAction(AUDIT_ACTIONS.UPDATED),
|
|
remarks: auditRemarks,
|
|
details: auditDetails
|
|
});
|
|
} catch (auditErr) {
|
|
console.error('[ConstitutionalWorkflowService] Dealer sync audit log failed:', auditErr);
|
|
}
|
|
|
|
const worknoteUserId = actingUserId || String(request.dealerId);
|
|
if (worknoteUserId) {
|
|
try {
|
|
await writeWorkflowActivityWorknote({
|
|
requestId: request.id,
|
|
requestType: 'constitutional',
|
|
userId: worknoteUserId,
|
|
noteText: `Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`,
|
|
noteType: 'workflow'
|
|
});
|
|
} catch (wnErr) {
|
|
console.error('[ConstitutionalWorkflowService] dealer sync worknote failed:', wnErr);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[ConstitutionalWorkflowService] Failed to update dealer constitutionType:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates progress percentage based on stage
|
|
*/
|
|
static calculateProgress(stage: string): number {
|
|
const progress: Record<string, number> = {
|
|
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: 15,
|
|
|
|
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 30,
|
|
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: 45,
|
|
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: 60,
|
|
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: 75,
|
|
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85,
|
|
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95,
|
|
[CONSTITUTIONAL_STAGES.COMPLETED]: 100,
|
|
[CONSTITUTIONAL_STAGES.REJECTED]: 100,
|
|
[CONSTITUTIONAL_STAGES.REVOKED]: 100
|
|
};
|
|
return progress[stage] || 0;
|
|
}
|
|
|
|
/**
|
|
* Returns mandatory document checklist based on target constitution
|
|
* Per SRS Section 12.2.4
|
|
*/
|
|
static getDocumentChecklist(targetConstitution: string): string[] {
|
|
const commonDocs = [
|
|
DOCUMENT_TYPES.GST_CERTIFICATE,
|
|
DOCUMENT_TYPES.PAN_CARD,
|
|
DOCUMENT_TYPES.AADHAAR,
|
|
DOCUMENT_TYPES.CANCELLED_CHECK,
|
|
DOCUMENT_TYPES.OTHER // Declaration/Authorization Letter
|
|
];
|
|
|
|
if (targetConstitution.includes('Partnership')) {
|
|
return [
|
|
...commonDocs,
|
|
DOCUMENT_TYPES.PARTNERSHIP_DEED,
|
|
'Business Purchase Agreement (BPA)',
|
|
DOCUMENT_TYPES.FIRM_REGISTRATION
|
|
];
|
|
}
|
|
|
|
if (targetConstitution.includes('LLP')) {
|
|
return [
|
|
...commonDocs,
|
|
DOCUMENT_TYPES.INCORPORATION_CERTIFICATE,
|
|
'Business Purchase Agreement (BPA)',
|
|
DOCUMENT_TYPES.LLP_AGREEMENT
|
|
];
|
|
}
|
|
|
|
if (targetConstitution.includes('Private Limited')) {
|
|
return [
|
|
...commonDocs,
|
|
DOCUMENT_TYPES.MOA,
|
|
DOCUMENT_TYPES.AOA,
|
|
DOCUMENT_TYPES.INCORPORATION_CERTIFICATE,
|
|
'Business Purchase Agreement (BPA)'
|
|
];
|
|
}
|
|
|
|
// Default/Proprietorship
|
|
return commonDocs;
|
|
}
|
|
}
|