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 { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.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'] } } }); 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: 'Recovery' }; }); const resignationId = 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}`); // Add as chat participants (Async) ParticipantService.assignResignationParticipants(resignation.id) .catch(err => logger.error('Error assigning participants to resignation:', err)); 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; } const resignations = await db.Resignation.findAll({ 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']] }); res.json({ success: true, resignations }); } 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; // Transition via Workflow Service await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, { remarks, 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 }); if (!existingFnF) { const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code); const dealerProfileId = (resignation as any).dealer?.dealerId; const fnf = await db.FnF.create({ settlementId: NomenclatureService.generateFnFId(), resignationId: resignation.id, outletId: resignation.outletId, dealerId: dealerProfileId, // Correctly using the Dealer model ID status: 'Initiated', totalReceivables: sapDues.data.outstandingInvoices, totalPayables: sapDues.data.securityDeposit, netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices }, { transaction }); await db.FnFLineItem.bulkCreate([ { fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: req.user.id }, { fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.id } ], { 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 } ); } } await transaction.commit(); try { const noteText = String(remarks || '').trim() || `[Approved] ${sourceStage} → ${nextStage}`; await writeWorkflowActivityWorknote({ requestId: resignation.id, requestType: 'resignation', userId: req.user.id, noteText, noteType: 'internal' }); } catch (wnErr) { logger.error('[resignation] workflow worknote (approve):', wnErr); } 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: 'Rejected', status: 'Rejected' }); await (resignation as any).outlet.update({ status: 'Active' }, { transaction }); await transaction.commit(); try { await writeWorkflowActivityWorknote({ requestId: resignation.id, requestType: 'resignation', userId: req.user.id, noteText: String(reason || '').trim(), noteType: 'internal' }); } catch (wnErr) { logger.error('[resignation] workflow worknote (reject):', wnErr); } res.json({ success: true, message: 'Resignation rejected', resignation }); } catch (error) { if (transaction) await transaction.rollback(); 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(); try { const noteText = String(reason || '').trim() ? `[Withdrawn] ${String(reason).trim()}` : '[Withdrawn]'; await writeWorkflowActivityWorknote({ requestId: resignation.id, requestType: 'resignation', userId: req.user.id, noteText, noteType: 'workflow' }); } catch (wnErr) { logger.error('[resignation] workflow worknote (withdraw):', wnErr); } res.json({ success: true, message: 'Resignation withdrawn successfully' }); } catch (error) { if (transaction) await transaction.rollback(); 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' }); } const stageFlowBack: Record = { [RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ASM, [RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.RBM, [RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.ZBH, [RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_LEAD, [RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.NBH, [RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN }; const prevStage = targetStage || stageFlowBack[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: 'Sent Back', status: `${getResignationStatusForStage(prevStage)} (Sent Back)` }); await transaction.commit(); try { const r = String(remarks || '').trim(); const noteText = r ? `[Send Back] ${r}` : `[Send Back] Returned to ${prevStage}`; await writeWorkflowActivityWorknote({ requestId: resignation.id, requestType: 'resignation', userId: req.user.id, noteText, noteType: 'workflow' }); } catch (wnErr) { logger.error('[resignation] workflow worknote (send back):', wnErr); } res.json({ success: true, message: `Resignation sent back to ${prevStage}` }); } catch (error) { if (transaction) await transaction.rollback(); logger.error('Error sending back 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 = d.asmId; 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 }; 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; 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: type || 'Recovery', 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: type || 'Receivable', 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; switch (action) { case 'approve': return approveResignation(req, res, next); case 'reject': return rejectResignation(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); } };