1070 lines
44 KiB
TypeScript
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);
|
|
}
|
|
};
|