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 = {}; 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 = { [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 = { '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); } };