Dealer_Onboarding_Backend/src/modules/self-service/resignation.controller.ts

1070 lines
44 KiB
TypeScript

import { Response, NextFunction } from 'express';
import db from '../../database/models/index.js';
import logger from '../../common/utils/logger.js';
import {
RESIGNATION_STAGES,
AUDIT_ACTIONS,
ROLES,
REQUEST_TYPES,
FNF_STATUS,
RESIGNATION_DOCUMENT_TYPES,
RESIGNATION_DOCUMENT_STAGES
} from '../../common/config/constants.js';
import { Op, Transaction } from 'sequelize';
import { AuthRequest } from '../../types/express.types.js';
import ExternalMocksService from '../../common/utils/externalMocks.service.js';
import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { ParticipantService } from '../../services/ParticipantService.js';
import { notifyResignationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js';
import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js';
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
// Removed generateResignationId and moved to NomenclatureService
const resolveResignationUuid = async (id: string) => {
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'resignation');
return resolvedId;
};
// Create resignation request (Dealer only)
export const createResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { outletId, resignationType, lastOperationalDateSales, lastOperationalDateServices, reason, additionalInfo } = req.body;
const dealerId = req.user.id;
const outlet = await db.Outlet.findOne({ where: { id: outletId, dealerId } });
if (!outlet) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Outlet not found or does not belong to you' });
}
const existingResignation = await db.Resignation.findOne({
where: { outletId, status: { [Op.notIn]: ['Completed', 'Rejected', 'Withdrawn', 'Revoked'] } }
});
if (existingResignation) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'This outlet already has an active resignation request' });
}
const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js');
const initialClearances: Record<string, any> = {};
FNF_DEPARTMENTS.forEach(dept => {
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
});
const resignationId = await NomenclatureService.generateResignationId();
const resignation = await db.Resignation.create({
resignationId,
outletId,
dealerId,
resignationType,
lastOperationalDateSales,
lastOperationalDateServices,
reason,
additionalInfo,
currentStage: RESIGNATION_STAGES.ASM,
status: getResignationStatusForStage(RESIGNATION_STAGES.ASM),
progressPercentage: ResignationWorkflowService.calculateProgress(RESIGNATION_STAGES.ASM),
submittedOn: new Date(),
documents: [],
departmentalClearances: initialClearances,
timeline: [{
stage: 'Submitted',
timestamp: new Date(),
user: req.user.fullName,
action: 'Resignation request submitted'
}]
}, { transaction });
await outlet.update({ status: 'Pending Resignation' }, { transaction });
await db.ResignationAudit.create({
userId: req.user.id,
action: AUDIT_ACTIONS.CREATED,
resignationId: resignation.id,
remarks: 'Dealer submitted resignation request'
}, { transaction });
await transaction.commit();
logger.info(`Resignation request created: ${resignationId} by dealer: ${req.user.email}`);
try {
await ParticipantService.assignResignationParticipants(resignation.id);
await notifyResignationSubmittedEmails(resignation.toJSON ? resignation.toJSON() : resignation);
} catch (partErr) {
logger.error('Error assigning resignation participants or submit emails:', partErr);
}
res.status(201).json({ success: true, message: 'Resignation request submitted successfully', resignationId, resignation });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error creating resignation:', error);
next(error);
}
};
// Get all resignation requests
export const getResignations = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.user) throw new Error('Unauthorized');
const where: any = {};
if (req.user.roleCode === ROLES.DEALER) {
where.dealerId = req.user.id;
} else {
// For administrative users, filter by status or assignment if requested
const { status, onlyMine } = req.query;
if (status) {
if (String(status).includes(',')) {
where.status = { [Op.in]: String(status).split(',') };
} else if (status === 'open') {
where.status = { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] };
} else {
where.status = status;
}
}
if (onlyMine === 'true') {
// This would involve a subquery on RequestParticipants or assignedTo field
// Assuming currentStage context or RequestParticipants
where.currentStage = { [Op.like]: `%${req.user.roleCode}%` };
}
}
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const { count, rows: resignations } = await db.Resignation.findAndCountAll({
where,
include: [
{ model: db.Outlet, as: 'outlet' },
{
model: db.User,
as: 'dealer',
attributes: ['fullName'],
include: [
{ model: db.Dealer, as: 'dealerProfile', include: [{ model: db.DealerCode, as: 'dealerCode' }] }
]
}
],
order: [['createdAt', 'DESC']],
limit,
offset,
distinct: true
});
res.json({
success: true,
resignations,
meta: {
total: count,
totalPages: Math.ceil(count / limit),
currentPage: page,
limit,
stats: {
total: count,
open: await db.Resignation.count({ where: { ...where, status: { [Op.notIn]: ['Completed', 'Closed', 'Rejected'] } } }),
completed: await db.Resignation.count({ where: { ...where, status: { [Op.in]: ['Completed', 'Closed'] } } })
}
}
});
} catch (error) {
logger.error('Error fetching resignations:', error);
next(error);
}
};
// Get resignation by ID
export const getResignationById = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId },
include: [
{ model: db.Outlet, as: 'outlet' },
{
model: db.User,
as: 'dealer',
attributes: ['id', 'fullName', 'email', 'roleCode'],
include: [
{
model: db.Dealer,
as: 'dealerProfile',
include: [
{ model: db.DealerCode, as: 'dealerCode' },
{
model: db.Application,
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.ResignationDocument,
as: 'uploadedDocuments',
include: [{ model: db.User, as: 'uploader', attributes: ['fullName'] }]
},
{
model: db.FnF,
as: 'settlement',
include: [
{ model: db.FnFLineItem, as: 'lineItems' },
{ model: db.FffClearance, as: 'clearances' }
]
},
{
model: db.RequestParticipant,
as: 'participants',
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'roleCode'] }]
}
]
});
if (!resignation) {
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
res.json({ success: true, resignation });
} catch (error) {
logger.error('Error fetching resignation:', error);
next(error);
}
};
export const uploadResignationDocument = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
if (!req.file) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'File is required' });
}
const { id } = req.params;
const { documentType = RESIGNATION_DOCUMENT_TYPES[0], stage = null } = req.body;
if (!RESIGNATION_DOCUMENT_TYPES.includes(documentType)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Invalid document type. Allowed values: ${RESIGNATION_DOCUMENT_TYPES.join(', ')}`
});
}
if (stage && !RESIGNATION_DOCUMENT_STAGES.includes(stage)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Invalid stage. Allowed values: ${RESIGNATION_DOCUMENT_STAGES.join(', ')}`
});
}
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId }
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
const filePath = `/uploads/documents/${req.file.filename}`;
const document = await db.ResignationDocument.create({
resignationId: resignation.id,
documentType,
fileName: req.file.originalname,
filePath,
fileSize: req.file.size,
mimeType: req.file.mimetype,
stage,
uploadedBy: req.user.id
}, { transaction });
await db.ResignationAudit.create({
userId: req.user.id,
resignationId: resignation.id,
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
remarks: `${documentType} uploaded`,
details: { fileName: req.file.originalname, stage, documentType }
}, { transaction });
const timeline = [...(resignation.timeline || []), {
stage: resignation.currentStage,
timestamp: new Date(),
user: req.user.fullName,
action: `Document uploaded: ${documentType}`,
remarks: req.file.originalname
}];
await resignation.update({ timeline }, { transaction });
await transaction.commit();
res.status(201).json({ success: true, message: 'Document uploaded successfully', document });
} catch (error) {
await transaction.rollback();
logger.error('Error uploading resignation document:', error);
next(error);
}
};
// Approve resignation (move to next stage)
export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const targetOverride = (req as any).targetStage;
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { remarks } = req.body;
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId },
include: [
{ model: db.Outlet, as: 'outlet' },
{ model: db.User, as: 'dealer', attributes: ['id', 'dealerId'] }
]
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
// 1. Authorization Check (Skip if targetOverride is from pushfnf, handled in updateResignationStatus)
if (!targetOverride) {
const isAuthorized = await ResignationWorkflowService.canUserAction(resignation, req.user);
if (!isAuthorized) {
await transaction.rollback();
return res.status(403).json({ success: false, message: `You are not authorized to approve this request at the ${resignation.currentStage} stage` });
}
}
const stageFlow: Record<string, string> = {
[RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM,
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
// Legal approval should complete only the Legal stage.
// F&F initiation is explicitly triggered via `pushfnf` action (with LWD/force gates).
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
};
const nextStage = targetOverride || stageFlow[resignation.currentStage];
if (!nextStage) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' });
}
// Guard before transition: F&F initiation is allowed only on/after LWD unless forced.
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
const today = new Date();
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
const { force } = req.body;
if (!force && lwd && today < new Date(lwd)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `F&F can only be initiated on or after the Last Working Day (${lwd}).`,
canForce: true
});
}
}
// Sequence guard: resignation can be marked completed only after F&F settlement is complete.
if (
resignation.currentStage === RESIGNATION_STAGES.FNF_INITIATED &&
nextStage === RESIGNATION_STAGES.COMPLETED
) {
const fnf = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
if (!fnf || fnf.status !== FNF_STATUS.COMPLETED) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Cannot complete resignation. F&F settlement must be completed first.'
});
}
}
const sourceStage = resignation.currentStage;
// JOINT APPROVAL LOGIC FOR RBM STAGE
if (sourceStage === RESIGNATION_STAGES.RBM) {
// Log the current user's approval in audit
await db.ResignationAudit.create({
userId: req.user.id,
resignationId: resignation.id,
action: 'PARTIAL_APPROVE',
remarks: `Partial approval by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
details: { roleCode: req.user.roleCode, stage: sourceStage }
}, { transaction });
// Ensure worknote is added for this partial approval
if (remarks) {
await writeWorkflowActivityWorknote({
requestId: resignation.id,
requestType: 'resignation',
userId: req.user.id,
noteText: `Approved: ${remarks}`,
noteType: 'internal'
});
}
// Check if both RBM and DD_ZM have approved
const requiredRoles = [ROLES.RBM, ROLES.DD_ZM];
const partialLogs = await db.ResignationAudit.findAll({
where: {
resignationId: resignation.id,
action: 'PARTIAL_APPROVE'
},
transaction
});
const approvedRoles = new Set(
partialLogs.map((log: any) => log.details?.roleCode)
);
const hasAllRequiredApprovals = requiredRoles.every(role => approvedRoles.has(role));
if (!hasAllRequiredApprovals) {
// Append to timeline directly without transitioning the stage
const timelineEntry = {
stage: sourceStage,
targetStage: nextStage,
timestamp: new Date(),
user: req.user.fullName,
action: `Approved by ${req.user.roleCode}`,
remarks: remarks || ''
};
const updatedTimeline = [...(resignation.timeline || []), timelineEntry];
await resignation.update({ timeline: updatedTimeline }, { transaction });
await transaction.commit();
return res.json({
success: true,
message: 'Approval recorded. Waiting for the other required approver (RBM or DD-ZM).',
resignation
});
}
}
// Transition via Workflow Service
await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, {
remarks,
actionType: OFFBOARDING_ACTIONS.APPROVE,
status: getResignationStatusForStage(nextStage),
transaction
});
// Special logic for F&F and Completion
if (nextStage === RESIGNATION_STAGES.COMPLETED) {
await (resignation as any).outlet.update({ status: 'Closed' }, { transaction });
ExternalMocksService.mockSyncDealerStatusToSap((resignation as any).outlet.code, 'Inactive')
.catch(err => logger.error('Error syncing resignation completion to SAP:', err));
}
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction });
let fnfId = existingFnF?.id;
if (!existingFnF) {
const dealerProfileId = (resignation as any).dealer?.dealerId;
// No seed line items or mock SAP amounts — totals stay 0 until users/scripts add FnF line items or department clearances.
const fnf = await db.FnF.create({
settlementId: await NomenclatureService.generateFnFId(),
resignationId: resignation.id,
outletId: resignation.outletId,
dealerId: dealerProfileId, // Correctly using the Dealer model ID
status: 'Initiated',
totalReceivables: 0,
totalPayables: 0,
netAmount: 0
}, { transaction });
const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js');
await db.FffClearance.bulkCreate(
FNF_DEPARTMENTS.map(dept => ({
fnfId: fnf.id,
department: dept,
status: 'Pending'
})),
{ transaction }
);
fnfId = fnf.id;
}
// Always assign/sync Participants for F&F (Sub-application chat) to ensure robustness
if (fnfId) {
await ParticipantService.assignFnFParticipants(fnfId);
}
}
await transaction.commit();
const message = (sourceStage === RESIGNATION_STAGES.LEGAL && nextStage === RESIGNATION_STAGES.LEGAL)
? 'Legal stage approved successfully. Use Push to F&F to initiate settlement as per LWD rules.'
: 'Resignation approved successfully';
res.json({ success: true, message, nextStage, resignation });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error approving resignation:', error);
next(error);
}
};
// Reject resignation
export const rejectResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { reason } = req.body;
if (!reason) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Rejection reason is required' });
}
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId },
include: [{ model: db.Outlet, as: 'outlet' }]
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
remarks: reason,
action: OFFBOARDING_ACTIONS.REJECT,
actionType: OFFBOARDING_ACTIONS.REJECT,
status: 'Rejected'
});
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
await transaction.commit();
res.json({ success: true, message: 'Resignation rejected', resignation });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error rejecting resignation:', error);
next(error);
}
};
// Withdraw resignation
export const withdrawResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { reason } = req.body;
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId },
include: [{ model: db.Outlet, as: 'outlet' }]
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
const restrictedStages = [
RESIGNATION_STAGES.NBH,
RESIGNATION_STAGES.DD_ADMIN,
RESIGNATION_STAGES.LEGAL,
RESIGNATION_STAGES.FNF_INITIATED,
RESIGNATION_STAGES.COMPLETED
];
if (restrictedStages.includes(resignation.currentStage as any)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Withdrawal not allowed after NBH evaluation stage. Current stage: ${resignation.currentStage}`
});
}
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
remarks: reason,
action: 'Withdrawn',
status: 'Withdrawn'
});
await (resignation as any).outlet.update({ status: 'Active' }, { transaction });
await transaction.commit();
res.json({ success: true, message: 'Resignation withdrawn successfully' });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error withdrawing resignation:', error);
next(error);
}
};
// Send back resignation
export const sendBackResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { targetStage, remarks } = req.body;
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId }
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
// Standardized validation
const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, remarks);
if (!validation.valid) {
await transaction.rollback();
return res.status(400).json({ success: false, message: validation.message });
}
const prevStage = targetStage || getPreviousStage(REQUEST_TYPES.RESIGNATION, resignation.currentStage);
if (!prevStage) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Cannot send back from current stage' });
}
await ResignationWorkflowService.transitionResignation(resignation, prevStage, req.user.id, {
remarks,
action: OFFBOARDING_ACTIONS.SEND_BACK,
status: `${getResignationStatusForStage(prevStage)} (Sent Back)`
});
await transaction.commit();
res.json({ success: true, message: `Resignation sent back to ${prevStage}` });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error sending back resignation:', error);
next(error);
}
};
// Revoke resignation (Standardized Action)
export const revokeResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { remarks } = req.body;
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId }
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
// Standardized validation
const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.REVOKE, remarks);
if (!validation.valid) {
await transaction.rollback();
return res.status(400).json({ success: false, message: validation.message });
}
// Transition to REJECTED stage with Revoked status (Terminal)
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
remarks,
action: OFFBOARDING_ACTIONS.REVOKE,
status: 'Revoked'
});
await transaction.commit();
res.json({ success: true, message: `Resignation for ${resignation.resignationId} has been revoked and closed.` });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error revoking resignation:', error);
next(error);
}
};
// Update departmental clearance (existing code)...
// Manually assign participant
export const assignResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { assignTo, remarks } = req.body; // assignTo is a role code or specific userId
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId },
include: [{ model: db.User, as: 'dealer' }]
});
if (!resignation) {
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
let targetUserId = null;
// If assignTo is a UUID, it's a direct user assignment
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(assignTo);
if (isUUID) {
targetUserId = assignTo;
} else {
// Role-based resolution
const user = await db.User.findByPk(resignation.dealerId);
if (user && user.dealerId) {
const dealer = await db.Dealer.findByPk(user.dealerId, {
include: [{
model: db.Application,
as: 'application',
include: [{
model: db.District,
as: 'district',
include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }]
}]
}]
});
if (dealer?.application?.district) {
const d = dealer.application.district;
if (assignTo === 'asm') targetUserId = dealer.asmId || null;
else if (assignTo === 'rbm') targetUserId = d.region?.rbmId;
else if (assignTo === 'zbh') targetUserId = d.zone?.zbhId;
}
}
// Fallback for national roles
if (!targetUserId) {
const roleIdMap: Record<string, string> = {
'nbh': ROLES.NBH,
'legal': ROLES.LEGAL_ADMIN,
'dd_admin': ROLES.DD_ADMIN,
'dd_lead': ROLES.DD_LEAD
};
const targetRole = roleIdMap[assignTo];
if (targetRole) {
const roleUser = await db.User.findOne({ where: { roleCode: targetRole, status: 'active' } });
if (roleUser) targetUserId = roleUser.id;
}
}
}
if (!targetUserId) {
return res.status(400).json({
success: false,
message: `Could not resolve a unique user for assignment: ${assignTo}. Please ensure the underlying master data (District/Region/Zone) is correctly mapped.`
});
}
await db.RequestParticipant.findOrCreate({
where: {
requestId: resignation.id,
requestType: REQUEST_TYPES.RESIGNATION,
userId: targetUserId
},
defaults: {
participantType: 'contributor',
joinedMethod: 'manual',
metadata: {
assignedBy: req.user.id,
remarks: remarks || 'Manual assignment'
}
}
});
await db.ResignationAudit.create({
userId: req.user.id,
resignationId: resignation.id,
action: AUDIT_ACTIONS.UPDATED,
remarks: `Manually assigned user to the request. ${remarks || ''}`,
details: { assignedUserId: targetUserId, roleToAssign: assignTo }
});
res.json({ success: true, message: 'Participant assigned successfully' });
} catch (error) {
logger.error('Error assigning resignation:', error);
next(error);
}
};
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const { id } = req.params;
const { department, status, remarks, amount, type } = req.body;
// Align with F&F: dealer-owed side is always stored as Receivable (legacy payloads may send Recovery)
const clearanceType = String(type || '').toLowerCase();
const resolvedItemType: 'Payable' | 'Receivable' | 'Deduction' =
clearanceType === 'payable'
? 'Payable'
: clearanceType === 'deduction'
? 'Deduction'
: clearanceType === 'recovery' || clearanceType === 'receivable'
? 'Receivable'
: type === 'Payable' || type === 'Deduction'
? type
: type === 'Recovery'
? 'Receivable'
: type === 'Receivable'
? 'Receivable'
: 'Receivable';
const clearanceStoredType: 'Payable' | 'Receivable' | 'Deduction' =
resolvedItemType === 'Payable'
? 'Payable'
: resolvedItemType === 'Deduction'
? 'Deduction'
: 'Receivable';
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId }
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
const currentClearances = resignation.departmentalClearances || {};
const normalizedAmount = Math.abs(parseFloat(amount) || 0);
const normalizedDeptStatus = normalizeClearanceStatus(status, normalizedAmount);
const documentUrl = req.file ? `/uploads/documents/${req.file.filename}` : (currentClearances[department]?.supportingDocument || null);
const clearances = {
...currentClearances,
[department]: {
status: normalizedDeptStatus,
remarks,
amount: normalizedAmount,
type: clearanceStoredType,
supportingDocument: documentUrl,
updatedAt: new Date().toISOString(),
updatedBy: req.user.fullName
}
};
await resignation.update({
departmentalClearances: clearances,
timeline: [...resignation.timeline, {
stage: resignation.currentStage,
timestamp: new Date(),
user: req.user.fullName,
action: `Updated clearance for ${department}: ${normalizedDeptStatus}`,
remarks
}]
}, { transaction });
// Record module-specific audit
await db.ResignationAudit.create({
userId: req.user.id,
resignationId: resignation.id,
action: 'CLEARANCE_UPDATED',
remarks: remarks || `Cleared ${department}`,
details: { department, status: normalizedDeptStatus, amount: normalizedAmount }
}, { transaction });
// Sync with F&F Clearance if settlement exists
const fnf = await db.FnF.findOne({ where: { resignationId: resignation.id } });
if (fnf) {
const numAmount = normalizedAmount;
const fnfStatus = normalizeClearanceStatus(status, numAmount);
const existingClearance = await db.FffClearance.findOne({
where: { fnfId: fnf.id, department },
transaction
});
if (existingClearance) {
await existingClearance.update({
status: fnfStatus,
remarks: remarks || '-',
clearedAt: new Date(),
supportingDocument: documentUrl
}, { transaction });
} else {
await db.FffClearance.create({
fnfId: fnf.id,
department,
status: fnfStatus,
remarks: remarks || '-',
clearedAt: new Date(),
supportingDocument: documentUrl
}, { transaction });
}
// Record F&F specific audit
await db.FnFAudit.create({
userId: req.user.id,
fnfId: fnf.id,
action: 'CLEARANCE_UPDATED',
remarks: remarks || `Departmental clearance recorded for ${department}`,
details: { department, status: fnfStatus, source: 'Resignation Workflow' }
}, { transaction });
// Write department claim in versioned, active-only model.
const enteredAmount = Math.abs(parseFloat(amount) || 0);
const existingClaim = await db.FnFLineItem.findOne({
where: {
fnfId: fnf.id,
department,
sourceType: 'DepartmentClaim',
isActive: true
},
transaction
});
if (enteredAmount > 0) {
if (existingClaim) {
await existingClaim.update({ isActive: false }, { transaction });
}
await db.FnFLineItem.create({
fnfId: fnf.id,
itemType: resolvedItemType,
description: '[DEPARTMENT_CLAIM] Department Clearance - Manual Update',
department,
amount: enteredAmount,
addedBy: req.user.id,
sourceType: 'DepartmentClaim',
version: Number(existingClaim?.version || 1) + (existingClaim ? 1 : 0),
isActive: true,
parentLineItemId: existingClaim?.parentLineItemId || existingClaim?.id || null,
claimAmount: enteredAmount,
validatedAmount: null,
varianceAmount: 0,
financeDecision: null,
varianceReason: null
}, { transaction });
} else if (existingClaim) {
await existingClaim.update({ isActive: false }, { transaction });
}
// Recalculate totals from active lines only.
// If finance-validated rows exist, use only those rows for totals.
const items = await db.FnFLineItem.findAll({ where: { fnfId: fnf.id, isActive: true }, transaction });
const hasFinanceValidated = items.some((item: any) => item.sourceType === 'FinanceValidated');
const calculationItems = hasFinanceValidated
? items.filter((item: any) => item.sourceType === 'FinanceValidated')
: items;
let totalPayables = 0;
let totalReceivables = 0;
let totalDeductions = 0;
calculationItems.forEach((item: any) => {
const val = Math.abs(parseFloat(item.amount) || 0);
if (item.itemType === 'Payable') totalPayables += val;
else if (item.itemType === 'Receivable' || item.itemType === 'Recovery') totalReceivables += val;
else if (item.itemType === 'Deduction') totalDeductions += val;
});
await fnf.update({
totalPayables,
totalReceivables,
totalDeductions,
netAmount: totalPayables - totalReceivables - totalDeductions
}, { transaction });
}
await transaction.commit();
res.json({ success: true, message: `Clearance updated for ${department}`, resignation });
} catch (error) {
if (transaction) await transaction.rollback();
logger.error('Error updating clearance:', error);
next(error);
}
};
// Unified status update handler for frontend compatibility
export const updateResignationStatus = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { action } = req.body;
// Normalize to lowercase alphanumeric for robust comparison (handles "Send Back", "sendBack", "send-back")
const actionNode = String(action || '').toLowerCase().trim().replace(/[^a-z0-9]/g, '');
switch (actionNode) {
case 'approve':
return approveResignation(req, res, next);
case 'reject':
return rejectResignation(req, res, next);
case 'revoke':
return revokeResignation(req, res, next);
case 'withdrawal':
case 'withdraw':
return withdrawResignation(req, res, next);
case 'sendback':
return sendBackResignation(req, res, next);
case 'pushfnf':
// Verify if user role is authorized for manual jump to F&F
const authorizedRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN];
if (!req.user || !authorizedRoles.includes(req.user.roleCode as any)) {
return res.status(403).json({ success: false, message: 'You do not have permission to push this request to F&F' });
}
{
const resolvedId = await resolveResignationUuid(String(req.params.id));
const resignation = await db.Resignation.findByPk(resolvedId);
if (!resignation) {
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
// SRS-aligned gate: F&F can start only after Legal completion artifacts.
if (resignation.currentStage !== RESIGNATION_STAGES.LEGAL) {
return res.status(400).json({
success: false,
message: `Cannot trigger F&F from ${resignation.currentStage}. Move request to Legal stage first.`
});
}
const hasLegalStageDocument = await db.ResignationDocument.findOne({
where: {
resignationId: resignation.id,
stage: RESIGNATION_STAGES.LEGAL
},
attributes: ['id']
});
if (!hasLegalStageDocument) {
return res.status(400).json({
success: false,
message: 'Cannot trigger F&F. Legal-stage acceptance/communication document is required first.'
});
}
}
// Jump directly to F&F Initiation
(req as any).targetStage = RESIGNATION_STAGES.FNF_INITIATED;
return approveResignation(req, res, next);
case 'assign':
return assignResignation(req, res, next);
default:
return res.status(400).json({
success: false,
message: `Invalid or unsupported resignation action: ${action}`
});
}
} catch (error) {
logger.error('Error in updateResignationStatus:', error);
next(error);
}
};