diff --git a/docs/FNF_Department_Finance_No_Conflict_Flow.md b/docs/FNF_Department_Finance_No_Conflict_Flow.md new file mode 100644 index 0000000..fa0de5d --- /dev/null +++ b/docs/FNF_Department_Finance_No_Conflict_Flow.md @@ -0,0 +1,123 @@ +# F&F Department vs Finance No-Conflict Flow + +## Purpose + +This document defines a conflict-free Full & Final (F&F) settlement approach where: +- Departments submit and own their initial financial claims. +- Finance validates and finalizes settlement amounts. +- Final net settlement is always calculated from finance-validated values. + +The objective is to prevent duplicate updates, overwritten values, and ambiguity in amount ownership. + +## Core Ownership Model + +### 1) Department-Owned Fields (Claim Layer) +- `department_claim_amount` +- `department_amount_type` (Payable / Recovery / Deduction if applicable) +- `department_remarks` +- `department_supporting_documents` +- `department_submission_status` + +Departments can only create or update this layer during the department response window. + +### 2) Finance-Owned Fields (Validation Layer) +- `finance_validated_amount` +- `finance_decision` (Accepted / Partially Accepted / Rejected / Under Clarification) +- `finance_variance_amount` +- `finance_variance_reason` (mandatory when variance is non-zero) +- `finance_supporting_documents` + +Finance can only update this layer after department responses are frozen. + +### 3) System-Owned Fields +- `total_payables` +- `total_receivables` +- `total_deductions` +- `net_settlement` + +System fields are read-only and formula-driven. + +## Workflow Stages and Edit Locks + +1. `Department Response Open` +- Departments: Edit allowed (claim layer only). +- Finance: View only. + +2. `Department Response Frozen` +- Departments: Read-only. +- Finance: Validation enabled. + +3. `Finance Reconciliation Open` +- Finance: Edit allowed (validation layer only). +- Departments: Read-only (unless clarification is requested). + +4. `Finance Locked` +- All amount fields read-only. +- Only approval/rejection/clarification routing actions are allowed. + +5. `Reopen` (exception path) +- Reopen must create a new version; previous values must never be overwritten. + +## Calculation Rule (Single Source for Final Amount) + +Only finance-validated values are used for final settlement: + +`Net Settlement = Total Payables - Total Receivables - Total Deductions` + +Where: +- `Total Payables = sum(finance_validated_amount where type = Payable)` +- `Total Receivables = sum(finance_validated_amount where type = Recovery)` +- `Total Deductions = sum(finance_validated_amount where type = Deduction)` + +Interpretation: +- Positive value: Payable to Dealer. +- Negative value: Recovery from Dealer. + +## Clarification and Reconciliation Rules + +- Finance raises clarification against specific department rows using Work Notes. +- Department responds with remarks/documents. +- If amount revision is needed after freeze, reopen the row as a new version. +- Every variance must include a reason and supporting proof. + +## Audit and Traceability Requirements + +For each row and version, store: +- Created by / Updated by +- Timestamp +- Previous value and new value +- Decision and variance reason +- Linked document references +- Workflow state at the time of change + +No hard overwrite of prior approved values is allowed. + +## Minimal Status Model + +### Department Status +- Pending +- Submitted +- Reopened +- Resubmitted + +### Finance Status +- Not Reviewed +- Accepted +- Partially Accepted +- Rejected +- Under Clarification + +### Case Status +- Dept Collection +- Dept Frozen +- Finance Reconciliation +- Finance Locked +- Approved +- Closed + +## Expected Outcome + +- Clear ownership of amount fields. +- No parallel edit conflict between departments and finance. +- Deterministic and auditable final amount calculation. +- Faster dispute resolution with explicit variance tracking. diff --git a/docs/Offboarding_Requirement_Tracking_Matrix.md b/docs/Offboarding_Requirement_Tracking_Matrix.md new file mode 100644 index 0000000..5b5f478 --- /dev/null +++ b/docs/Offboarding_Requirement_Tracking_Matrix.md @@ -0,0 +1,40 @@ +# Offboarding Requirement Tracking Matrix + +Date: 2026-04-17 +Baseline SRS: `backend/docs/Re_New_Dealer_Onboard_TWO.md` +Detailed analysis reference: `backend/docs/Offboarding_Requirements_Implementation_Gap_Report.md` + +## Status Legend +- `Done` = Implemented and aligned in both frontend/backend for core behavior. +- `Partial` = Implemented in parts, but missing standardization or edge-case handling. +- `Missing` = Not implemented or not yet aligned to SRS intent. + +## Tracking Matrix + +| Module | Requirement (SRS intent) | Current Implementation | Gap / Missing | Priority | Status | +|---|---|---|---|---|---| +| Resignation | Multi-stage review with role-based actions (`Approve`, `Send Back`, `Revoke`) | Core workflow and role visibility are implemented; legal/final flow integrated with F&F trigger path | Need cross-module send-back contract parity and common rollback behavior | High | Partial | +| Resignation | `Send Back`/`Revoke` must use Work Notes with mandatory remarks | Pattern exists and is used in module logic/UI communication | Need strict shared validation/payload consistency with other modules | High | Partial | +| Resignation | Dealer withdrawal only until NBH pending | Rule has been incorporated in behavior references and workflow handling | Validate all edge transitions and UI disable states for all roles | Medium | Partial | +| Termination | Workflow from initiation through NBH, CEO/CCO, Legal closure | Termination module backend now exists with stage progression, SCN, finalize APIs, and improved timeline rendering | Send-back paths not fully hardened/standardized across all stage transitions | Critical | Partial | +| Termination | NBH decisions (`Go Ahead`, `Hold`, `Raise Query`) and final (`Approve`, `Reconsider`, `Reject`) | Final decision endpoint and stage transitions are implemented | Need strict mapping and regression tests for all decision + rollback combinations | High | Partial | +| Termination | F&F trigger with LWD governance | Termination closure path supports handoff | LWD-gated trigger must be verified consistently in all finalization flows | High | Partial | +| Constitutional Change | Scenario-based constitution request with mandatory docs | Core request flow and checklist behavior are present | Need continuous validation for all constitution permutations and role-specific document constraints | Medium | Partial | +| Constitutional Change | Joint approval at `ZM+RBM` stage | Joint approval policy + backend logic + UI states implemented | On send-back to joint stage, approval metadata reset behavior must be explicit and deterministic | High | Partial | +| Constitutional Change | Work Notes + mandatory remarks for `Send Back`/`Revoke` | Supported by module patterns | Needs strict standardized contract parity with termination/resignation/relocation | High | Partial | +| Relocation | Role-based review with `Approve`, `Send Back`, `Revoke` | Core relocation flow exists with document tracking | Need validation that send-back/revoke use same strict contract and rollback semantics as other modules | High | Partial | +| Relocation | Work Notes/Audit traceability for reviewer actions | Logging and review traces are present | Ensure payload consistency and required fields are enforced centrally | Medium | Partial | +| F&F Settlement | Department-wise claims and finance validation separation | Ownership layering and reconciliation enhancements are implemented; finance draft seeding added | Add explicit final-summary label: "Final settlement uses Finance Validated values" | Medium | Partial | +| F&F Settlement | Finance actions (`Approve`, `Request Clarification`, `Reject`) with checklist | Settlement actions and finance flows are available | Expand UX cues and verify checklist gating across all entry paths | Medium | Partial | +| F&F Settlement | Work Notes as communication + audit trail | Implemented pattern available | Harmonize phrasing/contract with offboarding send-back communication model | Medium | Partial | +| Cross-module | Consistent role resolution (`role`/`roleCode`) and route visibility | Multiple fixes already implemented in sidebar/app/guard and module screens | Need full sweep to ensure no remaining module-level mismatches | Medium | Partial | +| Cross-module | Unified send-back contract and deterministic previous-stage routing | Implemented in module-specific ways, not yet unified | Build shared standard for action contract, remarks validation, rollback resolver, and audit payload | Critical | Missing | +| Cross-module | Module-level regression test coverage for approve/send-back/revoke | Manual and iterative fixes done | Add automated regression scenarios for each module and stage | High | Missing | + +--- + +## Immediate Action Queue +1. Standardize send-back contract in backend (`action`, mandatory `remarks`, role check, previous-stage resolver, audit/work-note payload). +2. Apply the standard first to **Termination** (highest risk), then align Resignation, Constitutional, Relocation. +3. Add constitutional send-back reset for joint-approval metadata at `ZM+RBM` stage. +4. Close remaining F&F UX consistency items and run end-to-end role-based verification. diff --git a/docs/Offboarding_Requirements_Implementation_Gap_Report.md b/docs/Offboarding_Requirements_Implementation_Gap_Report.md new file mode 100644 index 0000000..9bf8f44 --- /dev/null +++ b/docs/Offboarding_Requirements_Implementation_Gap_Report.md @@ -0,0 +1,132 @@ +# Offboarding Requirements vs Implementation Gap Report + +Date: 2026-04-17 +Source baseline: `backend/docs/Re_New_Dealer_Onboard_TWO.md` + +## Purpose +This report captures: +1. What the SRS requires for offboarding-related modules. +2. What is implemented in the current codebase. +3. What is still missing or needs standardization. + +--- + +## 1) SRS Requirements (What document asks) + +## 1.1 Common cross-module requirements +- Role-driven stage workflow across Resignation, Termination, Constitutional Change, Relocation, and F&F. +- Stage actions include combinations of `Approve`, `Send Back`, `Revoke`, and module-specific decisions such as `Hold`, `Reject`, `Reconsider`, `Raise Query`. +- `Send Back` and `Revoke` must be recorded through **Work Notes** with **mandatory remarks**. +- Every action, upload, and transition must be logged in **Audit Trail**. +- Visibility and actionability must follow persona-based access control. + +## 1.2 Resignation (SRS sections `4.2.2.x`, `12.1.x`) +- Multi-stage review with business hierarchy and Legal closure. +- Authorized users can `Approve`, `Send Back`, `Revoke` per stage authority. +- Dealer withdrawal allowed only up to NBH-pending state. +- F&F must trigger strictly on Last Working Day (LWD), not approval date. + +## 1.3 Termination (SRS sections `4.3.2.x`) +- Escalation workflow from business review to legal process and final authorization. +- NBH stage includes `Go Ahead`, `Hold Decision`, `Raise Query`. +- NBH final decision includes `Approve Termination`, `Reconsider`, `Reject`. +- CEO/CCO authorization before legal termination letter and closure. +- F&F trigger aligned to LWD rule. + +## 1.4 Constitutional Change (SRS section `12.2.x`) +- Dealer-initiated request with scenario-based mandatory documents. +- Internal review stages support `Approve`, `Send Back`, `Revoke`. +- Work Notes + mandatory remarks for send-back/revoke. +- Compliance verification and final master update by Legal stage. + +## 1.5 Relocation (SRS relocation section under `12.2.x`) +- Dealer-initiated relocation with document checklist and verification. +- Internal stages support `Approve`, `Send Back`, `Revoke`. +- Work Notes and audit logging for all reviewer actions. + +## 1.6 F&F Settlement (SRS sections `10.x`, `11.2.x`) +- Department-wise responses feed Finance summary and settlement decision. +- Finance actions include `Approve Settlement`, `Request Clarification`, `Reject Settlement`. +- Checklist-gated approval and complete auditability. +- Work Notes as official internal communication channel. + +--- + +## 2) Implemented in Current Codebase + +## 2.1 F&F conflict-free ownership and UX improvements +- Department claim vs finance validated layering is implemented in settlement flow. +- Auto-seeding of finance draft line items from department claims is implemented. +- Finance detail UI supports local draft editing and save-based API update pattern. +- Reconciliation table for department vs finance amounts added in internal F&F views. + +## 2.2 Resignation and termination role visibility fixes +- Sidebar and route access expanded for roles such as RBM, ZBH, DD Head, Legal Admin where required. +- Role checks normalized around `role` and `roleCode` usage. +- `RoleGuard` matching made case-insensitive and more robust. + +## 2.3 Termination workflow and API wiring +- Termination creation now starts at `RBM Review` stage. +- SCN APIs implemented (`/scn`, `/scn-response`) with stage transition handling. +- Finalize API implemented (`/:id/finalize`) for NBH/CCO/CEO related final decisions. +- Timeline rendering logic corrected to map remarks to correct stage entries. +- Stage progression and status rendering improved (including terminated-state completion). + +## 2.4 Constitutional joint approval (ZM+RBM) +- Joint approval logic implemented in backend for `ZM/RBM Review` stage using approval policy. +- Seed policy added for `CONSTITUTIONAL_ZM_RBM_REVIEW` with required roles and two approvals. +- Frontend updated to show `ZM+RBM` labeling and prevent duplicate self-approval actions. +- Progress UI now reflects both RBM and DD-ZM comments and "Approved by you" state. + +## 2.5 Dealer and detail data correctness +- Active dealer filtering introduced for dropdown usage. +- Dealer email exposure fixed in related detail screens (termination/resignation context). + +--- + +## 3) Missing / Gap Analysis (What is still pending) + +## 3.1 Send-back standardization across modules (High priority) +Current state is partially aligned but not fully standardized. + +Missing standard contract across modules: +- Unified action contract (for example: `action=sendBack` with mandatory `remarks`). +- Deterministic previous-stage routing logic shared across modules. +- Consistent role-guard enforcement for send-back by stage owner/authorized roles. +- Consistent work note + audit payload structure for each send-back/revoke action. + +## 3.2 Termination send-back hardening (Highest gap) +- Termination supports major transitions, but send-back behavior still needs full standard handling parity with other modules. +- Need explicit, centrally testable behavior for stage rollback and notifications on send-back paths. + +## 3.3 Constitutional send-back to joint stage reset behavior +- If a case is sent back to `ZM+RBM` from later stage, joint approvals must be reset for a fresh cycle. +- This reset behavior should be explicit and deterministic in controller/service layer. + +## 3.4 Remaining UX consistency tasks +- Add explicit final summary label in F&F: "Final settlement uses Finance Validated values." +- Add first-time "auto-seeded" badge for finance prefilled rows. +- Apply smooth edit pattern consistency to non-finance F&F detail editing where applicable. +- Improve timeline labels in termination to distinguish stage initiation vs stage action (if required by UX acceptance). + +## 3.5 Policy and admin-config visibility +- Approval policy UI should expose the constitutional joint-review policy label for admin maintenance. +- Legal Admin read-scope definitions across modules should be formalized if strict SRS interpretation is required. + +--- + +## 4) Recommended Next Execution Order +1. Standardize send-back contract and validations in backend services (start with Termination). +2. Add shared rollback resolver for previous stage routing. +3. Add module-specific send-back hooks (including joint-approval reset for Constitutional). +4. Align frontend action payloads and button guards to shared contract. +5. Add regression tests for send-back/revoke in each module. +6. Complete UX consistency items in F&F and timelines. + +--- + +## 5) Status Snapshot +- **SRS intent coverage:** Medium-High +- **Core flow implementation:** High in Resignation/F&F, Medium-High in Termination/Constitutional/Relocation +- **Main risk:** inconsistent send-back behavior across modules +- **Immediate focus:** send-back standardization + deterministic rollback behavior diff --git a/docs/Re_New_Dealer_Onboard_TWO.md b/docs/Re_New_Dealer_Onboard_TWO.md index e7f553d..d5ab7be 100644 --- a/docs/Re_New_Dealer_Onboard_TWO.md +++ b/docs/Re_New_Dealer_Onboard_TWO.md @@ -869,10 +869,17 @@ o ⚪ Pending – Awaiting department input o **Payables to Dealer** (e.g., refundable deposits, reimbursements) o **Receivables from Dealer** (e.g., outstanding invoices, recoveries) o **Deductions** (policy penalties, non-compliance adjustments) +- At this stage, **department-claimed amounts are frozen** and become read-only for + departments. +- Finance does **not overwrite department claim values**. Instead, Finance validates each row + in a dedicated validation layer by recording: + - Finance decision (Accepted / Partially Accepted / Rejected / Under Clarification) + - Finance-validated amount + - Variance amount and mandatory variance reason (if changed) + - Supporting proof/document - The system automatically calculates: - Net Settlement = Total Payables – Total Receivables – Total Deductions -- Finance reviews and adjusts entries as needed, attaching relevant proofs for - transparency. + - Final totals are computed from **finance-validated values only**. - Status updates to _Finance Summary Prepared_ once complete. ``` @@ -6158,6 +6165,8 @@ It provides Finance and DD teams with a transparent view of each department’s status, whether the department owes a payment to the dealer ( _Payable_ ) or the dealer owes the department ( _Recovery_ ). This enables complete financial visibility before the final settlement summary is prepared. +Departments are the **owners of initial claim input only**; final settlement values are owned by +Finance validation. **10.2.2 Width** @@ -6165,7 +6174,8 @@ This module connects all **functional departments (up to 16 units)** including S Finance, Warranty, Marketing, HR, IT, Legal, Logistics, and Quality. Each department inputs its clearance data — marking whether any dues exist — and provides supporting remarks or payable/recovery amounts. -The respective department person will login and fill his respective amount. +The respective department user logs in and submits the department claim amount and proof +during the response window. **10.2.3 Depth** @@ -6180,9 +6190,13 @@ o ⚪ Pending – Awaiting departmental response or review. ``` - **Amount Details:** When dues are identified, the department specifies the **Amount Type** (Payable or - Recovery) and corresponding **Value** , which directly contributes to the Finance team’s - final calculation matrix. -- They will login with there respective account and fill the details. + Recovery) and corresponding **Claim Value**. + This value is treated as a department claim and is not directly used as the final settlement + amount until Finance validation is completed. +- **Edit Lock Rule:** + Once all departmental submissions are complete or SLA freeze is triggered, department + amount fields become read-only. + Any subsequent correction must follow an authorized reopen flow with version tracking. - **Remarks Section:** Every response includes contextual remarks for clarity, such as “Outstanding amount identified” or “Cleared,” ensuring traceable communication between departments and @@ -6484,8 +6498,11 @@ Represents refundable amounts due from the company to the dealer, such as: - Equipment and fixture reimbursements - Outstanding credit notes -Finance users can add new line items with department tags and descriptions. -Each editable record auto-calculates into the total payables panel. +For department-originated items, Finance validates each submitted claim into a finance- +validated payable value, with decision and variance reason if changed. +Finance can add new line items only when they are finance-originated adjustments and must +tag source and reason. +Only finance-validated values auto-calculate into the total payables panel. ``` 11.2.3.5 Receivables from Dealer (Editable) @@ -6496,7 +6513,10 @@ Captures outstanding recoverables and pending dues, including: - Marketing recoveries - HR or Finance advances - Compliance or penalty adjustments - Each record can be added, edited, or deleted before final review. +For department-originated records, Finance cannot overwrite department claim history; it +must record a validated receivable value with variance tracking. +Finance-originated receivable rows may be added with mandatory remarks and supporting +documents. ``` 11.2.3.6 Deductions (Editable) @@ -6507,7 +6527,8 @@ Represents contingent deductions such as: - Policy violations - Miscellaneous settlements -Each item’s description, department, and value feed into the **Total Deductions** summary. +Each item includes claim value (if department-sourced) and finance-validated value. +Only finance-validated values feed into the **Total Deductions** summary. ``` 11.2.3.7 System-Calculated Formula @@ -6517,6 +6538,11 @@ At the bottom, a dynamic calculation displays: ``` Net Settlement = Total Payables – Total Receivables – Total Deductions ``` +Calculation source rule: +- `Total Payables` = Sum of finance-validated payable values +- `Total Receivables` = Sum of finance-validated receivable values +- `Total Deductions` = Sum of finance-validated deduction values + A positive balance indicates _Payable to Dealer_ ; a negative balance indicates _Recovery from Dealer_. diff --git a/reset_db.ts b/reset_db.ts index 17d007e..12da08d 100644 --- a/reset_db.ts +++ b/reset_db.ts @@ -48,10 +48,18 @@ async function resetAndSeed() { // 3. Users const users = [ - { email: 'admin@royalenfield.com', fullName: 'Laxman H', roleCode: 'Super Admin', password: hashedPassword, status: 'active' }, - { email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', roleCode: 'DD Lead', zoneId: zoneN.id, password: hashedPassword, status: 'active' }, - { email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', roleCode: 'Dealer', districtId: sDelhi.id, zoneId: zoneN.id, regionId: ncr.id, password: hashedPassword, status: 'active' }, - { email: 'lince@gmail.com', fullName: 'Lince', roleCode: 'DD Admin', password: hashedPassword, status: 'active' } + { email: 'admin@royalenfield.com', fullName: 'Super Admin', roleCode: 'Super Admin', password: hashedPassword, status: 'active' }, + { email: 'piyush@royalenfield.com', fullName: 'piyush', roleCode: 'DD-ZM', password: hashedPassword, status: 'active' }, + { email: 'manish@royalenfield.com', fullName: 'manish', roleCode: 'RBM', password: hashedPassword, status: 'active' }, + { email: 'manav@royalenfield.com', fullName: 'manav', roleCode: 'ZBH', password: hashedPassword, status: 'active' }, + { email: 'jaya@royalenfield.com', fullName: 'Jaya', roleCode: 'DD Lead', zoneId: zoneN.id, password: hashedPassword, status: 'active' }, + { email: 'ganesh@royalenfield.com', fullName: 'ganesh', roleCode: 'DD Head', password: hashedPassword, status: 'active' }, + { email: 'yashwin@royalenfield.com', fullName: 'Yashwin', roleCode: 'NBH', password: hashedPassword, status: 'active' }, + { email: 'fdd@royalenfield.com', fullName: 'FDD Team', roleCode: 'FDD', password: hashedPassword, status: 'active' }, + { email: 'finance@royalenfield.com', fullName: 'Finance Admin', roleCode: 'Finance', password: hashedPassword, status: 'active' }, + { email: 'abhishek@royalenfield.com', fullName: 'abhishek', roleCode: 'ASM', password: hashedPassword, status: 'active' }, + { email: 'lince@royalenfield.com', fullName: 'Lince', roleCode: 'DD Admin', password: hashedPassword, status: 'active' }, + { email: 'legal@royalenfield.com', fullName: 'Legal Admin', roleCode: 'Legal Admin', password: hashedPassword, status: 'active' } ]; for (const u of users) { diff --git a/scripts/reset_db_stable.ts b/scripts/reset_db_stable.ts index ad7fbb9..593971e 100644 --- a/scripts/reset_db_stable.ts +++ b/scripts/reset_db_stable.ts @@ -1,7 +1,7 @@ import 'dotenv/config'; import db from '../src/database/models/index.js'; import bcrypt from 'bcryptjs'; -import { ROLES, APPLICATION_STAGES, APPLICATION_STATUS } from '../src/common/config/constants.js'; +import { ROLES } from '../src/common/config/constants.js'; import { resolveManagerCode } from '../src/services/userRoleCode.service.js'; const { Role, User, UserRole, Zone, State, Region, Location } = db; @@ -57,21 +57,17 @@ async function masterReset() { // 4. Seed Essential Users const users = [ { email: 'admin@royalenfield.com', fullName: 'Super Admin', roleCode: ROLES.SUPER_ADMIN }, - { email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer (DD Lead)', roleCode: ROLES.DD_LEAD }, - { email: 'nbh@royalenfield.com', fullName: 'NBH Head', roleCode: ROLES.NBH }, - { email: 'cco@royalenfield.com', fullName: 'Ashok Singh (CCO)', roleCode: ROLES.CCO }, - { email: 'ceo@royalenfield.com', fullName: 'Siddhartha Lal (CEO)', roleCode: ROLES.CEO }, - { email: 'spares@royalenfield.com', fullName: 'Spares Clearance Mgr', roleCode: ROLES.SPARES_MANAGER }, - { email: 'service@royalenfield.com', fullName: 'Service Clearance Mgr', roleCode: ROLES.SERVICE_MANAGER }, - { email: 'accounts@royalenfield.com', fullName: 'Accounts Clearance Mgr', roleCode: ROLES.ACCOUNTS_MANAGER }, - { email: 'finance@royalenfield.com', fullName: 'Rahul Verma (Finance)', roleCode: ROLES.FINANCE }, + { email: 'piyush@royalenfield.com', fullName: 'piyush', roleCode: ROLES.DD_ZM }, + { email: 'manish@royalenfield.com', fullName: 'manish', roleCode: ROLES.RBM }, + { email: 'manav@royalenfield.com', fullName: 'manav', roleCode: ROLES.ZBH }, + { email: 'jaya@royalenfield.com', fullName: 'Jaya', roleCode: ROLES.DD_LEAD }, + { email: 'ganesh@royalenfield.com', fullName: 'ganesh', roleCode: ROLES.DD_HEAD }, + { email: 'yashwin@royalenfield.com', fullName: 'Yashwin', roleCode: ROLES.NBH }, + { email: 'fdd@royalenfield.com', fullName: 'FDD Team', roleCode: ROLES.FDD }, + { email: 'finance@royalenfield.com', fullName: 'Finance Admin', roleCode: ROLES.FINANCE }, + { email: 'abhishek@royalenfield.com', fullName: 'abhishek', roleCode: ROLES.ASM }, + { email: 'lince@royalenfield.com', fullName: 'Lince', roleCode: ROLES.DD_ADMIN }, { email: 'legal@royalenfield.com', fullName: 'Legal Admin', roleCode: ROLES.LEGAL_ADMIN }, - { email: 'dealer@royalenfield.com', fullName: 'Dealer One', roleCode: ROLES.DEALER, isExternal: true }, - { email: 'asm@royalenfield.com', fullName: 'Sales Manager (ASM)', roleCode: ROLES.ASM }, - { email: 'rbm@royalenfield.com', fullName: 'Regional Business Mgr', roleCode: ROLES.RBM }, - { email: 'ddzm@royalenfield.com', fullName: 'Zonal Manager (DD ZM)', roleCode: ROLES.DD_ZM }, - { email: 'zbh@royalenfield.com', fullName: 'Zonal Head (ZBH)', roleCode: ROLES.ZBH }, - { email: 'ddhead@royalenfield.com', fullName: 'Vikram Singh (DD Head)', roleCode: ROLES.DD_HEAD } ]; for (const u of users) { @@ -101,57 +97,7 @@ async function masterReset() { } console.log('✅ Standard Users seeded.'); - // 5. Seed a Dealer record for testing - const { Application, Dealer, Outlet } = db; - const dealerUser = await User.findOne({ where: { email: 'dealer@royalenfield.com' } }); - - if (dealerUser) { - // First create a mandatory Application as per model constraints - // We use 'Approved' stage and 'Onboarded' status to simulate a completed onboarding - const [application] = await Application.findOrCreate({ - where: { applicationId: 'APP-STABLE-001' }, - defaults: { - applicationId: 'APP-STABLE-001', - applicantName: dealerUser.fullName, - email: dealerUser.email, - phone: '9876543210', - businessType: 'Dealership', - userId: dealerUser.id, - currentStage: APPLICATION_STAGES.APPROVED, - overallStatus: APPLICATION_STATUS.ONBOARDED, - progressPercentage: 100, - isShortlisted: true - } - }); - - const dealer = await Dealer.create({ - applicationId: application.id, - legalName: 'Dealer One Motors Private Limited', - businessName: 'Dealer One Motors', - constitutionType: 'Private Limited', - dealerCode: 'D001', - status: 'Active', - onboardedAt: new Date() - }); - - // Update user to link to dealer profile - await dealerUser.update({ dealerId: dealer.id }); - - await db.Outlet.create({ - dealerId: dealer.id, - name: 'Main Outlet', - code: 'O001', - type: 'Dealership', - address: '123, MG Road, South Delhi', - city: 'Delhi', - state: 'Delhi', - pincode: '110001', - establishedDate: '2020-01-01', - districtId: district.id, - status: 'Active' - }); - } - console.log('✅ Test Application, Dealer & Outlet created.'); + console.log('ℹ️ Dealer user/profile not auto-seeded in stable reset (as requested).'); console.log('--- SYSTEM READY FOR OFFBOARDING TESTING ---'); process.exit(0); diff --git a/scripts/seed-approval-policies.ts b/scripts/seed-approval-policies.ts index a03f04d..d1a3b98 100644 --- a/scripts/seed-approval-policies.ts +++ b/scripts/seed-approval-policies.ts @@ -45,6 +45,13 @@ const policies = [ approvalMode: 'ROLE_MANDATORY', requiredRoles: ['DD Admin', 'Super Admin'], isActive: true + }, + { + stageCode: 'CONSTITUTIONAL_ZM_RBM_REVIEW', + minApprovals: 2, + approvalMode: 'ROLE_MANDATORY', + requiredRoles: ['DD-ZM', 'RBM'], + isActive: true } ]; diff --git a/scripts/seed-users.ts b/scripts/seed-users.ts index b94b533..76c413f 100644 --- a/scripts/seed-users.ts +++ b/scripts/seed-users.ts @@ -13,25 +13,18 @@ async function seedUsers() { const hashedPassword = await bcrypt.hash('Admin@123', 10); const usersToSeed = [ - { email: 'rbm.ncr@royalenfield.com', fullName: 'Sanjay Dutt', password: hashedPassword, roleCode: ROLES.RBM, status: 'active' }, - { email: 'zm.ncr@royalenfield.com', fullName: 'Rajesh Khanna', password: hashedPassword, roleCode: ROLES.DD_ZM, status: 'active' }, - { email: 'legal@royalenfield.com', fullName: 'Legal Admin', password: hashedPassword, roleCode: ROLES.LEGAL_ADMIN, status: 'active' }, - { email: 'ddhead@royalenfield.com', fullName: 'Vikram Singh', password: hashedPassword, roleCode: ROLES.DD_HEAD, status: 'active' }, - { email: 'nbh@royalenfield.com', fullName: 'Alwyn John', password: hashedPassword, roleCode: ROLES.NBH, status: 'active' }, - { email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, - { email: 'finance@royalenfield.com', fullName: 'Rahul Verma', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' }, - { email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', password: hashedPassword, roleCode: ROLES.DEALER, status: 'active', isExternal: true }, - { email: 'admin@royalenfield.com', fullName: 'Laxman H', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, - { email: 'yashwin@gmail.com', fullName: 'Yashwin', password: hashedPassword, roleCode: ROLES.ZBH, status: 'active' }, - { email: 'kenil@gmail.com', fullName: 'Kenil', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, - { email: 'lince@gmail.com', fullName: 'Lince', password: hashedPassword, roleCode: ROLES.DD_ADMIN, status: 'active' }, - { email: 'fdd@royalenfield.com', fullName: 'FDD Partner', password: hashedPassword, roleCode: ROLES.FDD, status: 'active' }, - { email: 'architecture@royalenfield.com', fullName: 'RE Architect', password: hashedPassword, roleCode: ROLES.ARCHITECTURE, status: 'active' }, - { email: 'cco@royalenfield.com', fullName: 'Ashok Singh (CCO)', password: hashedPassword, roleCode: ROLES.CCO, status: 'active' }, - { email: 'ceo@royalenfield.com', fullName: 'Siddhartha Lal (CEO)', password: hashedPassword, roleCode: ROLES.CEO, status: 'active' }, - { email: 'spares@royalenfield.com', fullName: 'Spares Clearance Mgr', password: hashedPassword, roleCode: ROLES.SPARES_MANAGER, status: 'active' }, - { email: 'service@royalenfield.com', fullName: 'Service Clearance Mgr', password: hashedPassword, roleCode: ROLES.SERVICE_MANAGER, status: 'active' }, - { email: 'accounts@royalenfield.com', fullName: 'Accounts Clearance Mgr', password: hashedPassword, roleCode: ROLES.ACCOUNTS_MANAGER, status: 'active' } + { email: 'admin@royalenfield.com', fullName: 'Super Admin', password: hashedPassword, roleCode: ROLES.SUPER_ADMIN, status: 'active' }, + { email: 'piyush@royalenfield.com', fullName: 'piyush', password: hashedPassword, roleCode: ROLES.DD_ZM, status: 'active' }, + { email: 'manish@royalenfield.com', fullName: 'manish', password: hashedPassword, roleCode: ROLES.RBM, status: 'active' }, + { email: 'manav@royalenfield.com', fullName: 'manav', password: hashedPassword, roleCode: ROLES.ZBH, status: 'active' }, + { email: 'jaya@royalenfield.com', fullName: 'Jaya', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' }, + { email: 'ganesh@royalenfield.com', fullName: 'ganesh', password: hashedPassword, roleCode: ROLES.DD_HEAD, status: 'active' }, + { email: 'yashwin@royalenfield.com', fullName: 'Yashwin', password: hashedPassword, roleCode: ROLES.NBH, status: 'active' }, + { email: 'fdd@royalenfield.com', fullName: 'FDD Team', password: hashedPassword, roleCode: ROLES.FDD, status: 'active' }, + { email: 'finance@royalenfield.com', fullName: 'Finance Admin', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' }, + { email: 'abhishek@royalenfield.com', fullName: 'abhishek', password: hashedPassword, roleCode: ROLES.ASM, status: 'active' }, + { email: 'lince@royalenfield.com', fullName: 'Lince', password: hashedPassword, roleCode: ROLES.DD_ADMIN, status: 'active' }, + { email: 'legal@royalenfield.com', fullName: 'Legal Admin', password: hashedPassword, roleCode: ROLES.LEGAL_ADMIN, status: 'active' } ]; for (const u of usersToSeed) { diff --git a/scripts/seed_normalized_data.ts b/scripts/seed_normalized_data.ts index 045e5d7..d305abf 100644 --- a/scripts/seed_normalized_data.ts +++ b/scripts/seed_normalized_data.ts @@ -2,7 +2,6 @@ import 'dotenv/config'; import db from '../src/database/models/index.js'; import bcrypt from 'bcryptjs'; import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js'; -import { APPLICATION_STAGES, APPLICATION_STATUS, BUSINESS_TYPES } from '../src/common/config/constants.js'; import { resolveManagerCode } from '../src/services/userRoleCode.service.js'; const { Role, Zone, Region, State, District, User, UserRole } = db; @@ -116,16 +115,13 @@ async function seed() { // 3. Create Key Management Users // National / Administrative const nationalUsers = [ - { email: 'nbh@royalenfield.com', name: 'Alwyn John', role: 'NBH' }, - { email: 'ddhead@royalenfield.com', name: 'Vikram Singh', role: 'DD Head' }, - { email: 'finance@royalenfield.com', name: 'Rahul Verma', role: 'Finance' }, - { email: 'admin@royalenfield.com', name: 'Laxman H', role: 'Super Admin' }, - { email: 'lince@gmail.com', name: 'Lince', role: 'DD Admin' }, - { email: 'fdd@royalenfield.com', name: 'FDD Partner', role: 'FDD' }, - { email: 'architecture@royalenfield.com', name: 'RE Architect', role: 'ARCHITECTURE' }, + { email: 'yashwin@royalenfield.com', name: 'Yashwin', role: 'NBH' }, + { email: 'ganesh@royalenfield.com', name: 'ganesh', role: 'DD Head' }, + { email: 'finance@royalenfield.com', name: 'Finance Admin', role: 'Finance' }, + { email: 'admin@royalenfield.com', name: 'Super Admin', role: 'Super Admin' }, + { email: 'lince@royalenfield.com', name: 'Lince', role: 'DD Admin' }, + { email: 'fdd@royalenfield.com', name: 'FDD Team', role: 'FDD' }, { email: 'legal@royalenfield.com', name: 'Legal Admin', role: 'Legal Admin' }, - { email: 'ceo@royalenfield.com', name: 'CEO User', role: 'CEO' }, - { email: 'cco@royalenfield.com', name: 'CCO User', role: 'CCO' } ]; for (const u of nationalUsers) { const [user] = await User.findOrCreate({ @@ -137,10 +133,11 @@ async function seed() { // Frontend Mock Users for Quick Login (Ensuring exact matches) const frontendMocks = [ - { email: 'ddlead@royalenfield.com', name: 'Meera Iyer', role: 'DD Lead', zone: 'North Zone' }, - { email: 'yashwin@gmail.com', name: 'Yashwin', role: 'ZBH', zone: 'North Zone' }, - { email: 'kenil@gmail.com', name: 'Kenil', role: 'DD Lead', zone: 'North Zone' }, - { email: 'dealer@royalenfield.com', name: 'Amit Sharma', role: 'Dealer', district: 'South Delhi' } + { email: 'jaya@royalenfield.com', name: 'Jaya', role: 'DD Lead', zone: 'North Zone' }, + { email: 'manav@royalenfield.com', name: 'manav', role: 'ZBH', zone: 'North Zone' }, + { email: 'piyush@royalenfield.com', name: 'piyush', role: 'DD-ZM', zone: 'North Zone' }, + { email: 'manish@royalenfield.com', name: 'manish', role: 'RBM', zone: 'North Zone' }, + { email: 'abhishek@royalenfield.com', name: 'abhishek', role: 'ASM', district: 'South Delhi' } ]; for (const m of frontendMocks) { const assignment: any = {}; @@ -161,9 +158,7 @@ async function seed() { } // Zonal Business Heads (Additional) - const zbhUsers = [ - { email: 'zbh.south@royalenfield.com', name: 'Srinivasan K', zone: 'South Zone' } - ]; + const zbhUsers: any[] = []; for (const u of zbhUsers) { const zone = zoneMap[u.zone]; const [user] = await User.findOrCreate({ @@ -175,9 +170,7 @@ async function seed() { // Regional Managers (RBMs) const rbmUsers = [ - { email: 'rbm.ncr@royalenfield.com', name: 'Sanjay Dutt', region: 'NCR Region' }, - { email: 'rbm.punjab@royalenfield.com', name: 'Harpreet Singh', region: 'Punjab Region' }, - { email: 'rbm.kar@royalenfield.com', name: 'Manish Kumar', region: 'Karnataka Region' } + { email: 'manish@royalenfield.com', name: 'manish', region: 'NCR Region' } ]; for (const u of rbmUsers) { const region = regionMap[u.region]; @@ -190,8 +183,7 @@ async function seed() { // Zonal Managers (DD-ZM) - Assigned to Regions const zmUsers = [ - { email: 'zm.ncr@royalenfield.com', name: 'Rajesh Khanna', region: 'NCR Region' }, - { email: 'zm.south@royalenfield.com', name: 'Kartik Subbaraj', region: 'Karnataka Region' } + { email: 'piyush@royalenfield.com', name: 'piyush', region: 'NCR Region' } ]; for (const u of zmUsers) { const region = regionMap[u.region]; @@ -204,9 +196,7 @@ async function seed() { // ASMs (Assigned to Districts) const asmUsers = [ - { email: 'asm.sdelhi@royalenfield.com', name: 'Arun Jaitley', district: 'South Delhi' }, - { email: 'asm.noida@royalenfield.com', name: 'Kishan Reddy', district: 'NOIDA' }, - { email: 'asm.bangalore@royalenfield.com', name: 'Vishnu Dev', district: 'Bangalore Urban' } + { email: 'abhishek@royalenfield.com', name: 'abhishek', district: 'South Delhi' } ]; for (const u of asmUsers) { const district = await District.findOne({ where: { name: u.district } }); @@ -219,85 +209,7 @@ async function seed() { } } - // 10. Create Test Dealer for Offboarding Workflows - console.log('🌱 Creating Test Dealer for Offboarding...'); - const dealerUser = await User.findOne({ where: { email: 'dealer@royalenfield.com' } }); - const asmUser = await User.findOne({ where: { email: 'asm.sdelhi@royalenfield.com' } }); - - if (dealerUser && southDelhi) { - // 1. Create Placeholder Application (The "Golden Path" root) - const [application] = await db.Application.findOrCreate({ - where: { applicationId: 'APP-TEST-001' }, - defaults: { - applicationId: 'APP-TEST-001', - applicantName: 'Amit Sharma', - email: 'dealer@royalenfield.com', - phone: '9876543210', - businessType: BUSINESS_TYPES.DEALERSHIP, - submittedBy: dealerUser.id, // Corrected: Prospect/Applicant initiates the request - constitutionType: 'Private Limited', - currentStage: APPLICATION_STAGES.APPROVED, - overallStatus: APPLICATION_STATUS.ONBOARDED, - progressPercentage: 100, - isShortlisted: true, - districtId: southDelhi.id, - documents: [] - } - }); - - // 2. Create SAP/Dealer Codes (Dependency for Dealer Profile) - const [dealerCodeRecord] = await db.DealerCode.findOrCreate({ - where: { dealerCode: 'D1001' }, - defaults: { - dealerCode: 'D1001', - applicationId: application.id, - salesCode: 'S1001', - serviceCode: 'V1001', - gmaCode: 'G1001', - gearCode: 'GE1001', - sapMasterId: 'SAP-999', - status: 'active' - } - }); - - // 3. Create Dealer Profile (Linked to App and Code) - const [dealerProfile] = await db.Dealer.findOrCreate({ - where: { applicationId: application.id }, - defaults: { - applicationId: application.id, - dealerCodeId: dealerCodeRecord.id, - legalName: 'Amit Sharma Dealership Pvt Ltd', - businessName: 'Royal Enfield South Delhi', - constitutionType: 'Private Limited', - status: 'active', - onboardedAt: new Date() - } - }); - - // 4. Update Dealer User to link to the Dealer Profile - await dealerUser.update({ dealerId: dealerProfile.id }); - - // 5. Create Main Outlet - // Note: As per Outlet model, dealerId refers to the User ID (Owner) - await db.Outlet.findOrCreate({ - where: { code: 'O001' }, - defaults: { - dealerId: dealerUser.id, // Linked to the User ID as owner - name: 'Main Outlet - South Delhi', - code: 'O001', - type: 'Dealership', - address: '123, MG Road, South Delhi', - city: 'Delhi', - state: 'Delhi', - pincode: '110001', - establishedDate: new Date('2020-01-01'), - districtId: southDelhi.id, - status: 'Active' - } - }); - - console.log('✅ Successfully seeded Golden Path: Application -> DealerCode -> Dealer Profile -> User -> Outlet'); - } + console.log('ℹ️ Dealer test profile seeding skipped (using internal users-only seed set).'); console.log('--- Triggering Hierarchy Synchronization ---'); // ... (rest same) diff --git a/src/database/models/FnFLineItem.ts b/src/database/models/FnFLineItem.ts index 55789b8..d30dd57 100644 --- a/src/database/models/FnFLineItem.ts +++ b/src/database/models/FnFLineItem.ts @@ -8,6 +8,15 @@ export interface FnFLineItemAttributes { department: string; amount: number; addedBy: string | null; + sourceType: 'DepartmentClaim' | 'FinanceValidated'; + version: number; + isActive: boolean; + parentLineItemId: string | null; + claimAmount: number | null; + validatedAmount: number | null; + varianceAmount: number; + financeDecision: 'Accepted' | 'Partially Accepted' | 'Rejected' | 'Under Clarification' | null; + varianceReason: string | null; } export interface FnFLineItemInstance extends Model, FnFLineItemAttributes { } @@ -51,6 +60,46 @@ export default (sequelize: Sequelize) => { model: 'users', key: 'id' } + }, + sourceType: { + type: DataTypes.ENUM('DepartmentClaim', 'FinanceValidated'), + allowNull: false, + defaultValue: 'FinanceValidated' + }, + version: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1 + }, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + parentLineItemId: { + type: DataTypes.UUID, + allowNull: true + }, + claimAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + validatedAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + varianceAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + defaultValue: 0 + }, + financeDecision: { + type: DataTypes.ENUM('Accepted', 'Partially Accepted', 'Rejected', 'Under Clarification'), + allowNull: true + }, + varianceReason: { + type: DataTypes.TEXT, + allowNull: true } }, { tableName: 'fnf_line_items', @@ -58,7 +107,9 @@ export default (sequelize: Sequelize) => { indexes: [ { fields: ['fnfId'] }, { fields: ['itemType'] }, - { fields: ['department'] } + { fields: ['department'] }, + { fields: ['isActive'] }, + { fields: ['sourceType'] } ] }); diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index 1b7bacb..9166e28 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -13,16 +13,27 @@ import { WorkflowService } from '../../services/WorkflowService.js'; export const getDealers = async (req: Request, res: Response) => { try { const where: Record = {}; + const query = (req.query || {}) as Record; if (String((req.query as any)?.onboarded || '') === 'true') { where.onboardedAt = { [Op.ne]: null }; } + if (String(query.activeOnly || '') === 'true') { + where.status = { [Op.iLike]: 'active' }; + } const dealers = await Dealer.findAll({ where, include: [ { model: DealerCode, as: 'dealerCode' }, { model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] }, - { model: User, as: 'user', attributes: ['id', 'email', 'status', 'isActive', 'roleCode'] } + { + model: User, + as: 'user', + attributes: ['id', 'email', 'status', 'isActive', 'roleCode'], + ...(String(query.activeOnly || '') === 'true' + ? { where: { isActive: true, status: { [Op.iLike]: 'active' } }, required: true } + : {}) + } ], order: [['createdAt', 'DESC']] }); diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index d88e105..67e9bcd 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -1,6 +1,6 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District } = db; +const { ConstitutionalChange, ConstitutionalAudit, Outlet, User, Worknote, Dealer, Application, District, StageApprovalPolicy } = db; import { Op } from 'sequelize'; import { AuthRequest } from '../../types/express.types.js'; import { @@ -361,6 +361,16 @@ const STAGE_FLOW_BACK: Record = { [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL }; +const CONSTITUTIONAL_STAGE_POLICY_CODES: Record = { + [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 'CONSTITUTIONAL_ZM_RBM_REVIEW' +}; + +const normalizeRoleKey = (rawRole: string) => { + const role = String(rawRole || '').trim().toUpperCase(); + if (role === 'DD ZM' || role === 'ZM' || role === 'DD-ZM') return 'DD-ZM'; + return role; +}; + const actionSuccessMessage = (raw: string): string => { const a = String(raw || '').trim().toLowerCase(); if (a === 'reject') return 'Request rejected successfully'; @@ -474,6 +484,97 @@ export const takeAction = async (req: AuthRequest, res: Response) => { return res.status(400).json({ success: false, message: 'Unsupported action. Use Approve, Reject, Send Back, or Revoke.' }); } + const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW; + if (isZmRbmJointStage) { + const stageCode = CONSTITUTIONAL_STAGE_POLICY_CODES[request.currentStage]; + const policy = stageCode + ? await StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } }) + : null; + const requiredRoles = (policy?.requiredRoles?.length ? policy.requiredRoles : ['DD-ZM', 'RBM']) + .map((r: string) => normalizeRoleKey(r)); + const minApprovals = Math.max( + Number(policy?.minApprovals || requiredRoles.length || 2), + requiredRoles.length ? 1 : 2 + ); + + const actorRole = normalizeRoleKey(String(req.user.roleCode || req.user.role || '')); + const isSuperAdmin = actorRole === 'SUPER ADMIN' || actorRole === 'SUPER_ADMIN'; + if (!isSuperAdmin && !requiredRoles.includes(actorRole)) { + return res.status(403).json({ + success: false, + message: `Role ${req.user.roleCode || req.user.role} is not allowed to approve at ${request.currentStage}. Required: ${requiredRoles.join(', ')}` + }); + } + + const metadata = { ...(request.metadata || {}) } as any; + const jointApprovals = { ...(metadata.jointApprovals || {}) } as any; + const zmRbm = { ...(jointApprovals.zmRbm || {}) } as any; + + if (zmRbm[actorRole]?.approvedByUserId) { + return res.status(400).json({ + success: false, + message: 'Your approval is already recorded at ZM/RBM stage. Waiting for the remaining approver(s).' + }); + } + + zmRbm[actorRole] = { + approvedByUserId: req.user.id, + approvedBy: req.user.fullName, + approvedAt: new Date().toISOString(), + remarks: comments || '' + }; + + metadata.jointApprovals = { + ...jointApprovals, + zmRbm + }; + + const approvedRequiredRoles = requiredRoles.filter((role) => Boolean(zmRbm[role]?.approvedByUserId)); + const waitingFor = requiredRoles.filter((role) => !zmRbm[role]?.approvedByUserId); + const approvalThresholdMet = approvedRequiredRoles.length >= Math.min(minApprovals, requiredRoles.length); + + if (!approvalThresholdMet) { + await request.update({ metadata, updatedAt: new Date() }); + + await ConstitutionalAudit.create({ + userId: req.user.id, + constitutionalChangeId: request.id, + action: AUDIT_ACTIONS.APPROVED, + remarks: `Joint approval recorded at ${request.currentStage} (waiting for remaining approver(s))`, + details: { + stage: request.currentStage, + policy: { + stageCode, + approvalMode: policy?.approvalMode || 'ROLE_MANDATORY', + minApprovals, + requiredRoles + }, + approvals: metadata.jointApprovals?.zmRbm || {}, + waitingFor + } + }); + + try { + await writeWorkflowActivityWorknote({ + requestId: request.id, + requestType: 'constitutional', + userId: req.user.id, + noteText: `[Joint Approval] ${req.user.fullName} approved at ZM/RBM stage. Waiting for ${waitingFor.join(', ')}.`, + noteType: 'internal' + }); + } catch (wnErr) { + console.error('[constitutional] workflow worknote:', wnErr); + } + + return res.json({ + success: true, + message: `Approval captured. Waiting for ${waitingFor.join(', ')} to approve.` + }); + } + + await request.update({ metadata, updatedAt: new Date() }); + } + const nextStage = STAGE_FLOW_FORWARD[request.currentStage]; if (!nextStage) { return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' }); diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index 0cad428..a28e26d 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -728,14 +728,16 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex } 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: status || 'Pending', + status: normalizedDeptStatus, remarks, - amount: amount || 0, + amount: normalizedAmount, type: type || 'Recovery', supportingDocument: documentUrl, updatedAt: new Date().toISOString(), @@ -749,7 +751,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex stage: resignation.currentStage, timestamp: new Date(), user: req.user.fullName, - action: `Updated clearance for ${department}: ${status}`, + action: `Updated clearance for ${department}: ${normalizedDeptStatus}`, remarks }] }, { transaction }); @@ -760,13 +762,13 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex resignationId: resignation.id, action: 'CLEARANCE_UPDATED', remarks: remarks || `Cleared ${department}`, - details: { department, status, amount } + 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 = parseFloat(amount) || 0; + const numAmount = normalizedAmount; const fnfStatus = normalizeClearanceStatus(status, numAmount); const existingClearance = await db.FffClearance.findOne({ @@ -801,43 +803,58 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex details: { department, status: fnfStatus, source: 'Resignation Workflow' } }, { transaction }); - // If there's an amount, create/update line item - if (amount > 0) { - const existingItem = await db.FnFLineItem.findOne({ - where: { - fnfId: fnf.id, - department, - description: { [Op.like]: `${department} Clearance:%` } - }, - 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 + }); - const lineItemData = { + if (enteredAmount > 0) { + if (existingClaim) { + await existingClaim.update({ isActive: false }, { transaction }); + } + await db.FnFLineItem.create({ fnfId: fnf.id, itemType: type || 'Receivable', - description: `${department} Clearance: ${remarks || 'Outstanding Dues'}`, + description: '[DEPARTMENT_CLAIM] Department Clearance - Manual Update', department, - amount: Math.abs(parseFloat(amount)), // Always positive magnitude - addedBy: req.user.id - }; - - if (existingItem) { - await existingItem.update(lineItemData, { transaction }); - } else { - await db.FnFLineItem.create(lineItemData, { transaction }); - } + 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 using the standardized formula - const items = await db.FnFLineItem.findAll({ where: { fnfId: fnf.id }, 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; - items.forEach((item: any) => { + calculationItems.forEach((item: any) => { const val = Math.abs(parseFloat(item.amount) || 0); if (item.itemType === 'Payable') totalPayables += val; - else if (item.itemType === 'Receivable') totalReceivables += val; + else if (item.itemType === 'Receivable' || item.itemType === 'Recovery') totalReceivables += val; else if (item.itemType === 'Deduction') totalDeductions += val; }); diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index dcdb7a6..69c227e 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -2,12 +2,74 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog } = db; import { AuthRequest } from '../../types/express.types.js'; -import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINATION_STAGES } from '../../common/config/constants.js'; +import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS, RESIGNATION_STAGES, TERMINATION_STAGES, ROLES } from '../../common/config/constants.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js'; import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; +const LINE_ITEM_DESCRIPTION_PREFIX = { + DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]', + FINANCE_VALIDATED: '[FINANCE_VALIDATED]' +} as const; + +const DEPARTMENT_CLAIM_DESCRIPTION = `${LINE_ITEM_DESCRIPTION_PREFIX.DEPARTMENT_CLAIM} Department Clearance - Manual Update`; + +const withPrefix = (description: string, prefix: string) => { + if (!description) return prefix; + return description.startsWith(prefix) ? description : `${prefix} ${description}`; +}; + +const isDepartmentClaimLineItem = (item: any) => + typeof item?.description === 'string' && item.description.startsWith(LINE_ITEM_DESCRIPTION_PREFIX.DEPARTMENT_CLAIM); + +const isFinanceValidatedLineItem = (item: any) => + typeof item?.description === 'string' && item.description.startsWith(LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED); + +const getActiveLineItems = (lineItems: any[]) => + (lineItems || []).filter((item: any) => item.isActive !== false); + +const ensureFinanceDraftsFromDepartmentClaims = async (fnfId: string, userId: string | null = null) => { + const allLineItems = await FnFLineItem.findAll({ + where: { fnfId }, + order: [['createdAt', 'ASC']] + }); + + const activeDepartmentClaims = allLineItems.filter((item: any) => + item.isActive !== false && + (item.sourceType === 'DepartmentClaim' || isDepartmentClaimLineItem(item)) + ); + + for (const claim of activeDepartmentClaims) { + const claimRootId = (claim as any).parentLineItemId || claim.id; + const hasFinanceLineForClaim = allLineItems.some((item: any) => + (item.sourceType === 'FinanceValidated' || isFinanceValidatedLineItem(item)) && + ((item as any).parentLineItemId === claimRootId || item.department === claim.department) + ); + + if (hasFinanceLineForClaim) continue; + + const claimAmount = Math.abs(Number((claim as any).claimAmount ?? claim.amount) || 0); + await FnFLineItem.create({ + fnfId, + itemType: claim.itemType, + description: withPrefix('Auto-seeded from department claim', LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED), + department: claim.department, + amount: claimAmount, + addedBy: userId, + sourceType: 'FinanceValidated', + version: 1, + isActive: true, + parentLineItemId: claimRootId, + claimAmount, + validatedAmount: claimAmount, + varianceAmount: 0, + financeDecision: 'Accepted', + varianceReason: null + }); + } +}; + export const getDepartments = async (req: Request, res: Response) => { try { res.json({ success: true, departments: FNF_DEPARTMENTS }); @@ -174,40 +236,51 @@ export const getFnFSettlements = async (req: Request, res: Response) => { export const getFnFById = async (req: Request, res: Response) => { try { const { id } = req.params; - const fnf = await FnF.findByPk(id, { - include: [ - { model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] }, - { model: TerminationRequest, as: 'terminationRequest' }, - { - model: Outlet, - as: 'outlet', - include: [{ - model: User, - as: 'dealer', - include: [{ - model: db.Dealer, - as: 'dealerProfile', - include: [ - { model: db.DealerCode, as: 'dealerCode' }, - { model: db.DealerBankDetail, as: 'bankDetails' } - ] - }] - }] - }, - { - model: db.Dealer, + const includeConfig = [ + { model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] }, + { model: TerminationRequest, as: 'terminationRequest' }, + { + model: Outlet, + as: 'outlet', + include: [{ + model: User, as: 'dealer', - include: [ - { model: db.DealerCode, as: 'dealerCode' }, - { model: db.DealerBankDetail, as: 'bankDetails' } - ] - }, - { model: FnFLineItem, as: 'lineItems' }, - { model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] } - ] + include: [{ + model: db.Dealer, + as: 'dealerProfile', + include: [ + { model: db.DealerCode, as: 'dealerCode' }, + { model: db.DealerBankDetail, as: 'bankDetails' } + ] + }] + }] + }, + { + model: db.Dealer, + as: 'dealer', + include: [ + { model: db.DealerCode, as: 'dealerCode' }, + { model: db.DealerBankDetail, as: 'bankDetails' } + ] + }, + { model: FnFLineItem, as: 'lineItems' }, + { model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] } + ]; + + const fnf = await FnF.findByPk(id, { + include: includeConfig }); if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' }); - res.json({ success: true, fnf }); + + await ensureFinanceDraftsFromDepartmentClaims(id, null); + + const fnfWithDrafts = await FnF.findByPk(id, { + include: [ + ...includeConfig + ] + }); + if (!fnfWithDrafts) return res.status(404).json({ success: false, message: 'F&F not found' }); + res.json({ success: true, fnf: fnfWithDrafts }); } catch (error) { res.status(500).json({ success: false, message: 'Error fetching F&F' }); } @@ -217,8 +290,26 @@ export const addLineItem = async (req: AuthRequest, res: Response) => { try { const { id } = req.params; const { itemType, description, department, amount } = req.body; + const fnf = await FnF.findByPk(id); + if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); + + if (fnf.status === FNF_STATUS.COMPLETED) { + return res.status(400).json({ success: false, message: 'Cannot add line items after settlement completion' }); + } + const lineItem = await FnFLineItem.create({ - fnfId: id, itemType, description, department, amount, addedBy: req.user?.id + fnfId: id, + itemType, + description: withPrefix(description, LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED), + department, + amount: Math.abs(Number(amount) || 0), + addedBy: req.user?.id, + sourceType: 'FinanceValidated', + version: 1, + isActive: true, + validatedAmount: Math.abs(Number(amount) || 0), + financeDecision: 'Accepted', + varianceAmount: 0 }); // Update FnF progress and department statuses @@ -244,21 +335,73 @@ export const updateLineItem = async (req: AuthRequest, res: Response) => { const { description, department, amount } = req.body; const lineItem = await FnFLineItem.findByPk(itemId); if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); - await lineItem.update({ description, department, amount }); - // Update FnF progress and department statuses - // Update FnF progress and department statuses - await calculateFnFLogic(lineItem.fnfId, req.user?.id); + const fnf = await FnF.findByPk(lineItem.fnfId); + if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); + + if (fnf.status === FNF_STATUS.COMPLETED) { + return res.status(400).json({ success: false, message: 'Cannot update line items after settlement completion' }); + } + + if (isDepartmentClaimLineItem(lineItem)) { + return res.status(400).json({ + success: false, + message: 'Department claim lines are immutable in Finance edit flow. Use department clearance update.' + }); + } + + const nextAmount = Math.abs(Number(amount) || 0); + const nextDescription = withPrefix(description || lineItem.description, LINE_ITEM_DESCRIPTION_PREFIX.FINANCE_VALIDATED); + const nextDepartment = department || lineItem.department; + const prevValidated = Math.abs(Number((lineItem as any).validatedAmount ?? lineItem.amount) || 0); + const claimAmount = Math.abs(Number((lineItem as any).claimAmount) || 0) || null; + const varianceAmount = claimAmount !== null ? claimAmount - nextAmount : 0; + const financeDecision = varianceAmount === 0 ? 'Accepted' : 'Partially Accepted'; + const varianceReason = varianceAmount === 0 ? null : 'Adjusted during finance reconciliation'; + + await lineItem.update({ isActive: false }); + + const updatedLineItem = await FnFLineItem.create({ + fnfId: lineItem.fnfId, + itemType: lineItem.itemType, + description: nextDescription, + department: nextDepartment, + amount: nextAmount, + addedBy: req.user?.id || lineItem.addedBy, + sourceType: 'FinanceValidated', + version: Number((lineItem as any).version || 1) + 1, + isActive: true, + parentLineItemId: (lineItem as any).parentLineItemId || lineItem.id, + claimAmount, + validatedAmount: nextAmount, + varianceAmount, + financeDecision, + varianceReason + }); await AuditLog.create({ userId: req.user?.id || null, action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: lineItem.fnfId, - newData: { action: 'UPDATE_LINE_ITEM', department, amount, description } + newData: { + action: 'FINANCE_VALIDATION_VERSION_CREATED', + previousLineItemId: lineItem.id, + newLineItemId: updatedLineItem.id, + previousValidatedAmount: prevValidated, + validatedAmount: nextAmount, + claimAmount, + varianceAmount, + financeDecision, + varianceReason + } }); - res.json({ success: true, lineItem }); + // Update FnF progress and department statuses + // Update FnF progress and department statuses + await calculateFnFLogic(lineItem.fnfId, req.user?.id); + + return res.json({ success: true, lineItem: updatedLineItem }); } catch (error) { res.status(500).json({ success: false, message: 'Error updating line item' }); } @@ -269,9 +412,16 @@ export const deleteLineItem = async (req: AuthRequest, res: Response) => { const { itemId } = req.params; const lineItem = await FnFLineItem.findByPk(itemId); if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' }); + + if (isDepartmentClaimLineItem(lineItem)) { + return res.status(400).json({ + success: false, + message: 'Department claim lines cannot be deleted from Finance edit flow.' + }); + } const fnfId = lineItem.fnfId; - await lineItem.destroy(); + await lineItem.update({ isActive: false }); // Update FnF progress and department statuses // Update FnF progress and department statuses @@ -282,7 +432,7 @@ export const deleteLineItem = async (req: AuthRequest, res: Response) => { action: AUDIT_ACTIONS.FNF_UPDATED, entityType: 'fnf', entityId: fnfId, - newData: { action: 'DELETE_LINE_ITEM', department: lineItem.department, amount: lineItem.amount } + newData: { action: 'DELETE_LINE_ITEM', department: lineItem.department, amount: lineItem.amount, softDeleted: true } }); res.json({ success: true, message: 'Line item deleted' }); @@ -298,16 +448,28 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { }); if (!fnf) return null; - const lineItems = (fnf as any).lineItems || []; - const clearances = (fnf as any).clearances || []; + await ensureFinanceDraftsFromDepartmentClaims(id, userId); + + const refreshedFnf = await FnF.findByPk(id, { + include: [{ model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' }] + }); + if (!refreshedFnf) return null; + + const lineItems = getActiveLineItems((refreshedFnf as any).lineItems || []); + const clearances = (refreshedFnf as any).clearances || []; let totalReceivables = 0; let totalPayables = 0; let totalDeductions = 0; - lineItems.forEach((item: any) => { + const hasFinanceValidatedLines = lineItems.some((item: any) => isFinanceValidatedLineItem(item)); + const calculationLineItems = hasFinanceValidatedLines + ? lineItems.filter((item: any) => isFinanceValidatedLineItem(item)) + : lineItems; + + calculationLineItems.forEach((item: any) => { const amt = Math.abs(parseFloat(item.amount) || 0); - if (item.itemType === 'Receivable') totalReceivables += amt; + if (item.itemType === 'Receivable' || item.itemType === 'Recovery') totalReceivables += amt; else if (item.itemType === 'Payable') totalPayables += amt; else if (item.itemType === 'Deduction') totalDeductions += amt; }); @@ -331,7 +493,7 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { // Only update if it's already been processed (not Pending) if (clearance.status === 'Pending') continue; - const deptDues = lineItems.filter((li: any) => li.department === clearance.department); + const deptDues = calculationLineItems.filter((li: any) => li.department === clearance.department); const totalDeptAmount = deptDues.reduce((sum: number, li: any) => sum + (parseFloat(li.amount) || 0), 0); const targetStatus = totalDeptAmount > 0 ? 'Dues Pending' : 'NOC Submitted'; @@ -341,15 +503,15 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { } // Determine Overall F&F Status - let newStatus = normalizeFnFStatus(fnf.status); - if (fnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) { + let newStatus = normalizeFnFStatus(refreshedFnf.status); + if (refreshedFnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) { newStatus = FNF_STATUS.DD_CLEARANCE; } - if (allCleared && fnf.status !== FNF_STATUS.COMPLETED) { + if (allCleared && refreshedFnf.status !== FNF_STATUS.COMPLETED) { newStatus = FNF_STATUS.FINANCE_APPROVAL; } - await fnf.update({ + await refreshedFnf.update({ totalReceivables, totalPayables, totalDeductions, netAmount, status: newStatus, progressPercentage @@ -357,8 +519,8 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { // If status moved to Completed, also update parent resignation/termination if (newStatus === FNF_STATUS.COMPLETED || newStatus === 'Completed') { - if (fnf.resignationId) { - const resignation = await Resignation.findByPk(fnf.resignationId); + if (refreshedFnf.resignationId) { + const resignation = await Resignation.findByPk(refreshedFnf.resignationId); if (resignation) { await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.COMPLETED, userId, { action: 'F&F Settlement Completed', @@ -366,8 +528,8 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { status: 'Completed' }); } - } else if (fnf.terminationRequestId) { - const terminationRequest = await TerminationRequest.findByPk(fnf.terminationRequestId); + } else if (refreshedFnf.terminationRequestId) { + const terminationRequest = await TerminationRequest.findByPk(refreshedFnf.terminationRequestId); if (terminationRequest) { await TerminationWorkflowService.transitionTermination(terminationRequest, TERMINATION_STAGES.TERMINATED, userId, { action: 'F&F Settlement Completed', @@ -378,7 +540,7 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => { } } - return fnf; + return refreshedFnf; }; export const updateClearance = async (req: AuthRequest, res: Response) => { @@ -388,6 +550,15 @@ export const updateClearance = async (req: AuthRequest, res: Response) => { const { status, remarks, documentId, supportingDocument, amount, type } = body; const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } }); if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' }); + const fnf = await FnF.findByPk(id); + if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' }); + + if ([FNF_STATUS.FINANCE_APPROVAL, FNF_STATUS.COMPLETED].includes(fnf.status) && ![ROLES.FINANCE, ROLES.SUPER_ADMIN].includes(req.user?.role as any)) { + return res.status(400).json({ + success: false, + message: 'Department response window is closed for this case.' + }); + } const uploadedSupportingDocument = req.file ? `/uploads/documents/${req.file.filename}` : undefined; const enteredAmount = Math.abs(Number(amount) || 0); @@ -397,23 +568,38 @@ export const updateClearance = async (req: AuthRequest, res: Response) => { : clearanceType === 'deduction' ? 'Deduction' : 'Receivable'; - const syntheticDescription = 'Department Clearance - Manual Update'; + const syntheticDescription = DEPARTMENT_CLAIM_DESCRIPTION; - // Persist amount/type as F&F line item so UI totals and department amount reflect user input. + // Persist amount/type as immutable department-claim line item. const existingSyntheticLine = await FnFLineItem.findOne({ where: { fnfId: id, department: clearance.department, - description: syntheticDescription + description: syntheticDescription, + isActive: true } }); if (enteredAmount > 0) { if (existingSyntheticLine) { - await existingSyntheticLine.update({ + await existingSyntheticLine.update({ isActive: false }); + + await FnFLineItem.create({ + fnfId: id, itemType, + description: syntheticDescription, + department: clearance.department, amount: enteredAmount, - addedBy: req.user?.id || existingSyntheticLine.addedBy + addedBy: req.user?.id || null, + sourceType: 'DepartmentClaim', + version: Number((existingSyntheticLine as any).version || 1) + 1, + isActive: true, + parentLineItemId: (existingSyntheticLine as any).parentLineItemId || existingSyntheticLine.id, + claimAmount: enteredAmount, + validatedAmount: null, + varianceAmount: 0, + financeDecision: null, + varianceReason: null }); } else { await FnFLineItem.create({ @@ -422,11 +608,19 @@ export const updateClearance = async (req: AuthRequest, res: Response) => { description: syntheticDescription, department: clearance.department, amount: enteredAmount, - addedBy: req.user?.id || null + addedBy: req.user?.id || null, + sourceType: 'DepartmentClaim', + version: 1, + isActive: true, + claimAmount: enteredAmount, + validatedAmount: null, + varianceAmount: 0, + financeDecision: null, + varianceReason: null }); } } else if (existingSyntheticLine) { - await existingSyntheticLine.destroy(); + await existingSyntheticLine.update({ isActive: false }); } const normalizedStatus = normalizeClearanceStatus(status || clearance.status, enteredAmount); diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index 20b6c8f..e68c0c6 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -40,14 +40,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N proposedLwd, comments, initiatedBy: req.user.id, - currentStage: TERMINATION_STAGES.SUBMITTED, - status: getTerminationStatusForStage(TERMINATION_STAGES.SUBMITTED), - progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.SUBMITTED), + currentStage: TERMINATION_STAGES.RBM_REVIEW, + status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW), + progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW), timeline: [{ stage: 'Submitted', + targetStage: TERMINATION_STAGES.RBM_REVIEW, timestamp: new Date(), user: req.user.fullName, - action: 'Termination request initiated', + action: `Termination request initiated and forwarded to ${TERMINATION_STAGES.RBM_REVIEW}`, remarks: comments }] }, { transaction }); @@ -136,7 +137,8 @@ export const getTerminationById = async (req: AuthRequest, res: Response, next: } ] }, - { model: db.DealerCode, as: 'dealerCode' } + { model: db.DealerCode, as: 'dealerCode' }, + { model: db.User, as: 'user', attributes: ['id', 'email', 'mobileNumber', 'status'] } ] }, { model: db.User, as: 'initiator', attributes: ['id', 'fullName', 'email'] }, @@ -228,6 +230,19 @@ export const uploadTerminationDocument = async (req: AuthRequest, res: Response, }]; await termination.update({ timeline }, { transaction }); + const normalizedStage = String(stage || '').trim().toLowerCase(); + const isScnStageUpload = normalizedStage === 'show cause notice' || normalizedStage === 'show cause notice (scn)' || normalizedStage === 'scn'; + const isScnResponseDoc = String(documentType || '').trim().toLowerCase() === 'scn response'; + if ( + termination.currentStage === TERMINATION_STAGES.SCN_ISSUED && + (isScnStageUpload || isScnResponseDoc) + ) { + await TerminationWorkflowService.handleScnResponse(termination, { + responseBody: `SCN response uploaded: ${req.file.originalname}`, + documents: [{ fileName: req.file.originalname, filePath }] + }, req.user.id); + } + await transaction.commit(); res.status(201).json({ success: true, message: 'Document uploaded successfully', document }); } catch (error) { @@ -371,6 +386,84 @@ export const submitScnResponse = async (req: AuthRequest, res: Response, next: N } }; +// Issue SCN for a specific termination request id (frontend-compatible route) +export const issueScn = 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 resolveTerminationUuid(String(id)); + + const termination = await db.TerminationRequest.findByPk(resolvedId); + if (!termination) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Termination request not found' }); + } + + if (termination.currentStage === TERMINATION_STAGES.NBH_EVALUATION) { + await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.SCN_ISSUED, req.user.id, { + action: 'SCN Issued', + status: 'Show Cause Notice', + remarks: remarks || 'Show Cause Notice issued' + }); + } + + await transaction.commit(); + return res.json({ success: true, message: 'SCN issued successfully', termination }); + } catch (error) { + if (transaction) await transaction.rollback(); + logger.error('Error issuing SCN:', error); + next(error); + } +}; + +// Upload SCN response by route param id (frontend-compatible route) +export const uploadScnResponse = 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 resolveTerminationUuid(String(id)); + + const termination = await db.TerminationRequest.findByPk(resolvedId); + if (!termination) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Termination request not found' }); + } + + if (req.file) { + const filePath = `/uploads/documents/${req.file.filename}`; + await db.TerminationDocument.create({ + terminationRequestId: termination.id, + documentType: 'SCN Response', + fileName: req.file.originalname, + filePath, + fileSize: req.file.size, + mimeType: req.file.mimetype, + stage: TERMINATION_STAGES.SCN_ISSUED, + uploadedBy: req.user.id + }, { transaction }); + } + + // Move SCN -> Personal Hearing after response submission. + if (termination.currentStage === TERMINATION_STAGES.SCN_ISSUED) { + await TerminationWorkflowService.handleScnResponse(termination, { + responseBody: remarks || 'SCN response uploaded via portal', + documents: req.file ? [{ fileName: req.file.originalname }] : [] + }, req.user.id); + } + + await transaction.commit(); + return res.status(201).json({ success: true, message: 'SCN response uploaded successfully', termination }); + } catch (error) { + if (transaction) await transaction.rollback(); + logger.error('Error uploading SCN response:', error); + next(error); + } +}; + // Record Personal Hearing Outcome export const recordPersonalHearing = async (req: AuthRequest, res: Response, next: NextFunction) => { const transaction: Transaction = await db.sequelize.transaction(); @@ -392,6 +485,70 @@ export const recordPersonalHearing = async (req: AuthRequest, res: Response, nex } }; +// Final Authorization (NBH Final / CCO / CEO) +export const finalizeTermination = 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 { decision, remarks } = req.body as { decision?: 'Approve' | 'Reject' | 'Reconsider'; remarks?: string }; + const resolvedId = await resolveTerminationUuid(String(id)); + + const termination = await db.TerminationRequest.findByPk(resolvedId); + if (!termination) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Termination request not found' }); + } + + const currentStage = termination.currentStage; + const allowedFinalizeStages = [ + TERMINATION_STAGES.NBH_FINAL_APPROVAL, + TERMINATION_STAGES.CCO_APPROVAL, + TERMINATION_STAGES.CEO_APPROVAL + ]; + if (!allowedFinalizeStages.includes(currentStage as any)) { + await transaction.rollback(); + return res.status(400).json({ + success: false, + message: `Finalize action is not allowed at stage: ${currentStage}` + }); + } + + if (decision === 'Reject') { + await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, { + action: 'Final Authorization Rejected', + status: 'Rejected', + remarks: remarks || 'Rejected during final authorization' + }); + } else if (decision === 'Reconsider') { + await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.NBH_EVALUATION, req.user.id, { + action: 'Sent for Reconsideration', + status: 'NBH Evaluation', + remarks: remarks || 'Sent back for reconsideration' + }); + } else { + const approveFlow: Record = { + [TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.CCO_APPROVAL, + [TERMINATION_STAGES.CCO_APPROVAL]: TERMINATION_STAGES.CEO_APPROVAL, + [TERMINATION_STAGES.CEO_APPROVAL]: TERMINATION_STAGES.LEGAL_LETTER + }; + const targetStage = approveFlow[currentStage]; + await TerminationWorkflowService.transitionTermination(termination, targetStage, req.user.id, { + action: `Final Authorization Approved to ${targetStage}`, + status: getTerminationStatusForStage(targetStage), + remarks: remarks || 'Approved' + }); + } + + await transaction.commit(); + return res.json({ success: true, message: 'Final authorization processed', termination }); + } catch (error) { + if (transaction) await transaction.rollback(); + logger.error('Error finalizing termination:', error); + next(error); + } +}; + // Record Clearance from Departments (16-Department F&F) export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => { const transaction: Transaction = await db.sequelize.transaction(); diff --git a/src/modules/termination/termination.routes.ts b/src/modules/termination/termination.routes.ts index af7547b..7ba16c3 100644 --- a/src/modules/termination/termination.routes.ts +++ b/src/modules/termination/termination.routes.ts @@ -2,7 +2,7 @@ import express from 'express'; const router = express.Router(); import { createTermination, getTerminations, getTerminationById, updateTerminationStatus, - submitScnResponse, recordPersonalHearing, updateClearance, uploadTerminationDocument + submitScnResponse, recordPersonalHearing, updateClearance, uploadTerminationDocument, issueScn, uploadScnResponse, finalizeTermination } from './termination.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; import { uploadSingle } from '../../common/middleware/upload.js'; @@ -14,6 +14,9 @@ router.get('/', getTerminations); router.get('/:id', getTerminationById); router.put('/:id/status', updateTerminationStatus); router.post('/:id/status', updateTerminationStatus); +router.post('/:id/scn', issueScn); +router.post('/:id/scn-response', uploadSingle, uploadScnResponse); +router.post('/:id/finalize', finalizeTermination); router.post('/scn-response', submitScnResponse); router.post('/hearing-record', recordPersonalHearing); router.put('/:id/clearance', updateClearance); diff --git a/trigger-constitutional.js b/trigger-constitutional.js index 780228f..3665789 100644 --- a/trigger-constitutional.js +++ b/trigger-constitutional.js @@ -8,14 +8,14 @@ const PASSWORD = 'Admin@123'; const STEP_DELAY_MS = Number(args.delayMs || 500); const EMAILS = { - DD_ADMIN: 'lince@gmail.com', - DEALER: 'dealer@royalenfield.com', - ASM: 'asm.sdelhi@royalenfield.com', - RBM_L1: 'rbm.ncr@royalenfield.com', - ZBH: 'yashwin@gmail.com', - DD_LEAD: 'ddlead@royalenfield.com', - DD_HEAD: 'ddhead@royalenfield.com', - NBH: 'nbh@royalenfield.com', + DD_ADMIN: 'lince@royalenfield.com', + DEALER: args.dealerEmail, + ASM: 'abhishek@royalenfield.com', + RBM_L1: 'manish@royalenfield.com', + ZBH: 'manav@royalenfield.com', + DD_LEAD: 'jaya@royalenfield.com', + DD_HEAD: 'ganesh@royalenfield.com', + NBH: 'yashwin@royalenfield.com', LEGAL: 'legal@royalenfield.com', SALES: 'sales@royalenfield.com', SERVICE: 'service@royalenfield.com', @@ -61,6 +61,9 @@ const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms)); async function run() { try { console.log('--- STARTING CONSTITUTIONAL CHANGE E2E FLOW ---'); + if (!EMAILS.DEALER) { + throw new Error('Missing --dealerEmail. This script requires an existing dealer user email.'); + } console.log(`[STEP 0] Logging in as Dealer: ${EMAILS.DEALER}...`); const dealerToken = await login(EMAILS.DEALER); diff --git a/trigger-relocation.js b/trigger-relocation.js index f5b0922..c831b26 100644 --- a/trigger-relocation.js +++ b/trigger-relocation.js @@ -9,15 +9,15 @@ const PASSWORD = "Admin@123"; const STEP_DELAY_MS = Number(args.delayMs || 500); const EMAILS = { - DD_ADMIN: args.ddAdminEmail || "lince@gmail.com", - DEALER: args.dealerEmail || "dealer@royalenfield.com", - ASM: args.asmEmail || "asm.sdelhi@royalenfield.com", - RBM: args.rbmEmail || "rbm.ncr@royalenfield.com", - DD_ZM: args.ddZmEmail || "zm.ncr@royalenfield.com", - ZBH: args.zbhEmail || "yashwin@gmail.com", - DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com", - DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com", - NBH: args.nbhEmail || "nbh@royalenfield.com", + DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com", + DEALER: args.dealerEmail, + ASM: args.asmEmail || "abhishek@royalenfield.com", + RBM: args.rbmEmail || "manish@royalenfield.com", + DD_ZM: args.ddZmEmail || "piyush@royalenfield.com", + ZBH: args.zbhEmail || "manav@royalenfield.com", + DD_LEAD: args.ddLeadEmail || "jaya@royalenfield.com", + DD_HEAD: args.ddHeadEmail || "ganesh@royalenfield.com", + NBH: args.nbhEmail || "yashwin@royalenfield.com", LEGAL: args.legalEmail || "legal@royalenfield.com", }; @@ -103,6 +103,9 @@ async function resolveDealerOutlet(dealerToken) { async function run() { try { console.log("--- STARTING RELOCATION E2E FLOW ---"); + if (!EMAILS.DEALER) { + throw new Error("Missing --dealerEmail. This script requires an existing dealer user email."); + } const adminToken = await login(EMAILS.DD_ADMIN); const dealerToken = await login(EMAILS.DEALER); diff --git a/trigger-resignation.js b/trigger-resignation.js index d4625cd..e081118 100644 --- a/trigger-resignation.js +++ b/trigger-resignation.js @@ -10,13 +10,13 @@ const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true' const SHOULD_SKIP_FINAL_SETTLEMENT = String(args.skipSettlement || 'false') === 'true'; const EMAILS = { - DD_ADMIN: 'lince@gmail.com', - DEALER: 'dealer@royalenfield.com', - ASM: 'asm.sdelhi@royalenfield.com', - RBM: 'rbm.ncr@royalenfield.com', - ZBH: 'yashwin@gmail.com', - DD_LEAD: 'ddlead@royalenfield.com', - NBH: 'nbh@royalenfield.com', + DD_ADMIN: 'lince@royalenfield.com', + DEALER: args.dealerEmail, + ASM: 'abhishek@royalenfield.com', + RBM: 'manish@royalenfield.com', + ZBH: 'manav@royalenfield.com', + DD_LEAD: 'jaya@royalenfield.com', + NBH: 'yashwin@royalenfield.com', LEGAL: 'legal@royalenfield.com', FINANCE: 'finance@royalenfield.com', SALES: 'sales@royalenfield.com', @@ -52,9 +52,7 @@ async function apiRequest(endpoint, method = 'GET', body = null, token = null) { async function login(email) { if (!login.cache) login.cache = {}; if (login.cache[email]) return login.cache[email]; - const isInternal = email.endsWith('@royalenfield.com') || - email === 'lince@gmail.com' || - email === 'yashwin@gmail.com'; + const isInternal = email.endsWith('@royalenfield.com'); const password = isInternal ? 'Admin@123' : 'Dealer@123'; const data = await apiRequest('/auth/login', 'POST', { email, password }); diff --git a/trigger-termination.js b/trigger-termination.js index 35c081a..6afd4f3 100644 --- a/trigger-termination.js +++ b/trigger-termination.js @@ -9,15 +9,15 @@ const STEP_DELAY_MS = Number(args.delayMs || 500); const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true'; const EMAILS = { - DD_ADMIN: 'lince@gmail.com', - ASM: 'asm.sdelhi@royalenfield.com', - RBM: 'rbm.ncr@royalenfield.com', - ZBH: 'yashwin@gmail.com', - DD_LEAD: 'ddlead@royalenfield.com', + DD_ADMIN: 'lince@royalenfield.com', + ASM: 'abhishek@royalenfield.com', + RBM: 'manish@royalenfield.com', + ZBH: 'manav@royalenfield.com', + DD_LEAD: 'jaya@royalenfield.com', LEGAL: 'legal@royalenfield.com', - NBH: 'nbh@royalenfield.com', - CCO: 'cco@royalenfield.com', - CEO: 'ceo@royalenfield.com', + NBH: 'yashwin@royalenfield.com', + CCO: 'admin@royalenfield.com', + CEO: 'admin@royalenfield.com', SALES: 'sales@royalenfield.com', SERVICE: 'service@royalenfield.com', SPARES: 'spares@royalenfield.com', diff --git a/trigger-workflow.js b/trigger-workflow.js index c0e1da2..528e241 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -20,16 +20,16 @@ const PROSPECT_EMAIL = `ramesh_${timestamp}@gmail.com`; const EMAILS = { PROSPECT: PROSPECT_EMAIL, - RBM_L1: 'manish@gmail.com', - ZM_L1: 'piyush@gmail.com', - DD_LEAD: 'jaya@gmail.com', - ZBH: 'manav@gmail.com', - NBH: 'yashwin@gmail.com', - DD_HEAD: 'ganesh@gmail.com', - FDD: 'fdd@gmail.com', - FINANCE: 'finance@gmail.com', - DD_ADMIN: 'aman@gmail.com', - ASM: 'abhishek@gmail.com', + RBM_L1: 'manish@royalenfield.com', + ZM_L1: 'piyush@royalenfield.com', + DD_LEAD: 'jaya@royalenfield.com', + ZBH: 'manav@royalenfield.com', + NBH: 'yashwin@royalenfield.com', + DD_HEAD: 'ganesh@royalenfield.com', + FDD: 'fdd@royalenfield.com', + FINANCE: 'finance@royalenfield.com', + DD_ADMIN: 'lince@royalenfield.com', + ASM: 'abhishek@royalenfield.com', SALES: 'sales@royalenfield.com', SERVICE: 'service@royalenfield.com', SPARES: 'spares@royalenfield.com',