major chnges made in all modules lin worknote nline history audit log enhncement progress bar improvement aross differnt modules
This commit is contained in:
parent
7e1e43bef3
commit
86f2323641
@ -117,6 +117,62 @@ export const APPLICATION_STATUS = {
|
||||
RETURNED_TO_FDD: 'Returned to FDD'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Maps `Application.overallStatus` → `Application.currentStage` (Postgres enum = APPLICATION_STAGES only).
|
||||
* Progress milestones like "Shortlist" / "LOI Issue" live in ApplicationProgress, not on this column.
|
||||
*/
|
||||
export const OVERALL_STATUS_TO_DB_CURRENT_STAGE: Record<
|
||||
string,
|
||||
(typeof APPLICATION_STAGES)[keyof typeof APPLICATION_STAGES]
|
||||
> = {
|
||||
[APPLICATION_STATUS.PENDING]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.SUBMITTED]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.QUESTIONNAIRE_PENDING]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.QUESTIONNAIRE_COMPLETED]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.SHORTLISTED]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.IN_REVIEW]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.APPROVED]: APPLICATION_STAGES.APPROVED,
|
||||
[APPLICATION_STATUS.REJECTED]: APPLICATION_STAGES.REJECTED,
|
||||
[APPLICATION_STATUS.LEVEL_1_PENDING]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.LEVEL_1_APPROVED]: APPLICATION_STAGES.LEVEL_1_APPROVED,
|
||||
[APPLICATION_STATUS.LEVEL_2_PENDING]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.LEVEL_2_APPROVED]: APPLICATION_STAGES.LEVEL_2_APPROVED,
|
||||
[APPLICATION_STATUS.LEVEL_2_RECOMMENDED]: APPLICATION_STAGES.LEVEL_2_RECOMMENDED,
|
||||
[APPLICATION_STATUS.LEVEL_3_PENDING]: APPLICATION_STAGES.DD,
|
||||
[APPLICATION_STATUS.LEVEL_3_APPROVED]: APPLICATION_STAGES.LEVEL_3_APPROVED,
|
||||
[APPLICATION_STATUS.FDD_VERIFICATION]: APPLICATION_STAGES.FDD,
|
||||
[APPLICATION_STATUS.SECURITY_DETAILS]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.PAYMENT_PENDING]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.LOI_IN_PROGRESS]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.LOI_ISSUED]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.DEALER_CODE_GENERATION]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.ARCHITECTURE_TEAM_ASSIGNED]: APPLICATION_STAGES.ARCHITECTURE_WORK,
|
||||
[APPLICATION_STATUS.ARCHITECTURE_DOCUMENT_UPLOAD]: APPLICATION_STAGES.ARCHITECTURE_WORK,
|
||||
[APPLICATION_STATUS.ARCHITECTURE_TEAM_COMPLETION]: APPLICATION_STAGES.ARCHITECTURE_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_GST]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_PAN]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_NODAL]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_CHECK]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_PARTNERSHIP]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_FIRM_REG]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_VIRTUAL_CODE]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_DOMAIN]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_MSD]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_LOI_ACK]: APPLICATION_STAGES.LOI,
|
||||
[APPLICATION_STATUS.EOR_IN_PROGRESS]: APPLICATION_STAGES.EOR,
|
||||
[APPLICATION_STATUS.LOA_PENDING]: APPLICATION_STAGES.LOA,
|
||||
[APPLICATION_STATUS.ARCHITECTURE_WORK]: APPLICATION_STAGES.ARCHITECTURE_WORK,
|
||||
[APPLICATION_STATUS.STATUTORY_WORK]: APPLICATION_STAGES.STATUTORY_WORK,
|
||||
[APPLICATION_STATUS.LOA_ISSUED]: APPLICATION_STAGES.LOA,
|
||||
[APPLICATION_STATUS.LOA_REJECTED]: APPLICATION_STAGES.LOA,
|
||||
[APPLICATION_STATUS.EOR_COMPLETE]: APPLICATION_STAGES.EOR,
|
||||
[APPLICATION_STATUS.INAUGURATION]: APPLICATION_STAGES.APPROVED,
|
||||
[APPLICATION_STATUS.ONBOARDED]: APPLICATION_STAGES.APPROVED,
|
||||
[APPLICATION_STATUS.DISQUALIFIED]: APPLICATION_STAGES.REJECTED,
|
||||
[APPLICATION_STATUS.LOI_REJECTED]: APPLICATION_STAGES.REJECTED,
|
||||
[APPLICATION_STATUS.RETURNED_TO_FDD]: APPLICATION_STAGES.FDD,
|
||||
};
|
||||
|
||||
// Termination Stages
|
||||
export const TERMINATION_STAGES = {
|
||||
SUBMITTED: 'Submitted',
|
||||
@ -176,6 +232,14 @@ export const CONSTITUTIONAL_CHANGE_TYPES = {
|
||||
DIRECTOR_CHANGE: 'Director Change'
|
||||
} as const;
|
||||
|
||||
/** Legal-structure targets shown in dealer / internal forms; `value` must match DB ENUM on `changeType`. */
|
||||
export const CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS = [
|
||||
{ value: CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP, label: 'Proprietorship' },
|
||||
{ value: CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP, label: 'Partnership' },
|
||||
{ value: CONSTITUTIONAL_CHANGE_TYPES.LLP, label: 'LLP (Limited Liability Partnership)' },
|
||||
{ value: CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED, label: 'Private Limited' }
|
||||
] as const;
|
||||
|
||||
// Constitutional Change Stages (Aligned with SRS v2.0)
|
||||
export const CONSTITUTIONAL_STAGES = {
|
||||
SUBMITTED: 'Submitted',
|
||||
@ -187,7 +251,9 @@ export const CONSTITUTIONAL_STAGES = {
|
||||
NBH_APPROVAL: 'NBH Approval',
|
||||
LEGAL_REVIEW: 'Legal Review',
|
||||
COMPLETED: 'Completed',
|
||||
REJECTED: 'Rejected'
|
||||
REJECTED: 'Rejected',
|
||||
/** SRS §12.2.3 — administrative cancellation (distinct from rejection of proposal). */
|
||||
REVOKED: 'Revoked'
|
||||
} as const;
|
||||
|
||||
// Relocation Types
|
||||
@ -302,6 +368,7 @@ export const AUDIT_ACTIONS = {
|
||||
// Documents & Collaboration
|
||||
DOCUMENT_UPLOADED: 'DOCUMENT_UPLOADED',
|
||||
DOCUMENT_VERIFIED: 'DOCUMENT_VERIFIED',
|
||||
DOCUMENT_REJECTED: 'DOCUMENT_REJECTED',
|
||||
WORKNOTE_ADDED: 'WORKNOTE_ADDED',
|
||||
ATTACHMENT_UPLOADED: 'ATTACHMENT_UPLOADED',
|
||||
PARTICIPANT_ADDED: 'PARTICIPANT_ADDED',
|
||||
@ -354,6 +421,10 @@ export const AUDIT_ACTIONS = {
|
||||
RESIGNATION_SUBMITTED: 'RESIGNATION_SUBMITTED',
|
||||
RESIGNATION_APPROVED: 'RESIGNATION_APPROVED',
|
||||
RESIGNATION_REJECTED: 'RESIGNATION_REJECTED',
|
||||
RELOCATION_SENT_BACK: 'RELOCATION_SENT_BACK',
|
||||
RELOCATION_REVOKED: 'RELOCATION_REVOKED',
|
||||
CONSTITUTIONAL_SENT_BACK: 'CONSTITUTIONAL_SENT_BACK',
|
||||
CONSTITUTIONAL_REVOKED: 'CONSTITUTIONAL_REVOKED',
|
||||
EMAIL_SENT: 'EMAIL_SENT',
|
||||
REMINDER_SENT: 'REMINDER_SENT'
|
||||
} as const;
|
||||
|
||||
54
src/common/utils/constitutionalNormalize.ts
Normal file
54
src/common/utils/constitutionalNormalize.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { CONSTITUTIONAL_CHANGE_TYPES } from '../config/constants.js';
|
||||
|
||||
const ALL_CHANGE_TYPES = Object.values(CONSTITUTIONAL_CHANGE_TYPES) as string[];
|
||||
|
||||
export function isRegisteredConstitutionalChangeType(value: string): boolean {
|
||||
return ALL_CHANGE_TYPES.includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map UI / legacy profile labels to a value that exists on `constitutional_changes.changeType` ENUM.
|
||||
*/
|
||||
export function normalizeToConstitutionalChangeType(raw: string | null | undefined): string | null {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) return null;
|
||||
if (isRegisteredConstitutionalChangeType(s)) return s;
|
||||
const compact = s
|
||||
.toLowerCase()
|
||||
.replace(/\./g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (
|
||||
(compact.includes('private') && (compact.includes('ltd') || compact.includes('limited'))) ||
|
||||
compact === 'pvt ltd' ||
|
||||
compact === 'pvtltd'
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@ -89,15 +89,15 @@ export const updateApplicationProgress = async (applicationId: string, stageName
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs all progress stages based on current overall status
|
||||
* Maps application `overallStatus` to the pipeline stage label used in ApplicationProgress
|
||||
* and (via WorkflowService) `Application.currentStage` + audit `newData.stage`.
|
||||
* Keeps audit trail aligned with the post-LOI milestones (dealer code → LOA → EOR → inauguration).
|
||||
*/
|
||||
export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => {
|
||||
// Map overallStatus to stage names
|
||||
const statusToStageMap: Record<string, string> = {
|
||||
'Submitted': 'Submitted',
|
||||
export const PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS: Record<string, string> = {
|
||||
Submitted: 'Submitted',
|
||||
'Questionnaire Pending': 'Questionnaire',
|
||||
'Questionnaire Completed': 'Questionnaire',
|
||||
'Shortlisted': 'Shortlist',
|
||||
Shortlisted: 'Shortlist',
|
||||
'Level 1 Interview Pending': '1st Level Interview',
|
||||
'Level 1 Approved': '1st Level Interview',
|
||||
'Level 2 Interview Pending': '2nd Level Interview',
|
||||
@ -129,14 +129,25 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
'Statutory Work': 'Statutory Work',
|
||||
'LOA Pending': 'LOA',
|
||||
'LOA Issued': 'LOA',
|
||||
'LOA Rejected': 'LOA',
|
||||
'LOI Rejected': 'LOI Issue',
|
||||
'EOR In Progress': 'EOR Complete',
|
||||
'EOR Complete': 'EOR Complete',
|
||||
'Inauguration': 'Inauguration',
|
||||
'Approved': 'Inauguration',
|
||||
'Onboarded': 'Onboarded'
|
||||
Inauguration: 'Inauguration',
|
||||
Approved: 'Inauguration',
|
||||
Onboarded: 'Onboarded',
|
||||
Rejected: 'Rejected',
|
||||
Disqualified: 'Disqualified',
|
||||
'Returned to FDD': 'FDD',
|
||||
Pending: 'Submitted',
|
||||
'In Review': 'Shortlist',
|
||||
};
|
||||
|
||||
const currentStageName = statusToStageMap[overallStatus];
|
||||
/**
|
||||
* Syncs all progress stages based on current overall status
|
||||
*/
|
||||
export const syncApplicationProgress = async (applicationId: string, overallStatus: string) => {
|
||||
const currentStageName = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[overallStatus];
|
||||
if (currentStageName) {
|
||||
const currentStage = ONBOARDING_STAGES.find(s => s.name === currentStageName);
|
||||
if (currentStage) {
|
||||
|
||||
31
src/common/utils/workflowWorknote.ts
Normal file
31
src/common/utils/workflowWorknote.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import db from '../../database/models/index.js';
|
||||
|
||||
/** Must match `REQUEST_TYPES` / collaboration / `RequestParticipant` (use `constitutional`, not `constitutional-change`). */
|
||||
export type WorkflowActivityRequestType =
|
||||
| 'relocation'
|
||||
| 'constitutional'
|
||||
| 'resignation'
|
||||
| 'termination';
|
||||
|
||||
/**
|
||||
* Persists a workflow / decision line for Work Notes (UI: activity strip when noteType is internal | workflow).
|
||||
* No-op if noteText is empty after trim.
|
||||
*/
|
||||
export async function writeWorkflowActivityWorknote(opts: {
|
||||
requestId: string;
|
||||
requestType: WorkflowActivityRequestType;
|
||||
userId: string;
|
||||
noteText: string;
|
||||
noteType: 'internal' | 'workflow';
|
||||
}): Promise<void> {
|
||||
const text = String(opts.noteText || '').trim();
|
||||
if (!text) return;
|
||||
await db.Worknote.create({
|
||||
requestId: opts.requestId,
|
||||
requestType: opts.requestType,
|
||||
userId: opts.userId,
|
||||
noteText: text,
|
||||
noteType: opts.noteType,
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
@ -229,6 +229,11 @@ const processStageDecision = async (params: {
|
||||
targetStatus = APPLICATION_STATUS.LOA_ISSUED;
|
||||
targetStage = 'LOA';
|
||||
targetProgress = 95;
|
||||
} else if (stageCode === 'LOI_APPROVAL') {
|
||||
// Always land on Security Details for admin + finance checks before LOI Issued (ignore client nextStatus).
|
||||
targetStatus = APPLICATION_STATUS.SECURITY_DETAILS;
|
||||
targetStage = APPLICATION_STAGES.LOI;
|
||||
targetProgress = typeof nextProgress === 'number' ? nextProgress : 78;
|
||||
}
|
||||
|
||||
if (targetStatus) {
|
||||
|
||||
@ -23,6 +23,7 @@ const ACTION_DESCRIPTIONS: Record<string, string> = {
|
||||
QUESTIONNAIRE_LINK_SENT: 'Questionnaire link sent to applicant',
|
||||
DOCUMENT_UPLOADED: 'Document uploaded',
|
||||
DOCUMENT_VERIFIED: 'Document verified',
|
||||
DOCUMENT_REJECTED: 'Document rejected',
|
||||
WORKNOTE_ADDED: 'Work note added',
|
||||
ATTACHMENT_UPLOADED: 'Attachment uploaded',
|
||||
PARTICIPANT_ADDED: 'Participant added',
|
||||
@ -62,29 +63,120 @@ const ACTION_DESCRIPTIONS: Record<string, string> = {
|
||||
RESIGNATION_SUBMITTED: 'Resignation submitted',
|
||||
RESIGNATION_APPROVED: 'Resignation approved',
|
||||
RESIGNATION_REJECTED: 'Resignation rejected',
|
||||
RELOCATION_SENT_BACK: 'Relocation sent back',
|
||||
RELOCATION_REVOKED: 'Relocation revoked',
|
||||
CONSTITUTIONAL_SENT_BACK: 'Constitutional change sent back',
|
||||
CONSTITUTIONAL_REVOKED: 'Constitutional change revoked',
|
||||
EMAIL_SENT: 'Email notification sent',
|
||||
REMINDER_SENT: 'Reminder sent',
|
||||
FDD_FLAGGED_NON_RESPONSIVE: 'APPLICANT FLAGGED: Non-responsive to audit queries'
|
||||
};
|
||||
|
||||
const isIdleParallelStatus = (v: unknown) => {
|
||||
const s = String(v ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return !s || s === 'PENDING' || s === 'NULL' || s === 'NOT_STARTED';
|
||||
};
|
||||
|
||||
/** Readable copy for onboarding status transitions (avoids repeating unchanged parallel tracks). */
|
||||
function buildFriendlyApplicationUpdatedDescription(logData: any, payload: any): string {
|
||||
const oldD = logData.oldData || {};
|
||||
const oldCtx = (oldD.context || {}) as Record<string, unknown>;
|
||||
const newCtx = (payload.context || {}) as Record<string, unknown>;
|
||||
const pipeline = payload.pipelineStage as string | undefined;
|
||||
const status = payload.status as string | undefined;
|
||||
const oldStatus = oldD.status as string | undefined;
|
||||
const reason = payload.reason != null ? String(payload.reason).trim() : '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (pipeline) {
|
||||
parts.push(`Onboarding progressed to ${pipeline}`);
|
||||
if (oldStatus && status && oldStatus !== status) {
|
||||
parts.push(`Overall status: ${oldStatus} → ${status}`);
|
||||
}
|
||||
} else if (status && oldStatus && oldStatus !== status) {
|
||||
parts.push(`Application status: ${oldStatus} → ${status}`);
|
||||
} else if (status) {
|
||||
parts.push(`Application status: ${status}`);
|
||||
} else {
|
||||
parts.push('Application updated');
|
||||
}
|
||||
|
||||
const statChanged = oldCtx.statutoryStatus !== newCtx.statutoryStatus;
|
||||
const archChanged = oldCtx.architectureStatus !== newCtx.architectureStatus;
|
||||
|
||||
if (statChanged) {
|
||||
parts.push(
|
||||
`Statutory: ${oldCtx.statutoryStatus ?? '—'} → ${newCtx.statutoryStatus ?? '—'}`
|
||||
);
|
||||
} else if (!isIdleParallelStatus(newCtx.statutoryStatus) && String(newCtx.statutoryStatus)) {
|
||||
parts.push(`Statutory: ${newCtx.statutoryStatus}`);
|
||||
}
|
||||
|
||||
if (archChanged) {
|
||||
parts.push(
|
||||
`Architecture: ${oldCtx.architectureStatus ?? '—'} → ${newCtx.architectureStatus ?? '—'}`
|
||||
);
|
||||
} else if (!isIdleParallelStatus(newCtx.architectureStatus) && String(newCtx.architectureStatus)) {
|
||||
parts.push(`Architecture: ${newCtx.architectureStatus}`);
|
||||
}
|
||||
|
||||
const src = payload.transitionSource as string | undefined;
|
||||
if (src && !/WorkflowService\.transitionApplication/i.test(src) && !/^WorkflowService$/i.test(src.trim())) {
|
||||
parts.push(`Note: ${src}`);
|
||||
}
|
||||
|
||||
if (reason && !/^Transitioned to\b/i.test(reason) && reason.length < 200) {
|
||||
parts.push(reason);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => {
|
||||
const payload = logData.details || logData.newData || {};
|
||||
const actorName = logData.user?.fullName || logData.userName || 'System';
|
||||
const action = logData.action || 'UPDATED';
|
||||
const et = String(entityType || '').toLowerCase();
|
||||
|
||||
let description = ACTION_DESCRIPTIONS[action] ||
|
||||
String(action).split('_').map((w: any) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ');
|
||||
|
||||
if (et === 'application' && action === 'UPDATED') {
|
||||
description = buildFriendlyApplicationUpdatedDescription(logData, payload);
|
||||
} else {
|
||||
if (payload?.stage) description += ` - Stage: ${payload.stage}`;
|
||||
else if (payload?.department) description += ` - ${payload.department}`;
|
||||
else if (payload?.status && action === 'UPDATED') description += ` to ${payload.status}`;
|
||||
|
||||
if (payload?.transitionSource && et !== 'application') {
|
||||
description += ` · Source: ${payload.transitionSource}`;
|
||||
}
|
||||
if (payload?.pipelineStage && et === 'application' && action !== 'UPDATED') {
|
||||
description += ` · Pipeline: ${payload.pipelineStage}`;
|
||||
}
|
||||
}
|
||||
if (payload?.documentType && action === 'DOCUMENT_UPLOADED') {
|
||||
description += ` · ${payload.documentType}`;
|
||||
}
|
||||
if (payload?.paymentType && action === 'PAYMENT_UPDATED') {
|
||||
description += ` · ${payload.paymentType}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: logData.id,
|
||||
action,
|
||||
description,
|
||||
entityType,
|
||||
entityId,
|
||||
stage:
|
||||
payload?.pipelineStage ||
|
||||
payload?.stage ||
|
||||
payload?.targetStage ||
|
||||
(payload?.context as any)?.currentStage ||
|
||||
null,
|
||||
actor: {
|
||||
name: actorName,
|
||||
email: logData.user?.email || logData.userEmail || null
|
||||
@ -92,8 +184,9 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s
|
||||
userName: actorName,
|
||||
userEmail: logData.user?.email || logData.userEmail || null,
|
||||
remarks: logData.remarks || payload?.remarks || '',
|
||||
newData: payload,
|
||||
newData: logData.newData ?? payload,
|
||||
details: payload,
|
||||
oldData: logData.oldData ?? null,
|
||||
timestamp: logData.createdAt || logData.timestamp
|
||||
};
|
||||
};
|
||||
@ -182,8 +275,13 @@ export const getAuditLogs = async (req: AuthRequest, res: Response) => {
|
||||
count = result.count;
|
||||
logs = result.rows;
|
||||
} else if (type === 'relocation') {
|
||||
const relocation = await db.RelocationRequest.findOne({
|
||||
where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] },
|
||||
attributes: ['id']
|
||||
});
|
||||
const resolvedRelocationId = relocation?.id || (entityId as string);
|
||||
const result = await db.RelocationAudit.findAndCountAll({
|
||||
where: { relocationRequestId: entityId as string },
|
||||
where: { relocationRequestId: resolvedRelocationId },
|
||||
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email'] }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: limitNum, offset
|
||||
@ -297,9 +395,14 @@ export const getAuditSummary = async (req: AuthRequest, res: Response) => {
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
} else if (type === 'relocation' || type === 'relocation_request') {
|
||||
totalLogs = await db.RelocationAudit.count({ where: { relocationRequestId: entityId as string } });
|
||||
const relocation = await db.RelocationRequest.findOne({
|
||||
where: { [Op.or]: [{ id: entityId as string }, { requestId: entityId as string }] },
|
||||
attributes: ['id']
|
||||
});
|
||||
const resolvedRelocationId = relocation?.id || (entityId as string);
|
||||
totalLogs = await db.RelocationAudit.count({ where: { relocationRequestId: resolvedRelocationId } });
|
||||
latestLog = await db.RelocationAudit.findOne({
|
||||
where: { relocationRequestId: entityId as string },
|
||||
where: { relocationRequestId: resolvedRelocationId },
|
||||
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Response } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import db from '../../database/models/index.js';
|
||||
const {
|
||||
Worknote, User, WorkNoteTag, WorkNoteAttachment, DocumentVersion, RequestParticipant, Application, AuditLog,
|
||||
OnboardingDocument, RelocationDocument, ResignationDocument, ConstitutionalDocument, TerminationDocument
|
||||
} = db;
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import { AUDIT_ACTIONS } from '../../common/config/constants.js';
|
||||
import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
||||
import * as EmailService from '../../common/utils/email.service.js';
|
||||
import { getIO } from '../../common/utils/socket.js';
|
||||
import * as NotificationService from '../../common/utils/notification.service.js';
|
||||
@ -16,7 +17,8 @@ const getDocumentModel = (requestType: string) => {
|
||||
switch (requestType?.toLowerCase()) {
|
||||
case 'relocation': return RelocationDocument;
|
||||
case 'resignation': return ResignationDocument;
|
||||
case 'constitutional': return ConstitutionalDocument;
|
||||
case 'constitutional':
|
||||
case 'constitutional-change': return ConstitutionalDocument;
|
||||
case 'termination': return TerminationDocument;
|
||||
case 'onboarding':
|
||||
case 'application': return OnboardingDocument;
|
||||
@ -64,24 +66,59 @@ const stitchWorknoteAttachments = async (worknotes: any[]) => {
|
||||
return Promise.all(notePromises);
|
||||
};
|
||||
|
||||
/** Resolve REQ-… vs UUID and align constitutional aliases with `REQUEST_TYPES.CONSTITUTIONAL`. */
|
||||
async function resolveWorknoteRequestKeys(rawId: string, rawType: string) {
|
||||
const id = String(rawId || '');
|
||||
let t = String(rawType || 'application').toLowerCase();
|
||||
if (t === 'constitutional_change') t = 'constitutional-change';
|
||||
let resolvedId = id;
|
||||
|
||||
if (id && (t === 'constitutional' || t === 'constitutional-change')) {
|
||||
const row = await db.ConstitutionalChange.findOne({
|
||||
where: { [Op.or]: [{ id }, { requestId: id }] },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (row) resolvedId = (row as any).id;
|
||||
t = REQUEST_TYPES.CONSTITUTIONAL;
|
||||
} else if (id && t === 'relocation') {
|
||||
const row = await db.RelocationRequest.findOne({
|
||||
where: { [Op.or]: [{ id }, { requestId: id }] },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (row) resolvedId = (row as any).id;
|
||||
}
|
||||
return { resolvedId, normalizedType: t };
|
||||
}
|
||||
|
||||
function worknoteListWhere(resolvedId: string, normalizedType: string) {
|
||||
if (normalizedType === REQUEST_TYPES.CONSTITUTIONAL) {
|
||||
return {
|
||||
requestId: resolvedId,
|
||||
requestType: { [Op.in]: [REQUEST_TYPES.CONSTITUTIONAL, 'constitutional-change'] }
|
||||
};
|
||||
}
|
||||
return { requestId: resolvedId, requestType: normalizedType };
|
||||
}
|
||||
|
||||
// --- Worknotes ---
|
||||
|
||||
export const addWorknote = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { requestId, requestType, noteText, noteType, tags, attachmentDocIds } = req.body;
|
||||
logger.info(`Adding worknote for ${requestType} ${requestId}. Body:`, { noteText, tags, attachmentDocIds });
|
||||
const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType);
|
||||
logger.info(`Adding worknote for ${normalizedType} ${resolvedId}. Body:`, { noteText, tags, attachmentDocIds });
|
||||
|
||||
// Debug: Log participants
|
||||
const participants = await db.RequestParticipant.findAll({
|
||||
where: { requestId, requestType },
|
||||
where: { requestId: resolvedId, requestType: normalizedType },
|
||||
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName'] }]
|
||||
});
|
||||
const simplifiedParticipants = participants.map((p: any) => ({ id: p.user?.id, name: p.user?.fullName }));
|
||||
logger.info(`Participants for ${requestId}:`, simplifiedParticipants);
|
||||
logger.info(`Participants for ${resolvedId}:`, simplifiedParticipants);
|
||||
|
||||
const worknote = await Worknote.create({
|
||||
requestId,
|
||||
requestType, // application, opportunity, etc.
|
||||
requestId: resolvedId,
|
||||
requestType: normalizedType,
|
||||
userId: req.user?.id,
|
||||
noteText,
|
||||
noteType: noteType || 'General',
|
||||
@ -99,17 +136,17 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
|
||||
await WorkNoteAttachment.create({
|
||||
noteId: worknote.id,
|
||||
documentId: docId,
|
||||
documentType: requestType || 'onboarding'
|
||||
documentType: normalizedType || 'onboarding'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add author as participant
|
||||
if (req.user?.id && requestId && requestType) {
|
||||
if (req.user?.id && resolvedId && normalizedType) {
|
||||
await db.RequestParticipant.findOrCreate({
|
||||
where: {
|
||||
requestId,
|
||||
requestType,
|
||||
requestId: resolvedId,
|
||||
requestType: normalizedType,
|
||||
userId: req.user.id
|
||||
},
|
||||
defaults: {
|
||||
@ -133,7 +170,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
|
||||
// --- Real-time & Notifications ---
|
||||
try {
|
||||
const io = getIO();
|
||||
io.to(requestId).emit('new_worknote', stitchedNote);
|
||||
io.to(resolvedId).emit('new_worknote', stitchedNote);
|
||||
|
||||
// Handle Mentions/Notifications
|
||||
const notifiedUserIds = new Set<string>();
|
||||
@ -170,7 +207,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
|
||||
title: 'New Mention',
|
||||
message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`,
|
||||
type: 'info',
|
||||
link: `/applications/${requestId}?tab=worknotes`
|
||||
link: `/applications/${resolvedId}?tab=worknotes`
|
||||
});
|
||||
} catch (notifyErr) {
|
||||
logger.warn(`Failed to send notification to ${userId}:`, notifyErr);
|
||||
@ -189,7 +226,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
|
||||
userId: req.user?.id,
|
||||
action: AUDIT_ACTIONS.WORKNOTE_ADDED,
|
||||
entityType: 'application',
|
||||
entityId: requestId,
|
||||
entityId: resolvedId,
|
||||
newData: { noteType: noteType || 'General', hasAttachments: !!(attachmentDocIds?.length) }
|
||||
});
|
||||
|
||||
@ -203,9 +240,11 @@ export const addWorknote = async (req: AuthRequest, res: Response) => {
|
||||
export const getWorknotes = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { requestId, requestType } = req.query as any;
|
||||
const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType);
|
||||
const where = worknoteListWhere(resolvedId, normalizedType);
|
||||
|
||||
const worknotes = await Worknote.findAll({
|
||||
where: { requestId, requestType },
|
||||
where,
|
||||
include: [
|
||||
{ model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] },
|
||||
{ model: WorkNoteTag, as: 'tags' },
|
||||
@ -225,12 +264,13 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
const { requestId, requestType } = req.body;
|
||||
const { resolvedId, normalizedType } = await resolveWorknoteRequestKeys(requestId, requestType);
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({ success: false, message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const DocModel = getDocumentModel(requestType);
|
||||
const DocModel = getDocumentModel(normalizedType);
|
||||
|
||||
let createData: any = {
|
||||
documentType: 'Worknote Attachment',
|
||||
@ -242,19 +282,19 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => {
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
// Assign correct FK based on model
|
||||
if (DocModel === RelocationDocument) createData.relocationId = requestId;
|
||||
else if (DocModel === ResignationDocument) createData.resignationId = requestId;
|
||||
else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = requestId;
|
||||
else if (DocModel === TerminationDocument) createData.terminationRequestId = requestId;
|
||||
else createData.applicationId = requestId;
|
||||
// Assign correct FK based on model (always UUID for self-service modules)
|
||||
if (DocModel === RelocationDocument) createData.relocationId = resolvedId;
|
||||
else if (DocModel === ResignationDocument) createData.resignationId = resolvedId;
|
||||
else if (DocModel === ConstitutionalDocument) createData.constitutionalChangeId = resolvedId;
|
||||
else if (DocModel === TerminationDocument) createData.terminationRequestId = resolvedId;
|
||||
else createData.applicationId = resolvedId;
|
||||
|
||||
const document = await DocModel.create(createData);
|
||||
|
||||
// Create initial version
|
||||
await DocumentVersion.create({
|
||||
documentId: document.id,
|
||||
documentType: requestType || 'onboarding',
|
||||
documentType: normalizedType || 'onboarding',
|
||||
versionNumber: 1,
|
||||
filePath: file.path,
|
||||
uploadedBy: req.user?.id,
|
||||
@ -262,12 +302,12 @@ export const uploadWorknoteAttachment = async (req: any, res: Response) => {
|
||||
});
|
||||
|
||||
// Audit log for attachment upload
|
||||
if (requestId) {
|
||||
if (resolvedId) {
|
||||
await AuditLog.create({
|
||||
userId: req.user?.id,
|
||||
action: AUDIT_ACTIONS.ATTACHMENT_UPLOADED,
|
||||
entityType: requestType || 'application',
|
||||
entityId: requestId,
|
||||
entityType: normalizedType || 'application',
|
||||
entityId: resolvedId,
|
||||
newData: { fileName: file.originalname, mimeType: file.mimetype }
|
||||
});
|
||||
}
|
||||
|
||||
@ -12,11 +12,17 @@ import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
|
||||
export const getDealers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (String((req.query as any)?.onboarded || '') === 'true') {
|
||||
where.onboardedAt = { [Op.ne]: null };
|
||||
}
|
||||
|
||||
const dealers = await Dealer.findAll({
|
||||
where,
|
||||
include: [
|
||||
{ model: DealerCode, as: 'dealerCode' },
|
||||
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
|
||||
{ model: User, as: 'user', attributes: ['id', 'email', 'status', 'isActive'] }
|
||||
{ model: User, as: 'user', attributes: ['id', 'email', 'status', 'isActive', 'roleCode'] }
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
@ -4,6 +4,101 @@ import db from '../../database/models/index.js';
|
||||
const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } = db;
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
|
||||
/** Default EOR rows for relocation (SRS 12.2.8) — must stay aligned with relocation required-doc labels. */
|
||||
export const RELOCATION_EOR_DEFAULT_ITEMS = [
|
||||
{ itemType: 'Property', description: 'Property documents for new location' },
|
||||
{ itemType: 'Property', description: 'Lease / Rental agreement' },
|
||||
{ itemType: 'Property', description: 'Layout / Floor plan of new location' },
|
||||
{ itemType: 'Infrastructure', description: 'Photos of new location' },
|
||||
{ itemType: 'Infrastructure', description: 'Locality map / Building plan approval' },
|
||||
{ itemType: 'Statutory', description: 'NOC from current landlord' },
|
||||
{ itemType: 'Statutory', description: 'Municipal approvals (Fire safety / Pollution clearance)' },
|
||||
{ itemType: 'Utility', description: 'Electricity & Water supply documents' }
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maps `RelocationDocument.documentType` (same strings as relocation required-doc UI) to EOR checklist row text.
|
||||
*/
|
||||
const RELOCATION_UPLOAD_TYPE_TO_EOR_DESCRIPTION: Record<string, string> = {
|
||||
'Property documents for new location': 'Property documents for new location',
|
||||
'Lease/Rental agreement for new location': 'Lease / Rental agreement',
|
||||
'NOC from current landlord': 'NOC from current landlord',
|
||||
'Municipal approvals': 'Municipal approvals (Fire safety / Pollution clearance)',
|
||||
'Fire safety certificate': 'Municipal approvals (Fire safety / Pollution clearance)',
|
||||
'Pollution clearance': 'Municipal approvals (Fire safety / Pollution clearance)',
|
||||
'Layout/Floor plan of new location': 'Layout / Floor plan of new location',
|
||||
'Photos of new location': 'Photos of new location',
|
||||
'Locality map': 'Locality map / Building plan approval',
|
||||
'Building plan approval': 'Locality map / Building plan approval',
|
||||
'Electricity connection documents': 'Electricity & Water supply documents',
|
||||
'Water supply documents': 'Electricity & Water supply documents'
|
||||
};
|
||||
|
||||
async function mapRelocationDocumentsToEorItems(checklistId: string, relocationId: string) {
|
||||
const docs = await RelocationDocument.findAll({
|
||||
where: {
|
||||
relocationId,
|
||||
status: { [Op.notIn]: ['Rejected'] }
|
||||
}
|
||||
});
|
||||
for (const doc of docs) {
|
||||
const dt = String((doc as any).documentType || '').trim();
|
||||
const eorDesc = RELOCATION_UPLOAD_TYPE_TO_EOR_DESCRIPTION[dt];
|
||||
if (!eorDesc) continue;
|
||||
const verified = String((doc as any).status || '').toLowerCase() === 'verified';
|
||||
|
||||
let item = await EorChecklistItem.findOne({
|
||||
where: {
|
||||
checklistId,
|
||||
description: { [Op.iLike]: eorDesc },
|
||||
proofDocumentId: { [Op.is]: null }
|
||||
}
|
||||
});
|
||||
if (!item) {
|
||||
const linked = await EorChecklistItem.findOne({
|
||||
where: {
|
||||
checklistId,
|
||||
description: { [Op.iLike]: eorDesc },
|
||||
proofDocumentId: (doc as any).id
|
||||
}
|
||||
});
|
||||
if (linked && verified) {
|
||||
await linked.update({ isCompliant: true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await item.update({
|
||||
proofDocumentId: (doc as any).id,
|
||||
isCompliant: verified ? true : item.isCompliant
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocation flow historically called `EorChecklist.findOrCreate` only, so checklists existed with **zero items**
|
||||
* and no link to `relocation_documents`. Heal by seeding default rows and attaching proofs by document type.
|
||||
*/
|
||||
export async function ensureRelocationEorChecklistSeeded(relocationId: string): Promise<void> {
|
||||
const checklist = await EorChecklist.findOne({ where: { relocationId } });
|
||||
if (!checklist) return;
|
||||
|
||||
const itemCount = await EorChecklistItem.count({ where: { checklistId: checklist.id } });
|
||||
if (itemCount === 0) {
|
||||
const compliantDefault = String(checklist.status || '').toLowerCase() === 'completed';
|
||||
await EorChecklistItem.bulkCreate(
|
||||
RELOCATION_EOR_DEFAULT_ITEMS.map((item) => ({
|
||||
itemType: item.itemType,
|
||||
description: item.description,
|
||||
checklistId: checklist.id,
|
||||
isCompliant: compliantDefault
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await mapRelocationDocumentsToEorItems(checklist.id, relocationId);
|
||||
}
|
||||
|
||||
export const getChecklist = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { applicationId, relocationId } = req.params;
|
||||
@ -23,8 +118,25 @@ export const getChecklist = async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve relocation route param (UUID or REL-2026-xxxx) to UUID for DB lookup
|
||||
let resolvedRelocationId: string | undefined;
|
||||
if (relocationId) {
|
||||
const relStr = relocationId as string;
|
||||
const isRelUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(relStr);
|
||||
if (isRelUUID) {
|
||||
resolvedRelocationId = relStr;
|
||||
} else {
|
||||
const rel = await db.RelocationRequest.findOne({ where: { requestId: relStr } });
|
||||
if (!rel) {
|
||||
res.status(404).json({ success: false, message: 'Relocation request not found' });
|
||||
return;
|
||||
}
|
||||
resolvedRelocationId = rel.id;
|
||||
}
|
||||
}
|
||||
|
||||
let checklist = await EorChecklist.findOne({
|
||||
where: relocationId ? { relocationId } : { applicationId: resolvedAppId },
|
||||
where: resolvedRelocationId ? { relocationId: resolvedRelocationId } : { applicationId: resolvedAppId },
|
||||
include: [{ model: EorChecklistItem, as: 'items' }]
|
||||
});
|
||||
|
||||
@ -33,28 +145,40 @@ export const getChecklist = async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedRelocationId) {
|
||||
await ensureRelocationEorChecklistSeeded(resolvedRelocationId);
|
||||
checklist = await EorChecklist.findOne({
|
||||
where: { relocationId: resolvedRelocationId },
|
||||
include: [{ model: EorChecklistItem, as: 'items' }]
|
||||
});
|
||||
if (!checklist) {
|
||||
res.status(404).json({ success: false, message: 'Checklist not found' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const items = checklist.items || [];
|
||||
const proofDocIds = items.map((i: any) => i.proofDocumentId).filter(Boolean);
|
||||
|
||||
let payload: any = checklist.toJSON ? checklist.toJSON() : checklist;
|
||||
|
||||
if (proofDocIds.length > 0) {
|
||||
// Find documents from the relevant table
|
||||
let docs = [];
|
||||
if (relocationId) {
|
||||
if (resolvedRelocationId) {
|
||||
docs = await RelocationDocument.findAll({ where: { id: proofDocIds } });
|
||||
} else {
|
||||
docs = await OnboardingDocument.findAll({ where: { id: proofDocIds } });
|
||||
}
|
||||
|
||||
// Map docs to items
|
||||
const docsMap = new Map(docs.map((d: any) => [d.id, d]));
|
||||
checklist = checklist.toJSON();
|
||||
checklist.items = checklist.items.map((item: any) => ({
|
||||
payload = { ...payload };
|
||||
payload.items = (payload.items || []).map((item: any) => ({
|
||||
...item,
|
||||
proofDocument: docsMap.get(item.proofDocumentId) || null
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({ success: true, data: checklist });
|
||||
res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
console.error('Get EOR checklist error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error fetching EOR checklist' });
|
||||
@ -95,20 +219,10 @@ export const createChecklist = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
if (created) {
|
||||
// Define Default Mandatory Items per SRS/Frontend
|
||||
let defaultItems = [];
|
||||
let defaultItems: { itemType: string; description: string }[] = [];
|
||||
|
||||
if (relocationId) {
|
||||
// Strictly per SRS Section 12.2.8 for Relocation
|
||||
defaultItems = [
|
||||
{ itemType: 'Property', description: 'Property documents for new location' },
|
||||
{ itemType: 'Property', description: 'Lease / Rental agreement' },
|
||||
{ itemType: 'Property', description: 'Layout / Floor plan of new location' },
|
||||
{ itemType: 'Infrastructure', description: 'Photos of new location' },
|
||||
{ itemType: 'Infrastructure', description: 'Locality map / Building plan approval' },
|
||||
{ itemType: 'Statutory', description: 'NOC from current landlord' },
|
||||
{ itemType: 'Statutory', description: 'Municipal approvals (Fire safety / Pollution clearance)' },
|
||||
{ itemType: 'Utility', description: 'Electricity & Water supply documents' }
|
||||
];
|
||||
defaultItems = [...RELOCATION_EOR_DEFAULT_ITEMS];
|
||||
} else {
|
||||
// Onboarding Default
|
||||
defaultItems = [
|
||||
@ -157,6 +271,8 @@ export const createChecklist = async (req: AuthRequest, res: Response) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (relocationId) {
|
||||
await mapRelocationDocumentsToEorItems(checklist.id, relocationId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Op } from 'sequelize';
|
||||
import db from '../../database/models/index.js';
|
||||
const { FddAssignment, FddReport, AuditLog, Application } = db;
|
||||
const { FddAssignment, FddReport, Application } = db;
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES } from '../../common/config/constants.js';
|
||||
import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||
|
||||
export const getAssignment = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@ -67,11 +68,24 @@ export const assignAgency = async (req: AuthRequest, res: Response) => {
|
||||
progressPercentage: 70
|
||||
});
|
||||
|
||||
await AuditLog.create({
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id,
|
||||
action: AUDIT_ACTIONS.FDD_ASSIGNED,
|
||||
entityType: 'fdd_assignment',
|
||||
entityId: assignment.id
|
||||
entityId: assignment.id,
|
||||
newData: { assignedToAgency, applicationId: application.id },
|
||||
});
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id,
|
||||
action: AUDIT_ACTIONS.FDD_ASSIGNED,
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
newData: {
|
||||
assignmentId: assignment.id,
|
||||
assignedToAgency,
|
||||
note: 'FDD agency assigned; application moved to FDD verification.',
|
||||
context: pickApplicationAuditContext(application),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, message: 'FDD Agency assigned', data: assignment });
|
||||
@ -169,17 +183,26 @@ export const flagNonResponsive = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(404).json({ success: false, message: 'Application not found' });
|
||||
}
|
||||
|
||||
const previousStatutory = application.statutoryStatus;
|
||||
// 1. Update Application status at model level
|
||||
await application.update({ statutoryStatus: 'Flagged' });
|
||||
|
||||
// 2. Add high-level Audit Log entry (Using literal 'UPDATED' to absolutely avoid ENUM errors)
|
||||
console.log(`[FDDController] Flagging application ${application.id} with action: UPDATED`);
|
||||
await AuditLog.create({
|
||||
console.log(`[FDDController] Flagging application ${application.id} as non-responsive (FDD)`);
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id,
|
||||
action: 'UPDATED',
|
||||
action: AUDIT_ACTIONS.FDD_FLAGGED_NON_RESPONSIVE,
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
newData: { statutoryStatus: 'Flagged', remarks: remarks || 'Applicant is non-responsive to FDD queries.' }
|
||||
oldData: {
|
||||
statutoryStatus: previousStatutory,
|
||||
overallStatus: application.overallStatus,
|
||||
currentStage: application.currentStage,
|
||||
},
|
||||
newData: {
|
||||
statutoryStatus: 'Flagged',
|
||||
remarks: remarks || 'Applicant is non-responsive to FDD queries.',
|
||||
context: pickApplicationAuditContext(application),
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Application flagged successfully' });
|
||||
|
||||
@ -341,28 +341,47 @@ export const updateSecurityDeposit = async (req: AuthRequest, res: Response) =>
|
||||
|
||||
// --- AUTOMATION: After verification transitions ---
|
||||
|
||||
// 1. If SECURITY_DEPOSIT Payment Verified -> Move to LOI Issue Stage
|
||||
// 1. SECURITY_DEPOSIT verified: the deposit row is always updated above. Only touch application workflow when
|
||||
// the app is already in the LOI payment/security corridor — never jump from earlier stages (e.g. interviews).
|
||||
if ((depositType === 'SECURITY_DEPOSIT' || !depositType) && status === 'Verified') {
|
||||
console.log(`[DEBUG] Security Deposit verified. Moving to LOI Issued stage...`);
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, req.user?.id || null, {
|
||||
reason: 'Security Deposit verified. Proceeding to LOI Issuance.',
|
||||
const os = application.overallStatus;
|
||||
const inInitialSdCorridor =
|
||||
os === APPLICATION_STATUS.PAYMENT_PENDING || os === APPLICATION_STATUS.SECURITY_DETAILS;
|
||||
if (inInitialSdCorridor) {
|
||||
console.log(
|
||||
`[DEBUG] SECURITY_DEPOSIT verified (overallStatus=${os}). Aligning to Security Details for admin before LOI Issued.`,
|
||||
);
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, req.user?.id || null, {
|
||||
reason: 'Security deposit verified by Finance. Awaiting admin approval to proceed to LOI issuance.',
|
||||
stage: APPLICATION_STAGES.LOI,
|
||||
progressPercentage: 80
|
||||
progressPercentage: 78
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
`[DEBUG] SECURITY_DEPOSIT verified but overallStatus=${os}; no status transition (payment recorded only).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If FIRST_FILL Payment Verified -> Move to LOA Pending stage
|
||||
// 2. FIRST_FILL verified: always persist the row above. Only touch workflow when already at LOA Pending
|
||||
// (same idea as SECURITY_DEPOSIT — never jump from earlier stages into LOA).
|
||||
if (depositType === 'FIRST_FILL' && status === 'Verified') {
|
||||
// Ensure LoaRequest exists for the next step
|
||||
const os = application.overallStatus;
|
||||
if (os === APPLICATION_STATUS.LOA_PENDING) {
|
||||
await db.LoaRequest.findOrCreate({
|
||||
where: { applicationId: application.id },
|
||||
defaults: { status: 'pending', requestedBy: req.user?.id }
|
||||
});
|
||||
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOA_PENDING, req.user?.id || null, {
|
||||
reason: 'First Fill Verified. Initiating LOA Approval stage.',
|
||||
reason: 'First Fill verified by Finance while at LOA Pending. LOA approval may proceed.',
|
||||
progressPercentage: 90
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
`[DEBUG] FIRST_FILL verified but overallStatus=${os}; no status transition (payment recorded only).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Security Deposit updated', data: updatedDeposit });
|
||||
|
||||
@ -11,6 +11,7 @@ import { syncLocationManagers } from '../master/syncHierarchy.service.js';
|
||||
import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
import { WorkflowIntegrityService } from '../../services/WorkflowIntegrityService.js';
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||
|
||||
const { DocumentStageConfig } = db;
|
||||
|
||||
@ -454,6 +455,24 @@ export const uploadDocuments = async (req: any, res: Response) => {
|
||||
console.log(`[debug] EOR items updated: ${updatedCount} for type: ${documentType}`);
|
||||
}
|
||||
|
||||
const prospectiveUpload = req.user?.roleCode === 'Prospective Dealer' || !req.user?.id;
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id || null,
|
||||
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
newData: {
|
||||
documentId: newDoc.id,
|
||||
documentType,
|
||||
fileName: file.originalname,
|
||||
docStage: stage || null,
|
||||
prospectiveUpload,
|
||||
uploaderRole: req.user?.roleCode || 'Prospective (session)',
|
||||
eorChecklistLinked: eorDescriptions.includes(documentType),
|
||||
context: pickApplicationAuditContext(application),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Document uploaded successfully',
|
||||
|
||||
@ -1,34 +1,177 @@
|
||||
import { Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
const { ConstitutionalChange, Outlet, User, Worknote, Dealer, Application, District } = db;
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District } = db;
|
||||
import { Op } from 'sequelize';
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js';
|
||||
import {
|
||||
CONSTITUTIONAL_STAGES,
|
||||
AUDIT_ACTIONS,
|
||||
ROLES,
|
||||
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS
|
||||
} from '../../common/config/constants.js';
|
||||
import { ConstitutionalWorkflowService } from '../../services/ConstitutionalWorkflowService.js';
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
import { ParticipantService } from '../../services/ParticipantService.js';
|
||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||
import {
|
||||
isRegisteredConstitutionalChangeType,
|
||||
normalizeToConstitutionalChangeType
|
||||
} from '../../common/utils/constitutionalNormalize.js';
|
||||
|
||||
const STRUCTURE_TARGET_VALUES = new Set<string>(
|
||||
CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string)
|
||||
);
|
||||
|
||||
export const getMeta = async (_req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: true,
|
||||
structureTargets: CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => ({ value: o.value, label: o.label }))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Constitutional meta error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error loading options' });
|
||||
}
|
||||
};
|
||||
|
||||
export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const { outletId, changeType, reason, currentConstitution, newPartnersDetails, shareholdingPattern } = req.body;
|
||||
const {
|
||||
outletId,
|
||||
changeType,
|
||||
reason,
|
||||
description,
|
||||
currentConstitution,
|
||||
newPartnersDetails,
|
||||
shareholdingPattern,
|
||||
forDealerUserId
|
||||
} = req.body;
|
||||
|
||||
const remarksText = String(reason ?? description ?? '').trim();
|
||||
if (!remarksText) {
|
||||
return res.status(400).json({ success: false, message: 'Reason / description is required' });
|
||||
}
|
||||
|
||||
const resolvedChangeType = normalizeToConstitutionalChangeType(String(changeType || '').trim());
|
||||
if (!resolvedChangeType || !isRegisteredConstitutionalChangeType(resolvedChangeType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid constitutional change type. Use a value returned from GET /constitutional-change/meta.'
|
||||
});
|
||||
}
|
||||
|
||||
const isDealerRole = req.user.roleCode === ROLES.DEALER;
|
||||
const internalTargetUserId = forDealerUserId ? String(forDealerUserId).trim() : '';
|
||||
|
||||
if (isDealerRole && internalTargetUserId && internalTargetUserId !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Dealers cannot create a constitutional request for another account.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDealerRole && !internalTargetUserId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Select the dealer this request is for (forDealerUserId).'
|
||||
});
|
||||
}
|
||||
|
||||
let dealerUserId = req.user.id;
|
||||
let subjectDealerProfile: any = null;
|
||||
|
||||
if (!isDealerRole) {
|
||||
const targetUser = await User.findByPk(internalTargetUserId, {
|
||||
include: [{ model: Dealer, as: 'dealerProfile' }]
|
||||
});
|
||||
if (!targetUser || targetUser.roleCode !== ROLES.DEALER) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid dealer user selected.' });
|
||||
}
|
||||
if (!(targetUser as any).dealerProfile) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Selected user has no dealer profile.'
|
||||
});
|
||||
}
|
||||
dealerUserId = targetUser.id;
|
||||
subjectDealerProfile = (targetUser as any).dealerProfile;
|
||||
} else {
|
||||
const selfUser = await User.findByPk(req.user.id, {
|
||||
include: [{ model: Dealer, as: 'dealerProfile' }]
|
||||
});
|
||||
subjectDealerProfile = (selfUser as any)?.dealerProfile || null;
|
||||
if (!subjectDealerProfile) {
|
||||
return res.status(400).json({ success: false, message: 'Dealer profile not found for this account.' });
|
||||
}
|
||||
}
|
||||
|
||||
const profileConstitutionRaw = subjectDealerProfile?.constitutionType;
|
||||
const resolvedCurrentFromBody = normalizeToConstitutionalChangeType(
|
||||
currentConstitution != null ? String(currentConstitution) : ''
|
||||
);
|
||||
const resolvedCurrentFromProfile = normalizeToConstitutionalChangeType(
|
||||
profileConstitutionRaw != null ? String(profileConstitutionRaw) : ''
|
||||
);
|
||||
|
||||
let resolvedCurrent = resolvedCurrentFromBody || resolvedCurrentFromProfile;
|
||||
if (!resolvedCurrent) {
|
||||
if (String(profileConstitutionRaw || '').trim()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Dealer profile constitution "${profileConstitutionRaw}" is not recognized. Update constitution type in dealer master data.`
|
||||
});
|
||||
}
|
||||
resolvedCurrent = normalizeToConstitutionalChangeType('Proprietorship')!;
|
||||
}
|
||||
|
||||
if (
|
||||
STRUCTURE_TARGET_VALUES.has(resolvedChangeType) &&
|
||||
STRUCTURE_TARGET_VALUES.has(resolvedCurrent) &&
|
||||
resolvedChangeType === resolvedCurrent
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Proposed constitution must differ from the current constitution.'
|
||||
});
|
||||
}
|
||||
|
||||
let resolvedOutletId: string | null = outletId ? String(outletId) : null;
|
||||
if (resolvedOutletId) {
|
||||
const outlet = await Outlet.findByPk(resolvedOutletId);
|
||||
if (!outlet || String(outlet.dealerId) !== String(dealerUserId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Selected outlet does not belong to the chosen dealer user.'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const firstOutlet = await Outlet.findOne({
|
||||
where: { dealerId: dealerUserId },
|
||||
order: [['createdAt', 'ASC']]
|
||||
});
|
||||
resolvedOutletId = firstOutlet ? String(firstOutlet.id) : null;
|
||||
}
|
||||
|
||||
const requestId = NomenclatureService.generateConstitutionalChangeId();
|
||||
|
||||
// Store extra details in metadata
|
||||
const metadata = {
|
||||
newPartnersDetails,
|
||||
shareholdingPattern,
|
||||
currentConstitution
|
||||
currentConstitution: resolvedCurrent,
|
||||
submittedByUserId: req.user.id,
|
||||
submittedByRole: req.user.roleCode,
|
||||
createdOnBehalfOfDealer: !isDealerRole
|
||||
};
|
||||
|
||||
const request = await ConstitutionalChange.create({
|
||||
requestId,
|
||||
outletId: outletId || null, // Optional for dealer-level changes
|
||||
dealerId: req.user.id,
|
||||
changeType,
|
||||
description: reason,
|
||||
currentConstitution: currentConstitution || null,
|
||||
outletId: resolvedOutletId,
|
||||
dealerId: dealerUserId,
|
||||
changeType: resolvedChangeType,
|
||||
description: remarksText,
|
||||
currentConstitution: resolvedCurrent,
|
||||
currentStage: CONSTITUTIONAL_STAGES.SUBMITTED,
|
||||
status: 'Submitted',
|
||||
progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED),
|
||||
@ -38,8 +181,8 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
stage: 'Submitted',
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Request submitted',
|
||||
remarks: reason
|
||||
action: isDealerRole ? 'Request submitted' : 'Request submitted (on behalf of dealer)',
|
||||
remarks: remarksText
|
||||
}]
|
||||
});
|
||||
|
||||
@ -50,6 +193,14 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
entityId: request.id
|
||||
});
|
||||
|
||||
await ConstitutionalAudit.create({
|
||||
userId: req.user.id,
|
||||
constitutionalChangeId: request.id,
|
||||
action: AUDIT_ACTIONS.CREATED,
|
||||
remarks: remarksText || 'Constitutional change request submitted',
|
||||
details: { stage: CONSTITUTIONAL_STAGES.SUBMITTED, requestId: request.requestId }
|
||||
});
|
||||
|
||||
// Add as chat participants (Async)
|
||||
ParticipantService.assignConstitutionalParticipants(request.id)
|
||||
.catch(err => console.error('Error assigning participants to constitutional change:', err));
|
||||
@ -125,6 +276,21 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
{
|
||||
model: db.Dealer,
|
||||
as: 'dealerProfile',
|
||||
attributes: [
|
||||
'id',
|
||||
'legalName',
|
||||
'businessName',
|
||||
'constitutionType',
|
||||
'registeredAddress',
|
||||
'onboardedAt',
|
||||
'loiDate',
|
||||
'loaDate',
|
||||
'gstNumber',
|
||||
'panNumber',
|
||||
'dealerCodeId',
|
||||
'applicationId',
|
||||
'status'
|
||||
],
|
||||
include: [
|
||||
{ model: db.DealerCode, as: 'dealerCode' },
|
||||
{
|
||||
@ -132,8 +298,18 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
as: 'application',
|
||||
include: [
|
||||
{ model: db.District, as: 'district' },
|
||||
{ model: db.LoiRequest, as: 'loiRequests', where: { status: 'approved' }, required: false },
|
||||
{ model: db.LoaRequest, as: 'loaRequests', where: { status: 'approved' }, required: false }
|
||||
{
|
||||
model: db.LoiRequest,
|
||||
as: 'loiRequests',
|
||||
where: { [Op.or]: [{ status: 'approved' }, { status: 'Approved' }] },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: db.LoaRequest,
|
||||
as: 'loaRequests',
|
||||
where: { [Op.or]: [{ status: 'approved' }, { status: 'Approved' }] },
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -158,31 +334,7 @@ export const getRequestById = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { action, comments } = req.body; // Approve, Reject, Send Back
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const request = await ConstitutionalChange.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
|
||||
|
||||
if (action === 'Reject') {
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
|
||||
action: 'Rejected',
|
||||
status: 'Rejected',
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName
|
||||
});
|
||||
} else {
|
||||
// Multi-level approval flow as per SRS 12.2.4
|
||||
const stageFlow: Record<string, string> = {
|
||||
const STAGE_FLOW_FORWARD: Record<string, string> = {
|
||||
[CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW,
|
||||
@ -193,17 +345,158 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.COMPLETED
|
||||
};
|
||||
|
||||
const nextStage = stageFlow[request.currentStage];
|
||||
if (!nextStage) return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' });
|
||||
/** SRS §12.2.3 — return to previous review stage */
|
||||
const STAGE_FLOW_BACK: Record<string, string | undefined> = {
|
||||
[CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.SUBMITTED,
|
||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ASM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.HEAD_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL
|
||||
};
|
||||
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, nextStage, req.user.id, {
|
||||
action: action === 'Approve' ? `Approved to ${nextStage}` : action,
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName
|
||||
const actionSuccessMessage = (raw: string): string => {
|
||||
const a = String(raw || '').trim().toLowerCase();
|
||||
if (a === 'reject') return 'Request rejected successfully';
|
||||
if (a === 'revoke') return 'Request revoked successfully';
|
||||
if (a.includes('send') && a.includes('back')) return 'Request sent back successfully';
|
||||
if (a === 'approve') return 'Request approved successfully';
|
||||
return 'Action completed successfully';
|
||||
};
|
||||
|
||||
export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const rawAction = String(req.body.action || '').trim();
|
||||
const actionNorm = rawAction.toLowerCase().replace(/\s+/g, ' ');
|
||||
const comments = req.body.comments;
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
const request = await ConstitutionalChange.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) return res.status(404).json({ success: false, message: 'Request not found' });
|
||||
|
||||
const sourceStage = request.currentStage;
|
||||
const remarksTrim = String(comments || '').trim();
|
||||
|
||||
const isReject = actionNorm === 'reject';
|
||||
const isRevoke = actionNorm === 'revoke';
|
||||
const isSendBack = actionNorm.includes('send') && actionNorm.includes('back');
|
||||
const isApprove = actionNorm === 'approve' || actionNorm === 'approved';
|
||||
|
||||
if (isReject) {
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, {
|
||||
action: 'Rejected',
|
||||
status: 'Rejected',
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName,
|
||||
auditAction: AUDIT_ACTIONS.REJECTED
|
||||
});
|
||||
try {
|
||||
const reviewText = remarksTrim || `[Rejected] at ${sourceStage}`;
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: request.id,
|
||||
requestType: 'constitutional',
|
||||
userId: req.user.id,
|
||||
noteText: reviewText,
|
||||
noteType: 'internal'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
console.error('[constitutional] workflow worknote:', wnErr);
|
||||
}
|
||||
return res.json({ success: true, message: actionSuccessMessage(rawAction) });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Request ${action.toLowerCase()}ed successfully` });
|
||||
if (isRevoke) {
|
||||
if (!remarksTrim) {
|
||||
return res.status(400).json({ success: false, message: 'Remarks are required to revoke (SRS §12.2.3 Work Notes).' });
|
||||
}
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REVOKED, req.user.id, {
|
||||
action: 'Revoked',
|
||||
status: 'Revoked',
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName,
|
||||
auditAction: AUDIT_ACTIONS.CONSTITUTIONAL_REVOKED
|
||||
});
|
||||
try {
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: request.id,
|
||||
requestType: 'constitutional',
|
||||
userId: req.user.id,
|
||||
noteText: `[Revoked] ${remarksTrim}`,
|
||||
noteType: 'workflow'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
console.error('[constitutional] workflow worknote:', wnErr);
|
||||
}
|
||||
return res.json({ success: true, message: actionSuccessMessage(rawAction) });
|
||||
}
|
||||
|
||||
if (isSendBack) {
|
||||
if (!remarksTrim) {
|
||||
return res.status(400).json({ success: false, message: 'Remarks are required to send back (SRS §12.2.3 Work Notes).' });
|
||||
}
|
||||
const prevStage = STAGE_FLOW_BACK[request.currentStage];
|
||||
if (!prevStage) {
|
||||
return res.status(400).json({ success: false, message: 'Cannot send back from this stage' });
|
||||
}
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, prevStage, req.user.id, {
|
||||
action: `Sent back to ${prevStage}`,
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName,
|
||||
auditAction: AUDIT_ACTIONS.CONSTITUTIONAL_SENT_BACK
|
||||
});
|
||||
try {
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: request.id,
|
||||
requestType: 'constitutional',
|
||||
userId: req.user.id,
|
||||
noteText: `[Send Back] ${remarksTrim}`,
|
||||
noteType: 'workflow'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
console.error('[constitutional] workflow worknote:', wnErr);
|
||||
}
|
||||
return res.json({ success: true, message: actionSuccessMessage(rawAction) });
|
||||
}
|
||||
|
||||
if (!isApprove) {
|
||||
return res.status(400).json({ success: false, message: 'Unsupported action. Use Approve, Reject, Send Back, or Revoke.' });
|
||||
}
|
||||
|
||||
const nextStage = STAGE_FLOW_FORWARD[request.currentStage];
|
||||
if (!nextStage) {
|
||||
return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' });
|
||||
}
|
||||
|
||||
await ConstitutionalWorkflowService.transitionRequest(request, nextStage, req.user.id, {
|
||||
action: `Approved to ${nextStage}`,
|
||||
remarks: comments,
|
||||
userFullName: req.user.fullName,
|
||||
auditAction: AUDIT_ACTIONS.APPROVED
|
||||
});
|
||||
|
||||
try {
|
||||
const reviewText = remarksTrim;
|
||||
const noteText = reviewText || `[Approved] ${sourceStage} → ${nextStage}`;
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: request.id,
|
||||
requestType: 'constitutional',
|
||||
userId: req.user.id,
|
||||
noteText,
|
||||
noteType: 'internal'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
console.error('[constitutional] workflow worknote:', wnErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: actionSuccessMessage(rawAction) });
|
||||
} catch (error) {
|
||||
console.error('Take action error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error processing action' });
|
||||
@ -227,6 +520,8 @@ export const getChecklist = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const { id } = req.params;
|
||||
const idStr = String(id);
|
||||
const { documents } = req.body;
|
||||
@ -245,6 +540,15 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
const docCount = Array.isArray(documents) ? documents.length : 0;
|
||||
await ConstitutionalAudit.create({
|
||||
userId: req.user.id,
|
||||
constitutionalChangeId: request.id,
|
||||
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
|
||||
remarks: `Document checklist updated (${docCount} entr${docCount === 1 ? 'y' : 'ies'})`,
|
||||
details: { documentCount: docCount, stage: request.currentStage }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Documents uploaded successfully' });
|
||||
} catch (error) {
|
||||
console.error('Upload documents error:', error);
|
||||
|
||||
@ -4,6 +4,7 @@ import * as constitutionalController from './constitutional.controller.js';
|
||||
import { authenticate } from '../../common/middleware/auth.js';
|
||||
|
||||
// Constitutional change routes (Base at /)
|
||||
router.get('/meta', authenticate as any, constitutionalController.getMeta);
|
||||
router.post('/', authenticate as any, constitutionalController.submitRequest);
|
||||
router.get('/checklist', authenticate as any, constitutionalController.getChecklist);
|
||||
router.get('/', authenticate as any, constitutionalController.getRequests);
|
||||
|
||||
@ -3,7 +3,7 @@ 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 } = db;
|
||||
import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES } from '../../common/config/constants.js';
|
||||
import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS } from '../../common/config/constants.js';
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuthRequest } from '../../types/express.types.js';
|
||||
@ -122,6 +122,58 @@ const assignRelocationEvaluators = async (requestId: string, outletId: string) =
|
||||
}
|
||||
};
|
||||
|
||||
const REQUIRED_RELOCATION_DOCUMENTS = [
|
||||
'Property documents for new location',
|
||||
'Lease/Rental agreement for new location',
|
||||
'NOC from current landlord',
|
||||
'Municipal approvals',
|
||||
'Fire safety certificate',
|
||||
'Pollution clearance',
|
||||
'Layout/Floor plan of new location',
|
||||
'Photos of new location',
|
||||
'Locality map',
|
||||
'Building plan approval',
|
||||
'Electricity connection documents',
|
||||
'Water supply documents'
|
||||
];
|
||||
|
||||
const isDocumentMappedToRequirement = (doc: any, requirement: string) => {
|
||||
if (!doc || !requirement) return false;
|
||||
const docType = String(doc.type || '').trim().toLowerCase();
|
||||
const docName = String(doc.name || '').trim().toLowerCase();
|
||||
const req = requirement.toLowerCase();
|
||||
if (docType === req) return true;
|
||||
// Keep matching behavior aligned with current frontend checklist logic
|
||||
const reqFirstToken = req.split(' ')[0];
|
||||
return Boolean(reqFirstToken) && (docType.includes(reqFirstToken) || docName.includes(reqFirstToken));
|
||||
};
|
||||
|
||||
const getRelocationDocumentReadiness = (documents: any[]) => {
|
||||
const docs = Array.isArray(documents) ? documents : [];
|
||||
const missingUploads: string[] = [];
|
||||
const pendingVerification: string[] = [];
|
||||
|
||||
for (const requirement of REQUIRED_RELOCATION_DOCUMENTS) {
|
||||
const matchedDocs = docs.filter((d: any) => isDocumentMappedToRequirement(d, requirement));
|
||||
if (!matchedDocs.length) {
|
||||
missingUploads.push(requirement);
|
||||
continue;
|
||||
}
|
||||
const hasVerified = matchedDocs.some((d: any) => String(d.status || '').toLowerCase() === 'verified');
|
||||
if (!hasVerified) {
|
||||
pendingVerification.push(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequired: REQUIRED_RELOCATION_DOCUMENTS.length,
|
||||
uploadedCount: REQUIRED_RELOCATION_DOCUMENTS.length - missingUploads.length,
|
||||
verifiedCount: REQUIRED_RELOCATION_DOCUMENTS.length - pendingVerification.length - missingUploads.length,
|
||||
missingUploads,
|
||||
pendingVerification
|
||||
};
|
||||
};
|
||||
|
||||
export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
@ -143,6 +195,54 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
const finalState = proposedState || newState;
|
||||
const finalRelocationType = relocationType || 'Intercity';
|
||||
|
||||
if (!outletId) {
|
||||
return res.status(400).json({ success: false, message: 'Outlet is required' });
|
||||
}
|
||||
|
||||
const outlet = await Outlet.findByPk(outletId, { attributes: ['id', 'dealerId', 'status', 'name', 'code'] });
|
||||
if (!outlet) {
|
||||
return res.status(404).json({ success: false, message: 'Outlet not found' });
|
||||
}
|
||||
|
||||
// SRS §12.2.8 — only active, eligible outlets
|
||||
if (outlet.status !== OUTLET_STATUS.ACTIVE) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: `Relocation can only be requested for active outlets. Current outlet status: ${outlet.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const roleCode = req.user.roleCode as string;
|
||||
if (roleCode === ROLES.DEALER && String(outlet.dealerId) !== String(req.user.id)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'You can only submit a relocation request for outlets assigned to your dealership account.'
|
||||
});
|
||||
}
|
||||
if (roleCode !== ROLES.DEALER && roleCode !== ROLES.SUPER_ADMIN) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Only a dealer may initiate a relocation request (or Super Admin for support).'
|
||||
});
|
||||
}
|
||||
|
||||
// SRS §12.2.7 — prevent parallel / duplicate open relocations for the same outlet
|
||||
const openExisting = await RelocationRequest.findOne({
|
||||
where: {
|
||||
outletId,
|
||||
status: { [Op.notIn]: ['Completed', 'Rejected'] }
|
||||
},
|
||||
attributes: ['id', 'requestId', 'status', 'currentStage']
|
||||
});
|
||||
if (openExisting) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message:
|
||||
'An active relocation request already exists for this outlet. Complete or reject it before submitting a new one.',
|
||||
existingRequestId: openExisting.requestId
|
||||
});
|
||||
}
|
||||
|
||||
const requestId = NomenclatureService.generateRelocationId();
|
||||
|
||||
const request = await RelocationRequest.create({
|
||||
@ -171,6 +271,18 @@ export const submitRequest = async (req: AuthRequest, res: Response) => {
|
||||
// Auto-assign evaluators based on outlet location hierarchy
|
||||
await assignRelocationEvaluators(request.id, outletId);
|
||||
|
||||
await db.RelocationAudit.create({
|
||||
userId: req.user.id,
|
||||
relocationRequestId: request.id,
|
||||
action: AUDIT_ACTIONS.CREATED,
|
||||
remarks: 'Relocation request submitted',
|
||||
details: {
|
||||
requestId: request.requestId,
|
||||
stage: RELOCATION_STAGES.ASM_REVIEW,
|
||||
status: request.status
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Relocation request submitted successfully',
|
||||
@ -395,10 +507,13 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const { id } = req.params;
|
||||
const { action, comments } = req.body;
|
||||
const { action, comments, remarks } = req.body;
|
||||
const reviewComments = (comments ?? remarks ?? '') as string;
|
||||
|
||||
// Normalize action to uppercase for service consistency (APPROVE/REJECT)
|
||||
const normalizedAction = action?.toUpperCase() || '';
|
||||
const normalizedAction = String(action || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
// Check if id is a UUID or a requestId string
|
||||
const idStr = String(id);
|
||||
@ -412,6 +527,32 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
return res.status(404).json({ success: false, message: 'Request not found' });
|
||||
}
|
||||
|
||||
if (request.status === 'Completed' || request.currentStage === RELOCATION_STAGES.COMPLETED) {
|
||||
return res.status(400).json({ success: false, message: 'This relocation request is already completed.' });
|
||||
}
|
||||
if (request.status === 'Rejected' || request.currentStage === RELOCATION_STAGES.REJECTED) {
|
||||
return res.status(400).json({ success: false, message: 'This relocation request is already rejected.' });
|
||||
}
|
||||
if (request.status === 'Revoked') {
|
||||
return res.status(400).json({ success: false, message: 'This relocation request has been revoked.' });
|
||||
}
|
||||
|
||||
const supportedActions = ['APPROVE', 'REJECT', 'SEND_BACK', 'REVOKE', 'HOLD'];
|
||||
if (!supportedActions.includes(normalizedAction)) {
|
||||
return res.status(400).json({ success: false, message: `Unsupported action: ${action}` });
|
||||
}
|
||||
if (normalizedAction === 'HOLD') {
|
||||
return res.status(501).json({ success: false, message: 'Hold is not implemented for relocation yet.' });
|
||||
}
|
||||
|
||||
// SRS §12.2.8 — Send Back / Revoke communicated through Work Notes with mandatory remarks
|
||||
if ((normalizedAction === 'SEND_BACK' || normalizedAction === 'REVOKE') && !String(reviewComments).trim()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Remarks are required for Send Back and Revoke.'
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Authorization Check via Workflow Service
|
||||
const canAction = await RelocationWorkflowService.canUserAction(request, req.user);
|
||||
if (!canAction) {
|
||||
@ -421,7 +562,6 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Update status and current_stage based on action
|
||||
let newStatus = request.status;
|
||||
let newCurrentStage = request.currentStage;
|
||||
|
||||
@ -438,35 +578,78 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.COMPLETED
|
||||
};
|
||||
|
||||
const reverseStageFlow: Record<string, string> = {
|
||||
'DD Admin Review': RELOCATION_STAGES.ASM_REVIEW,
|
||||
[RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.ASM_REVIEW,
|
||||
[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.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
};
|
||||
|
||||
if (normalizedAction === 'APPROVE') {
|
||||
newCurrentStage = stageFlow[request.currentStage] || request.currentStage;
|
||||
newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`;
|
||||
} else if (normalizedAction === 'REJECT') {
|
||||
newStatus = 'Rejected';
|
||||
newCurrentStage = RELOCATION_STAGES.REJECTED;
|
||||
} else if (normalizedAction === 'SEND_BACK') {
|
||||
const prevStage = reverseStageFlow[request.currentStage as string];
|
||||
if (!prevStage) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Send Back is not available at this stage (already at the first review step).'
|
||||
});
|
||||
}
|
||||
newCurrentStage = prevStage;
|
||||
newStatus = `Pending ${prevStage}`;
|
||||
} else if (normalizedAction === 'REVOKE') {
|
||||
newStatus = 'Revoked';
|
||||
newCurrentStage = RELOCATION_STAGES.REJECTED;
|
||||
}
|
||||
|
||||
// SRS §12.2.8 — enforce mandatory document submission + verification before late-stage approvals
|
||||
if (
|
||||
normalizedAction === 'APPROVE' &&
|
||||
(
|
||||
request.currentStage === RELOCATION_STAGES.NBH_APPROVAL ||
|
||||
request.currentStage === RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
)
|
||||
) {
|
||||
const readiness = getRelocationDocumentReadiness(request.documents || []);
|
||||
if (readiness.missingUploads.length || readiness.pendingVerification.length) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Mandatory relocation documents are incomplete or pending verification.',
|
||||
readiness
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform transition via Workflow Service (handles request update, timeline, audit logs)
|
||||
const progressSteps = 9;
|
||||
const currentStepIndex = Object.keys(stageFlow).indexOf(request.currentStage);
|
||||
const newProgress = normalizedAction === 'APPROVE'
|
||||
? Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100)
|
||||
: request.progressPercentage;
|
||||
const stageKeys = Object.keys(stageFlow);
|
||||
const currentStepIndex = stageKeys.indexOf(request.currentStage as string);
|
||||
const backStepIndex = stageKeys.indexOf(newCurrentStage as string);
|
||||
let newProgress = request.progressPercentage;
|
||||
if (normalizedAction === 'APPROVE') {
|
||||
newProgress = Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100);
|
||||
} else if (normalizedAction === 'SEND_BACK' && backStepIndex >= 0) {
|
||||
newProgress = Math.max(0, Math.min(100, Math.round(((backStepIndex + 1) / progressSteps) * 100)));
|
||||
}
|
||||
|
||||
await RelocationWorkflowService.transitionRelocation(request, newStatus, req.user?.id || null, {
|
||||
reason: comments || 'No remarks provided',
|
||||
reason: reviewComments || 'No remarks provided',
|
||||
stage: newCurrentStage,
|
||||
action: normalizedAction,
|
||||
progressPercentage: newProgress
|
||||
});
|
||||
|
||||
// 2.5 Auto-initiate EOR Checklist if moving to NBH_CLEARANCE_EOR
|
||||
// 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 {
|
||||
// Internal call to EOR controller logic (or we could use a service)
|
||||
// For now, simpler to just trigger the DB creation here or ensure controller handles it
|
||||
const { createChecklist } = await import('../eor/eor.controller.js');
|
||||
// We mock the req/res for internal call or just use the DB directly
|
||||
await db.EorChecklist.findOrCreate({
|
||||
where: { relocationId: request.id },
|
||||
defaults: {
|
||||
@ -475,24 +658,48 @@ export const takeAction = async (req: AuthRequest, res: Response) => {
|
||||
applicationId: null
|
||||
}
|
||||
});
|
||||
console.log(`[RelocationController] EOR Checklist initiated for ${request.requestId}`);
|
||||
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. Create a worknote entry for the comment
|
||||
if (comments) {
|
||||
// 3. Work note: mandatory for Send Back / Revoke; optional for other actions when remarks provided
|
||||
const shouldWriteWorknote =
|
||||
Boolean(String(reviewComments).trim()) &&
|
||||
(normalizedAction === 'SEND_BACK' ||
|
||||
normalizedAction === 'REVOKE' ||
|
||||
normalizedAction === 'APPROVE' ||
|
||||
normalizedAction === 'REJECT');
|
||||
if (shouldWriteWorknote) {
|
||||
const prefix =
|
||||
normalizedAction === 'SEND_BACK'
|
||||
? '[Send Back] '
|
||||
: normalizedAction === 'REVOKE'
|
||||
? '[Revoke] '
|
||||
: '';
|
||||
await Worknote.create({
|
||||
requestId: request.id,
|
||||
requestType: 'relocation' as any,
|
||||
userId: req.user.id,
|
||||
content: comments,
|
||||
isInternal: true
|
||||
noteText: `${prefix}${reviewComments}`,
|
||||
noteType: normalizedAction === 'SEND_BACK' || normalizedAction === 'REVOKE' ? 'workflow' : 'internal',
|
||||
status: 'active'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Request ${action.toLowerCase()} successfully` });
|
||||
const actionLabel =
|
||||
normalizedAction === 'SEND_BACK'
|
||||
? 'sent back'
|
||||
: normalizedAction === 'REVOKE'
|
||||
? 'revoked'
|
||||
: (normalizedAction || 'ACTION').toLowerCase();
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Request ${actionLabel} successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Take action error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error processing action' });
|
||||
@ -557,6 +764,26 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
await db.RelocationAudit.create({
|
||||
userId: req.user?.id || null,
|
||||
relocationRequestId: request.id,
|
||||
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
|
||||
remarks: `Uploaded: ${documentType}`,
|
||||
details: {
|
||||
stage: request.currentStage,
|
||||
documentType,
|
||||
fileName: file.originalname,
|
||||
documentId: newDoc.id
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
|
||||
await ensureRelocationEorChecklistSeeded(request.id);
|
||||
} catch (e) {
|
||||
console.error('[RelocationController] EOR checklist sync after upload:', e);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Document uploaded successfully',
|
||||
@ -568,60 +795,69 @@ export const uploadDocuments = async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyDocument = async (req: AuthRequest, res: Response) => {
|
||||
const applyRelocationDocumentDecision = async (
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
targetStatus: 'Verified' | 'Rejected'
|
||||
) => {
|
||||
try {
|
||||
const { id, documentId } = req.params;
|
||||
const { remarks } = req.body || {};
|
||||
|
||||
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
|
||||
const idStr = String(id);
|
||||
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idStr);
|
||||
|
||||
// Search by UUID or requestId for the request
|
||||
const request = await RelocationRequest.findOne({
|
||||
where: isUUID ? { [Op.or]: [{ id: idStr }, { requestId: idStr }] } : { requestId: idStr }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ success: false, message: 'Relocation request not found' });
|
||||
}
|
||||
|
||||
// Authorization: Non-dealers only, and ideally matching the current stage
|
||||
const isInternal = req.user.roleCode !== 'Dealer';
|
||||
if (!isInternal) {
|
||||
return res.status(403).json({ success: false, message: 'Forbidden: Dealers cannot verify documents' });
|
||||
return res.status(403).json({ success: false, message: 'Forbidden: Dealers cannot verify or reject documents' });
|
||||
}
|
||||
if (targetStatus === 'Rejected' && !String(remarks || '').trim()) {
|
||||
return res.status(400).json({ success: false, message: 'Remarks are required when rejecting a document' });
|
||||
}
|
||||
|
||||
// Find and update the Document record
|
||||
const docRecord = await RelocationDocument.findByPk(documentId);
|
||||
if (docRecord) {
|
||||
await docRecord.update({ status: 'Verified' });
|
||||
await docRecord.update({ status: targetStatus });
|
||||
}
|
||||
|
||||
// Update the document entry in the request's JSON JSON array
|
||||
const currentDocuments = request.documents || [];
|
||||
const documentIndex = currentDocuments.findIndex((d: any) => d.id === documentId);
|
||||
if (documentIndex === -1) {
|
||||
return res.status(404).json({ success: false, message: 'Document not found in request tracker' });
|
||||
}
|
||||
|
||||
if (documentIndex !== -1) {
|
||||
currentDocuments[documentIndex].status = 'Verified';
|
||||
currentDocuments[documentIndex].status = targetStatus;
|
||||
currentDocuments[documentIndex].verifiedBy = req.user.fullName;
|
||||
currentDocuments[documentIndex].verifiedOn = new Date();
|
||||
if (targetStatus === 'Rejected') {
|
||||
currentDocuments[documentIndex].rejectionRemarks = remarks || 'Rejected by reviewer';
|
||||
}
|
||||
|
||||
// Add simple timeline log for document verification in same update
|
||||
const actionText = targetStatus === 'Verified' ? 'Document Verified' : 'Document Rejected';
|
||||
const updatedTimeline = [...(request.timeline || []), {
|
||||
stage: request.currentStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Document Verified',
|
||||
remarks: `Verified document: ${currentDocuments[documentIndex].name}`
|
||||
action: actionText,
|
||||
remarks:
|
||||
targetStatus === 'Verified'
|
||||
? `Verified document: ${currentDocuments[documentIndex].name}`
|
||||
: `Rejected document: ${currentDocuments[documentIndex].name}. ${remarks || ''}`.trim()
|
||||
}];
|
||||
|
||||
// Calculate progress percentage
|
||||
const totalRequired = 12; // Standard relocation requirement
|
||||
const totalRequired = REQUIRED_RELOCATION_DOCUMENTS.length;
|
||||
const verifiedCount = currentDocuments.filter((d: any) => d.status === 'Verified').length;
|
||||
const progressPercentage = Math.min(Math.round((verifiedCount / totalRequired) * 100), 100);
|
||||
|
||||
// Update request status to 'In Progress' if it was 'Pending'
|
||||
let newStatus = request.status;
|
||||
if (request.status === 'Pending') {
|
||||
newStatus = 'In Progress';
|
||||
@ -635,27 +871,52 @@ export const verifyDocument = async (req: AuthRequest, res: Response) => {
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
// Force Sequelize to detect JSON changes
|
||||
request.changed('documents', true);
|
||||
request.changed('timeline', true);
|
||||
await request.save();
|
||||
|
||||
await db.RelocationAudit.create({
|
||||
userId: req.user?.id || null,
|
||||
relocationRequestId: request.id,
|
||||
action: targetStatus === 'Verified' ? AUDIT_ACTIONS.DOCUMENT_VERIFIED : AUDIT_ACTIONS.DOCUMENT_REJECTED,
|
||||
remarks:
|
||||
targetStatus === 'Verified'
|
||||
? `Verified: ${currentDocuments[documentIndex].name}`
|
||||
: `Rejected: ${currentDocuments[documentIndex].name}. ${remarks || ''}`.trim(),
|
||||
details: {
|
||||
stage: request.currentStage,
|
||||
documentId,
|
||||
documentName: currentDocuments[documentIndex].name,
|
||||
status: targetStatus
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const { ensureRelocationEorChecklistSeeded } = await import('../eor/eor.controller.js');
|
||||
await ensureRelocationEorChecklistSeeded(request.id);
|
||||
} catch (e) {
|
||||
console.error('[RelocationController] EOR checklist sync after document decision:', e);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Document verified successfully',
|
||||
message: targetStatus === 'Verified' ? 'Document verified successfully' : 'Document rejected successfully',
|
||||
document: currentDocuments[documentIndex],
|
||||
progressPercentage,
|
||||
status: newStatus
|
||||
});
|
||||
}
|
||||
|
||||
res.status(404).json({ success: false, message: 'Document not found in request tracker' });
|
||||
} catch (error) {
|
||||
console.error('Verify document error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error verifying document' });
|
||||
console.error('Relocation document decision error:', error);
|
||||
return res.status(500).json({ success: false, message: 'Error processing document decision' });
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyDocument = async (req: AuthRequest, res: Response) =>
|
||||
applyRelocationDocumentDecision(req, res, 'Verified');
|
||||
|
||||
export const rejectDocument = async (req: AuthRequest, res: Response) =>
|
||||
applyRelocationDocumentDecision(req, res, 'Rejected');
|
||||
|
||||
// Helper function to calculate distance between two coordinates
|
||||
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371; // Radius of Earth in km
|
||||
|
||||
@ -12,5 +12,6 @@ router.get('/:id', authenticate as any, relocationController.getRequestById);
|
||||
router.post('/:id/action', authenticate as any, relocationController.takeAction);
|
||||
router.post('/:id/documents', authenticate as any, uploadSingle, relocationController.uploadDocuments);
|
||||
router.post('/:id/documents/:documentId/verify', authenticate as any, relocationController.verifyDocument);
|
||||
router.post('/:id/documents/:documentId/reject', authenticate as any, relocationController.rejectDocument);
|
||||
|
||||
export default router;
|
||||
@ -17,6 +17,7 @@ import { ResignationWorkflowService } from '../../services/ResignationWorkflowSe
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
import { ParticipantService } from '../../services/ParticipantService.js';
|
||||
import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||
|
||||
// Removed generateResignationId and moved to NomenclatureService
|
||||
|
||||
@ -316,6 +317,8 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' });
|
||||
}
|
||||
|
||||
const sourceStage = resignation.currentStage;
|
||||
|
||||
// Transition via Workflow Service
|
||||
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
|
||||
remarks,
|
||||
@ -374,6 +377,20 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
try {
|
||||
const noteText = String(remarks || '').trim() || `[Approved] ${sourceStage} → ${nextStage}`;
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: resignation.id,
|
||||
requestType: 'resignation',
|
||||
userId: req.user.id,
|
||||
noteText,
|
||||
noteType: 'internal'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
logger.error('[resignation] workflow worknote (approve):', wnErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Resignation approved successfully', nextStage, resignation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
@ -413,6 +430,19 @@ export const rejectResignation = async (req: AuthRequest, res: Response, next: N
|
||||
|
||||
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
|
||||
await transaction.commit();
|
||||
|
||||
try {
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: resignation.id,
|
||||
requestType: 'resignation',
|
||||
userId: req.user.id,
|
||||
noteText: String(reason || '').trim(),
|
||||
noteType: 'internal'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
logger.error('[resignation] workflow worknote (reject):', wnErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Resignation rejected', resignation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
@ -464,6 +494,22 @@ export const withdrawResignation = async (req: AuthRequest, res: Response, next:
|
||||
|
||||
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
|
||||
await transaction.commit();
|
||||
|
||||
try {
|
||||
const noteText = String(reason || '').trim()
|
||||
? `[Withdrawn] ${String(reason).trim()}`
|
||||
: '[Withdrawn]';
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: resignation.id,
|
||||
requestType: 'resignation',
|
||||
userId: req.user.id,
|
||||
noteText,
|
||||
noteType: 'workflow'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
logger.error('[resignation] workflow worknote (withdraw):', wnErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Resignation withdrawn successfully' });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
@ -512,6 +558,21 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next:
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
try {
|
||||
const r = String(remarks || '').trim();
|
||||
const noteText = r ? `[Send Back] ${r}` : `[Send Back] Returned to ${prevStage}`;
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: resignation.id,
|
||||
requestType: 'resignation',
|
||||
userId: req.user.id,
|
||||
noteText,
|
||||
noteType: 'workflow'
|
||||
});
|
||||
} catch (wnErr) {
|
||||
logger.error('[resignation] workflow worknote (send back):', wnErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Resignation sent back to ${prevStage}` });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
|
||||
@ -18,5 +18,7 @@ router.get('/relocation', authenticate as any, relocationController.getRequests)
|
||||
router.get('/relocation/:id', authenticate as any, relocationController.getRequestById);
|
||||
router.post('/relocation/:id/action', authenticate as any, relocationController.takeAction);
|
||||
router.post('/relocation/:id/documents', authenticate as any, relocationController.uploadDocuments);
|
||||
router.post('/relocation/:id/documents/:documentId/verify', authenticate as any, relocationController.verifyDocument);
|
||||
router.post('/relocation/:id/documents/:documentId/reject', authenticate as any, relocationController.rejectDocument);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -6,6 +6,7 @@ import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINA
|
||||
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
|
||||
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||
import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js';
|
||||
|
||||
export const getDepartments = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@ -40,6 +41,13 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
|
||||
const payment = await FinancePayment.findByPk(id);
|
||||
if (!payment) return res.status(404).json({ success: false, message: 'Payment not found' });
|
||||
|
||||
const previousPaymentSnapshot = {
|
||||
paymentStatus: payment.paymentStatus,
|
||||
paymentType: payment.paymentType,
|
||||
amount: payment.amount,
|
||||
transactionId: payment.transactionId,
|
||||
};
|
||||
|
||||
const isVerifying = status === 'Paid' && payment.paymentStatus !== 'Paid';
|
||||
|
||||
await payment.update({
|
||||
@ -61,6 +69,24 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
|
||||
]
|
||||
});
|
||||
|
||||
const p = updatedPayment || payment;
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id || null,
|
||||
action: AUDIT_ACTIONS.PAYMENT_UPDATED,
|
||||
entityType: 'application',
|
||||
entityId: payment.applicationId,
|
||||
oldData: { paymentId: payment.id, ...previousPaymentSnapshot },
|
||||
newData: {
|
||||
paymentId: payment.id,
|
||||
paymentType: p.paymentType,
|
||||
paymentStatus: p.paymentStatus,
|
||||
financeVerified: !!isVerifying,
|
||||
amount: p.amount,
|
||||
transactionId: p.transactionId,
|
||||
remarks: p.remarks,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Payment updated successfully', data: updatedPayment });
|
||||
} catch (error) {
|
||||
console.error('Update payment error:', error);
|
||||
|
||||
@ -16,6 +16,7 @@ import { TerminationWorkflowService } from '../../services/TerminationWorkflowSe
|
||||
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
||||
import { ParticipantService } from '../../services/ParticipantService.js';
|
||||
import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
|
||||
|
||||
// Create termination request
|
||||
export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
@ -246,6 +247,9 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
return res.status(404).json({ success: false, message: 'Termination not found' });
|
||||
}
|
||||
|
||||
const fromStage = termination.currentStage;
|
||||
let approvedToStage: string | null = null;
|
||||
|
||||
if (action === 'reject') {
|
||||
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
||||
action: 'Rejected',
|
||||
@ -276,6 +280,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
||||
}
|
||||
|
||||
approvedToStage = nextStage;
|
||||
|
||||
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
||||
remarks,
|
||||
status: getTerminationStatusForStage(nextStage)
|
||||
@ -303,6 +309,32 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
try {
|
||||
if (action === 'reject') {
|
||||
const noteText = String(remarks || '').trim() || `[Rejected] at ${fromStage}`;
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: termination.id,
|
||||
requestType: 'termination',
|
||||
userId: req.user.id,
|
||||
noteText,
|
||||
noteType: 'internal'
|
||||
});
|
||||
} else if (approvedToStage) {
|
||||
const noteText =
|
||||
String(remarks || '').trim() || `[Approved] ${fromStage} → ${approvedToStage}`;
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: termination.id,
|
||||
requestType: 'termination',
|
||||
userId: req.user.id,
|
||||
noteText,
|
||||
noteType: 'internal'
|
||||
});
|
||||
}
|
||||
} catch (wnErr) {
|
||||
logger.error('[termination] workflow worknote:', wnErr);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Termination updated', termination });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
|
||||
@ -6,8 +6,12 @@ export class ConstitutionalWorkflowService {
|
||||
* 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 } = options;
|
||||
const { action, status, remarks, userFullName, auditAction: explicitAuditAction } = options;
|
||||
const sourceStage = request.currentStage;
|
||||
const actionLower = String(action || '').toLowerCase();
|
||||
const resolvedAuditAction =
|
||||
explicitAuditAction ??
|
||||
(actionLower.includes('reject') ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED);
|
||||
|
||||
const updatedTimeline = [
|
||||
...(request.timeline || []),
|
||||
@ -21,9 +25,19 @@ export class ConstitutionalWorkflowService {
|
||||
}
|
||||
];
|
||||
|
||||
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: status || (targetStage === CONSTITUTIONAL_STAGES.COMPLETED ? 'Completed' : targetStage),
|
||||
status: resolvedStatus,
|
||||
progressPercentage: this.calculateProgress(targetStage),
|
||||
timeline: updatedTimeline,
|
||||
updatedAt: new Date()
|
||||
@ -35,7 +49,7 @@ export class ConstitutionalWorkflowService {
|
||||
await db.ConstitutionalAudit.create({
|
||||
userId,
|
||||
constitutionalChangeId: request.id,
|
||||
action: action === 'Reject' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED,
|
||||
action: resolvedAuditAction,
|
||||
remarks: remarks || '',
|
||||
details: { status: updateData.status, stage: sourceStage, targetStage: targetStage }
|
||||
});
|
||||
@ -57,7 +71,8 @@ export class ConstitutionalWorkflowService {
|
||||
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85,
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95,
|
||||
[CONSTITUTIONAL_STAGES.COMPLETED]: 100,
|
||||
[CONSTITUTIONAL_STAGES.REJECTED]: 0
|
||||
[CONSTITUTIONAL_STAGES.REJECTED]: 0,
|
||||
[CONSTITUTIONAL_STAGES.REVOKED]: 0
|
||||
};
|
||||
return progress[stage] || 0;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ export class RelocationWorkflowService {
|
||||
*/
|
||||
static async transitionRelocation(request: any, targetStatus: string, userId: string | null = null, metadata: any = {}) {
|
||||
const previousStatus = request.status;
|
||||
const { reason, stage, progressPercentage, action } = metadata;
|
||||
const { reason, stage, progressPercentage, action, auditAction } = metadata;
|
||||
|
||||
const updateData: any = {
|
||||
status: targetStatus,
|
||||
@ -45,10 +45,21 @@ export class RelocationWorkflowService {
|
||||
await request.update({ timeline: updatedTimeline });
|
||||
|
||||
// 3. Create Audit Log
|
||||
let resolvedAuditAction: string = AUDIT_ACTIONS.APPROVED;
|
||||
if (auditAction) {
|
||||
resolvedAuditAction = auditAction;
|
||||
} else if (action === 'REJECT') {
|
||||
resolvedAuditAction = AUDIT_ACTIONS.REJECTED;
|
||||
} else if (action === 'REVOKE') {
|
||||
resolvedAuditAction = AUDIT_ACTIONS.RELOCATION_REVOKED;
|
||||
} else if (action === 'SEND_BACK') {
|
||||
resolvedAuditAction = AUDIT_ACTIONS.RELOCATION_SENT_BACK;
|
||||
}
|
||||
|
||||
await db.RelocationAudit.create({
|
||||
userId: userId,
|
||||
relocationRequestId: request.id,
|
||||
action: action === 'REJECT' ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.APPROVED,
|
||||
action: resolvedAuditAction,
|
||||
remarks: reason || '',
|
||||
details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus }
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { AuditLog, User, Worknote } = db;
|
||||
const { AuditLog, User } = db;
|
||||
import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js';
|
||||
import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
@ -54,16 +54,6 @@ export class ResignationWorkflowService {
|
||||
details: { status: updateData.status, stage: sourceStage, targetStage: targetStage }
|
||||
});
|
||||
|
||||
// 4. Create Worknote if it's a "Sent Back" action for communication
|
||||
if (action === 'Sent Back') {
|
||||
await Worknote.create({
|
||||
requestId: resignation.id,
|
||||
requestType: 'resignation',
|
||||
userId: userId,
|
||||
note: `Resignation sent back to ${targetStage}. Comments: ${remarks}`
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`);
|
||||
|
||||
// 5. Send Notifications
|
||||
|
||||
@ -114,7 +114,7 @@ export class WorkflowIntegrityService {
|
||||
});
|
||||
|
||||
if (policyMet && deposit) {
|
||||
console.log(`[WorkflowIntegrityService] Policy met and Payment Verified for LOI on ${application.applicationId}. Transitioning to LOI Issued...`);
|
||||
console.log(`[WorkflowIntegrityService] Policy met and payment verified for LOI on ${application.applicationId}. Aligning to Security Details for admin approval before LOI Issued.`);
|
||||
|
||||
// Ensure LoiRequest is also updated
|
||||
const request = await db.LoiRequest.findOne({ where: { applicationId: application.id } });
|
||||
@ -122,9 +122,9 @@ export class WorkflowIntegrityService {
|
||||
await request.update({ status: 'Approved', approvedAt: new Date() });
|
||||
}
|
||||
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.LOI_ISSUED, null, {
|
||||
reason: 'Auto-transitioned by Integrity Service: Policy and Payment criteria met.',
|
||||
progressPercentage: 80
|
||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.SECURITY_DETAILS, null, {
|
||||
reason: 'Integrity sync: LOI policy and deposit verified — use Security Details admin approval to reach LOI Issued.',
|
||||
progressPercentage: 78
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import db from '../database/models/index.js';
|
||||
const {
|
||||
Application, ApplicationStatusHistory, AuditLog,
|
||||
User, Dealer
|
||||
} = db;
|
||||
import { syncApplicationProgress } from '../common/utils/progress.js';
|
||||
import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js';
|
||||
const { Application, ApplicationStatusHistory, User, Dealer } = db;
|
||||
import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../common/utils/progress.js';
|
||||
import {
|
||||
AUDIT_ACTIONS,
|
||||
APPLICATION_STAGES,
|
||||
OVERALL_STATUS_TO_DB_CURRENT_STAGE,
|
||||
} from '../common/config/constants.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
|
||||
|
||||
export class WorkflowService {
|
||||
/**
|
||||
@ -14,7 +16,9 @@ export class WorkflowService {
|
||||
*/
|
||||
static async transitionApplication(application: any, targetStatus: string, userId: string | null = null, metadata: any = {}) {
|
||||
const previousStatus = application.overallStatus;
|
||||
const { reason, stage, progressPercentage, forceLog } = metadata;
|
||||
const previousStage = application.currentStage;
|
||||
const previousProgress = application.progressPercentage;
|
||||
const { reason, stage, progressPercentage, forceLog, transitionSource } = metadata;
|
||||
|
||||
// Skip redundant history logging if status is identical (unless forced)
|
||||
if (targetStatus === previousStatus && !forceLog) {
|
||||
@ -22,14 +26,28 @@ export class WorkflowService {
|
||||
return application;
|
||||
}
|
||||
|
||||
const allowedStages = new Set<string>(Object.values(APPLICATION_STAGES) as string[]);
|
||||
|
||||
/** Role-based gates (NBH, DD Head, …) — only these may override when explicitly passed. */
|
||||
const explicitStage =
|
||||
stage && allowedStages.has(stage as string) ? (stage as string) : null;
|
||||
/** Progress-tracker label (not necessarily a DB enum value). */
|
||||
const pipelineStageLabel = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[targetStatus];
|
||||
/** Value allowed by Postgres enum `applications.currentStage`. */
|
||||
const dbStageFromOverallStatus = OVERALL_STATUS_TO_DB_CURRENT_STAGE[targetStatus];
|
||||
const stageForDbColumn =
|
||||
explicitStage ||
|
||||
(pipelineStageLabel && allowedStages.has(pipelineStageLabel) ? pipelineStageLabel : null) ||
|
||||
dbStageFromOverallStatus ||
|
||||
null;
|
||||
|
||||
const updateData: any = {
|
||||
overallStatus: targetStatus,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
// Update stage if provided and valid
|
||||
if (stage && Object.values(APPLICATION_STAGES).includes(stage)) {
|
||||
updateData.currentStage = stage;
|
||||
if (stageForDbColumn && allowedStages.has(stageForDbColumn)) {
|
||||
updateData.currentStage = stageForDbColumn;
|
||||
}
|
||||
|
||||
// Update progress percentage if explicitly provided
|
||||
@ -37,6 +55,11 @@ export class WorkflowService {
|
||||
updateData.progressPercentage = progressPercentage;
|
||||
}
|
||||
|
||||
const nextProgress =
|
||||
progressPercentage !== undefined ? Number(progressPercentage) : previousProgress;
|
||||
|
||||
const contextBefore = pickApplicationAuditContext(application);
|
||||
|
||||
// 1. Update Application Record
|
||||
await application.update(updateData);
|
||||
|
||||
@ -49,37 +72,47 @@ export class WorkflowService {
|
||||
changeReason: reason || `Transitioned to ${targetStatus}`
|
||||
});
|
||||
|
||||
// 3. Create High-Fidelity Audit Log
|
||||
await AuditLog.create({
|
||||
const contextAfter = pickApplicationAuditContext(application);
|
||||
|
||||
// 3. Audit log — non-fatal: must not roll back or block the transition
|
||||
await safeAuditLogCreate({
|
||||
userId: userId,
|
||||
action: AUDIT_ACTIONS.UPDATED,
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
oldData: {
|
||||
status: previousStatus,
|
||||
stage: application.currentStage,
|
||||
progress: application.progressPercentage
|
||||
stage: previousStage,
|
||||
progress: previousProgress,
|
||||
context: contextBefore,
|
||||
},
|
||||
newData: {
|
||||
status: targetStatus,
|
||||
stage: stage || application.currentStage,
|
||||
progress: progressPercentage ?? application.progressPercentage,
|
||||
reason: reason || `Transitioned to ${targetStatus}`
|
||||
stage: stageForDbColumn ?? previousStage,
|
||||
...(pipelineStageLabel &&
|
||||
pipelineStageLabel !== (stageForDbColumn ?? previousStage)
|
||||
? { pipelineStage: pipelineStageLabel }
|
||||
: {}),
|
||||
progress: nextProgress,
|
||||
reason: reason || `Transitioned to ${targetStatus}`,
|
||||
context: contextAfter,
|
||||
transitionSource: transitionSource || metadata?.source || 'WorkflowService.transitionApplication',
|
||||
},
|
||||
metadata: {
|
||||
...metadata,
|
||||
timestamp: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Synchronize Progress Tracker (The true source of truth for the frontend UI)
|
||||
// 4. Progress sync — non-fatal (DB state is already committed)
|
||||
try {
|
||||
await syncApplicationProgress(application.id, targetStatus);
|
||||
} catch (syncErr) {
|
||||
console.error('[WorkflowService] syncApplicationProgress failed (non-fatal):', syncErr);
|
||||
}
|
||||
|
||||
// 5. Send Status Update Notification (Intelligent Template Selection)
|
||||
// 5. Notifications — non-fatal
|
||||
if (application.email) {
|
||||
try {
|
||||
const user = await User.findOne({
|
||||
where: { email: application.email },
|
||||
attributes: ['id']
|
||||
attributes: ['id'],
|
||||
});
|
||||
const targetUserId = user ? user.id : null;
|
||||
|
||||
@ -99,9 +132,12 @@ export class WorkflowService {
|
||||
applicationId: application.applicationId,
|
||||
reason: reason || 'N/A',
|
||||
salesCode: application.dealerCode?.salesCode || 'N/A',
|
||||
serviceCode: application.dealerCode?.serviceCode || 'N/A'
|
||||
}
|
||||
serviceCode: application.dealerCode?.serviceCode || 'N/A',
|
||||
},
|
||||
});
|
||||
} catch (notifyErr) {
|
||||
console.error('[WorkflowService] Notification failed (non-fatal):', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);
|
||||
|
||||
39
src/services/applicationAuditLog.service.ts
Normal file
39
src/services/applicationAuditLog.service.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import db from '../database/models/index.js';
|
||||
|
||||
const { AuditLog } = db;
|
||||
|
||||
/** Fields that help explain pipeline / FDD / statutory state without large payloads */
|
||||
export function pickApplicationAuditContext(app: any): Record<string, unknown> {
|
||||
if (!app) return {};
|
||||
return {
|
||||
applicationHumanId: app.applicationId ?? null,
|
||||
overallStatus: app.overallStatus ?? null,
|
||||
currentStage: app.currentStage ?? null,
|
||||
progressPercentage: app.progressPercentage ?? null,
|
||||
statutoryStatus: app.statutoryStatus ?? null,
|
||||
architectureStatus: app.architectureStatus ?? null,
|
||||
isShortlisted: app.isShortlisted ?? null,
|
||||
opportunityId: app.opportunityId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type SafeAuditPayload = {
|
||||
userId?: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
oldData?: Record<string, unknown> | null;
|
||||
newData?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes an audit row and never throws — avoids breaking sensitive workflows
|
||||
* (status transitions, prospective uploads, finance verification) if audit storage fails.
|
||||
*/
|
||||
export async function safeAuditLogCreate(payload: SafeAuditPayload): Promise<void> {
|
||||
try {
|
||||
await AuditLog.create(payload as any);
|
||||
} catch (err) {
|
||||
console.error('[safeAuditLogCreate] Non-fatal audit failure:', (err as Error)?.message || err);
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,7 @@ const EMAILS = {
|
||||
DEALER: args.dealerEmail || "dealer@royalenfield.com",
|
||||
ASM: args.asmEmail || "asm.sdelhi@royalenfield.com",
|
||||
RBM: args.rbmEmail || "rbm.ncr@royalenfield.com",
|
||||
DD_ZM: args.ddZmEmail || "ddzm.ncr@royalenfield.com",
|
||||
DD_ZM: args.ddZmEmail || "zm.ncr@royalenfield.com",
|
||||
ZBH: args.zbhEmail || "yashwin@gmail.com",
|
||||
DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com",
|
||||
DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com",
|
||||
@ -24,7 +24,7 @@ const EMAILS = {
|
||||
const ROLE_BY_STAGE = {
|
||||
"ASM Review": ["ASM"],
|
||||
"RBM Review": ["RBM"],
|
||||
"DD ZM Review": ["DD_ZM", "RBM"],
|
||||
"DD ZM Review": ["DD_ZM"],
|
||||
"ZBH Review": ["ZBH"],
|
||||
"DD Lead Review": ["DD_LEAD"],
|
||||
"DD Head Approval": ["DD_HEAD"],
|
||||
@ -68,9 +68,13 @@ async function approveCurrentStage(requestId, stageName) {
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
const attempts = [];
|
||||
for (const roleKey of candidateRoles) {
|
||||
const email = EMAILS[roleKey];
|
||||
if (!email) continue;
|
||||
if (!email) {
|
||||
attempts.push(`${roleKey}:missing-email`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const token = await login(email);
|
||||
const res = await apiRequest(`/self-service/relocation/${requestId}/action`, "POST", {
|
||||
@ -80,9 +84,13 @@ async function approveCurrentStage(requestId, stageName) {
|
||||
return { roleKey, email, message: res.message || "Approved" };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
attempts.push(`${roleKey}:${error.message}`);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error(`Approval failed for stage: ${stageName}`);
|
||||
throw new Error(
|
||||
`Approval failed for stage: ${stageName}. Attempts -> ${attempts.join(" | ")}`
|
||||
+ (lastError ? ` | Last error: ${lastError.message}` : "")
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDealerOutlet(dealerToken) {
|
||||
|
||||
@ -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-26 10-08-00.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);
|
||||
@ -389,172 +389,172 @@ async function triggerWorkflow() {
|
||||
await delay(1000);
|
||||
|
||||
// 7.5 LOI APPROVAL
|
||||
log(7.5, 'LOI Generation & Approval...');
|
||||
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
const loiRequestId = loiRes.data.id;
|
||||
// log(7.5, 'LOI Generation & Approval...');
|
||||
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
// const loiRequestId = loiRes.data.id;
|
||||
|
||||
// Head Approval
|
||||
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'Head Authorization for LOI'
|
||||
}, headToken);
|
||||
// // Head Approval
|
||||
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'Head Authorization for LOI'
|
||||
// }, headToken);
|
||||
|
||||
// NBH Approval
|
||||
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'NBH Authorization for LOI'
|
||||
}, nbhToken);
|
||||
// // NBH Approval
|
||||
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'NBH Authorization for LOI'
|
||||
// }, nbhToken);
|
||||
|
||||
log(7.5, 'LOI Milestone Complete.');
|
||||
await delay();
|
||||
// log(7.5, 'LOI Milestone Complete.');
|
||||
// await delay();
|
||||
|
||||
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
||||
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
||||
const financeToken = await login(EMAILS.FINANCE);
|
||||
await apiRequest('/loa/security-deposit', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
amount: 500000,
|
||||
paymentReference: 'PAY-888999',
|
||||
depositType: 'SECURITY_DEPOSIT',
|
||||
status: 'Verified'
|
||||
}, financeToken);
|
||||
log(8, 'Security Deposit Verified.');
|
||||
await delay();
|
||||
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
||||
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
||||
// const financeToken = await login(EMAILS.FINANCE);
|
||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// amount: 500000,
|
||||
// paymentReference: 'PAY-888999',
|
||||
// depositType: 'SECURITY_DEPOSIT',
|
||||
// status: 'Verified'
|
||||
// }, financeToken);
|
||||
// log(8, 'Security Deposit Verified.');
|
||||
// await delay();
|
||||
|
||||
// 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK)
|
||||
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
||||
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
||||
await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
||||
await delay(300);
|
||||
// // 9. GENERATE DEALER CODES (NOW RETRY-SAFE WITH STATUS CHECK)
|
||||
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
||||
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
||||
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
||||
// await delay(300);
|
||||
|
||||
if (statusBeforeCodeGen === 'Security Details') {
|
||||
log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
|
||||
await apiRequest('/loa/security-deposit', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
amount: 500000,
|
||||
paymentReference: `PAY-RETRY-${Date.now()}`,
|
||||
depositType: 'SECURITY_DEPOSIT',
|
||||
status: 'Verified'
|
||||
}, financeToken);
|
||||
await delay();
|
||||
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
||||
}
|
||||
// if (statusBeforeCodeGen === 'Security Details') {
|
||||
// log(9, 'Status is Security Details; re-verifying Security Deposit to move to LOI Issued...');
|
||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// amount: 500000,
|
||||
// paymentReference: `PAY-RETRY-${Date.now()}`,
|
||||
// depositType: 'SECURITY_DEPOSIT',
|
||||
// status: 'Verified'
|
||||
// }, financeToken);
|
||||
// await delay();
|
||||
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
||||
// }
|
||||
|
||||
log(9, 'Admin Generating SAP Dealer Codes...');
|
||||
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
||||
log(9, 'Dealer Codes Generated.');
|
||||
await delay();
|
||||
// log(9, 'Admin Generating SAP Dealer Codes...');
|
||||
// await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
||||
// log(9, 'Dealer Codes Generated.');
|
||||
// await delay();
|
||||
|
||||
// 10. FIRST FILL (POST CODE-GENERATION)
|
||||
log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
||||
await apiRequest('/loa/security-deposit', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
amount: 1500000,
|
||||
paymentReference: 'PAY-FIN-999',
|
||||
depositType: 'FIRST_FILL',
|
||||
status: 'Verified'
|
||||
}, financeToken);
|
||||
log(10, 'Final Security Deposit Verified.');
|
||||
await delay();
|
||||
// // 10. FIRST FILL (POST CODE-GENERATION)
|
||||
// log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// amount: 1500000,
|
||||
// paymentReference: 'PAY-FIN-999',
|
||||
// depositType: 'FIRST_FILL',
|
||||
// status: 'Verified'
|
||||
// }, financeToken);
|
||||
// log(10, 'Final Security Deposit Verified.');
|
||||
// await delay();
|
||||
|
||||
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||
accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||
panNumber: 'ABCDE1234F',
|
||||
gstNumber: '07ABCDE1234F1Z5',
|
||||
bankName: 'HDFC Bank',
|
||||
accountNumber: '50100223344556',
|
||||
ifscCode: 'HDFC0001234'
|
||||
}, adminToken);
|
||||
log(11, 'Statutory & Bank details updated.');
|
||||
await delay();
|
||||
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||
// log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||
// await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||
// accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||
// panNumber: 'ABCDE1234F',
|
||||
// gstNumber: '07ABCDE1234F1Z5',
|
||||
// bankName: 'HDFC Bank',
|
||||
// accountNumber: '50100223344556',
|
||||
// ifscCode: 'HDFC0001234'
|
||||
// }, adminToken);
|
||||
// log(11, 'Statutory & Bank details updated.');
|
||||
// await delay();
|
||||
|
||||
// 12. FINAL LOA APPROVAL
|
||||
log(12, 'NBH & Head Approving Final LOA...');
|
||||
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||
const finalLoaRequestId = loaRes.data.id;
|
||||
// // 12. FINAL LOA APPROVAL
|
||||
// log(12, 'NBH & Head Approving Final LOA...');
|
||||
// const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||
// const finalLoaRequestId = loaRes.data.id;
|
||||
|
||||
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'Head Authorization (Level 1)'
|
||||
}, headToken);
|
||||
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'Head Authorization (Level 1)'
|
||||
// }, headToken);
|
||||
|
||||
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'NBH Approval (Level 2)'
|
||||
}, nbhToken);
|
||||
log(12, 'LOA Fully Approved.');
|
||||
await delay();
|
||||
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'NBH Approval (Level 2)'
|
||||
// }, nbhToken);
|
||||
// log(12, 'LOA Fully Approved.');
|
||||
// await delay();
|
||||
|
||||
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
const checklistId = eorInit.data.id;
|
||||
log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
||||
// // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||
// log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||
// const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
// const checklistId = eorInit.data.id;
|
||||
// log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
||||
|
||||
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||
const eorItems = [
|
||||
{ itemType: 'Sales', description: 'Sales Standards' },
|
||||
{ itemType: 'Service', description: 'Service & Spares' },
|
||||
{ itemType: 'IT', description: 'DMS infra' },
|
||||
{ itemType: 'Training', description: 'Manpower Training' },
|
||||
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||
{ itemType: 'Finance', description: 'Inventory Funding' },
|
||||
{ itemType: 'IT', description: 'Virtual code availability' },
|
||||
{ itemType: 'Finance', description: 'Vendor payments' },
|
||||
{ itemType: 'Marketing', description: 'Details for website submission' },
|
||||
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||
{ itemType: 'IT', description: 'Auto ordering' }
|
||||
];
|
||||
// log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||
// const eorItems = [
|
||||
// { itemType: 'Sales', description: 'Sales Standards' },
|
||||
// { itemType: 'Service', description: 'Service & Spares' },
|
||||
// { itemType: 'IT', description: 'DMS infra' },
|
||||
// { itemType: 'Training', description: 'Manpower Training' },
|
||||
// { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||
// { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||
// { itemType: 'Finance', description: 'Inventory Funding' },
|
||||
// { itemType: 'IT', description: 'Virtual code availability' },
|
||||
// { itemType: 'Finance', description: 'Vendor payments' },
|
||||
// { itemType: 'Marketing', description: 'Details for website submission' },
|
||||
// { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||
// { itemType: 'IT', description: 'Auto ordering' }
|
||||
// ];
|
||||
|
||||
for (const item of eorItems) {
|
||||
process.stdout.write(`.`); // Visual progress
|
||||
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||
...item,
|
||||
isCompliant: true,
|
||||
remarks: 'Verified by Auditor - Compliant'
|
||||
}, adminToken);
|
||||
}
|
||||
console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
||||
// for (const item of eorItems) {
|
||||
// process.stdout.write(`.`); // Visual progress
|
||||
// await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||
// ...item,
|
||||
// isCompliant: true,
|
||||
// remarks: 'Verified by Auditor - Compliant'
|
||||
// }, adminToken);
|
||||
// }
|
||||
// console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
||||
|
||||
log(13.2, 'Auditor Submitting Final EOR Audit...');
|
||||
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||
status: 'Completed',
|
||||
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||
}, adminToken);
|
||||
// log(13.2, 'Auditor Submitting Final EOR Audit...');
|
||||
// await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||
// status: 'Completed',
|
||||
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||
// }, adminToken);
|
||||
|
||||
// Status check
|
||||
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||
await delay();
|
||||
// // Status check
|
||||
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||
// await delay();
|
||||
|
||||
// 14. FINAL ONBOARDING
|
||||
log(14, 'Admin Finalizing Dealer Onboarding...');
|
||||
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
await delay();
|
||||
// // 14. FINAL ONBOARDING
|
||||
// log(14, 'Admin Finalizing Dealer Onboarding...');
|
||||
// await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
// await delay();
|
||||
|
||||
// 15. VERIFICATION
|
||||
log(15, 'Verifying Dealer Record Creation...');
|
||||
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||
if (!dealerRes.success || !dealerRes.data) {
|
||||
throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||
}
|
||||
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||
// // 15. VERIFICATION
|
||||
// log(15, 'Verifying Dealer Record Creation...');
|
||||
// const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||
// if (!dealerRes.success || !dealerRes.data) {
|
||||
// throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||
// }
|
||||
// log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||
|
||||
log(15.1, 'Verifying User Account Role Update...');
|
||||
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
||||
}
|
||||
log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
|
||||
// log(15.1, 'Verifying User Account Role Update...');
|
||||
// const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||
// const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||
// if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||
// throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
||||
// }
|
||||
// log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
|
||||
|
||||
log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
||||
log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||
// log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
||||
// log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user