new table changes done for the vrersioning for the department clearence and conflict is concluded between department and finacne due entry before refactoring application detail sceen
This commit is contained in:
parent
7fbf134cf1
commit
778a3a7452
123
docs/FNF_Department_Finance_No_Conflict_Flow.md
Normal file
123
docs/FNF_Department_Finance_No_Conflict_Flow.md
Normal file
@ -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.
|
||||||
40
docs/Offboarding_Requirement_Tracking_Matrix.md
Normal file
40
docs/Offboarding_Requirement_Tracking_Matrix.md
Normal file
@ -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.
|
||||||
132
docs/Offboarding_Requirements_Implementation_Gap_Report.md
Normal file
132
docs/Offboarding_Requirements_Implementation_Gap_Report.md
Normal file
@ -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
|
||||||
@ -869,10 +869,17 @@ o ⚪ Pending – Awaiting department input
|
|||||||
o **Payables to Dealer** (e.g., refundable deposits, reimbursements)
|
o **Payables to Dealer** (e.g., refundable deposits, reimbursements)
|
||||||
o **Receivables from Dealer** (e.g., outstanding invoices, recoveries)
|
o **Receivables from Dealer** (e.g., outstanding invoices, recoveries)
|
||||||
o **Deductions** (policy penalties, non-compliance adjustments)
|
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:
|
- The system automatically calculates:
|
||||||
- Net Settlement = Total Payables – Total Receivables – Total Deductions
|
- Net Settlement = Total Payables – Total Receivables – Total Deductions
|
||||||
- Finance reviews and adjusts entries as needed, attaching relevant proofs for
|
- Final totals are computed from **finance-validated values only**.
|
||||||
transparency.
|
|
||||||
- Status updates to _Finance Summary Prepared_ once complete.
|
- 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
|
status, whether the department owes a payment to the dealer ( _Payable_ ) or the dealer owes the
|
||||||
department ( _Recovery_ ).
|
department ( _Recovery_ ).
|
||||||
This enables complete financial visibility before the final settlement summary is prepared.
|
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**
|
**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.
|
Finance, Warranty, Marketing, HR, IT, Legal, Logistics, and Quality.
|
||||||
Each department inputs its clearance data — marking whether any dues exist — and provides
|
Each department inputs its clearance data — marking whether any dues exist — and provides
|
||||||
supporting remarks or payable/recovery amounts.
|
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**
|
**10.2.3 Depth**
|
||||||
|
|
||||||
@ -6180,9 +6190,13 @@ o ⚪ Pending – Awaiting departmental response or review.
|
|||||||
```
|
```
|
||||||
- **Amount Details:**
|
- **Amount Details:**
|
||||||
When dues are identified, the department specifies the **Amount Type** (Payable or
|
When dues are identified, the department specifies the **Amount Type** (Payable or
|
||||||
Recovery) and corresponding **Value** , which directly contributes to the Finance team’s
|
Recovery) and corresponding **Claim Value**.
|
||||||
final calculation matrix.
|
This value is treated as a department claim and is not directly used as the final settlement
|
||||||
- They will login with there respective account and fill the details.
|
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:**
|
- **Remarks Section:**
|
||||||
Every response includes contextual remarks for clarity, such as “Outstanding amount
|
Every response includes contextual remarks for clarity, such as “Outstanding amount
|
||||||
identified” or “Cleared,” ensuring traceable communication between departments and
|
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
|
- Equipment and fixture reimbursements
|
||||||
- Outstanding credit notes
|
- Outstanding credit notes
|
||||||
|
|
||||||
Finance users can add new line items with department tags and descriptions.
|
For department-originated items, Finance validates each submitted claim into a finance-
|
||||||
Each editable record auto-calculates into the total payables panel.
|
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)
|
11.2.3.5 Receivables from Dealer (Editable)
|
||||||
@ -6496,7 +6513,10 @@ Captures outstanding recoverables and pending dues, including:
|
|||||||
- Marketing recoveries
|
- Marketing recoveries
|
||||||
- HR or Finance advances
|
- HR or Finance advances
|
||||||
- Compliance or penalty adjustments
|
- 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)
|
11.2.3.6 Deductions (Editable)
|
||||||
@ -6507,7 +6527,8 @@ Represents contingent deductions such as:
|
|||||||
- Policy violations
|
- Policy violations
|
||||||
- Miscellaneous settlements
|
- 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
|
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
|
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
|
A positive balance indicates _Payable to Dealer_ ; a negative balance indicates _Recovery from
|
||||||
Dealer_.
|
Dealer_.
|
||||||
|
|
||||||
|
|||||||
16
reset_db.ts
16
reset_db.ts
@ -48,10 +48,18 @@ async function resetAndSeed() {
|
|||||||
|
|
||||||
// 3. Users
|
// 3. Users
|
||||||
const users = [
|
const users = [
|
||||||
{ email: 'admin@royalenfield.com', fullName: 'Laxman H', roleCode: 'Super Admin', password: hashedPassword, status: 'active' },
|
{ email: 'admin@royalenfield.com', fullName: 'Super Admin', 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: 'piyush@royalenfield.com', fullName: 'piyush', roleCode: 'DD-ZM', 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: 'manish@royalenfield.com', fullName: 'manish', roleCode: 'RBM', password: hashedPassword, status: 'active' },
|
||||||
{ email: 'lince@gmail.com', fullName: 'Lince', roleCode: 'DD Admin', 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) {
|
for (const u of users) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import db from '../src/database/models/index.js';
|
import db from '../src/database/models/index.js';
|
||||||
import bcrypt from 'bcryptjs';
|
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';
|
import { resolveManagerCode } from '../src/services/userRoleCode.service.js';
|
||||||
|
|
||||||
const { Role, User, UserRole, Zone, State, Region, Location } = db;
|
const { Role, User, UserRole, Zone, State, Region, Location } = db;
|
||||||
@ -57,21 +57,17 @@ async function masterReset() {
|
|||||||
// 4. Seed Essential Users
|
// 4. Seed Essential Users
|
||||||
const users = [
|
const users = [
|
||||||
{ email: 'admin@royalenfield.com', fullName: 'Super Admin', roleCode: ROLES.SUPER_ADMIN },
|
{ 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: 'piyush@royalenfield.com', fullName: 'piyush', roleCode: ROLES.DD_ZM },
|
||||||
{ email: 'nbh@royalenfield.com', fullName: 'NBH Head', roleCode: ROLES.NBH },
|
{ email: 'manish@royalenfield.com', fullName: 'manish', roleCode: ROLES.RBM },
|
||||||
{ email: 'cco@royalenfield.com', fullName: 'Ashok Singh (CCO)', roleCode: ROLES.CCO },
|
{ email: 'manav@royalenfield.com', fullName: 'manav', roleCode: ROLES.ZBH },
|
||||||
{ email: 'ceo@royalenfield.com', fullName: 'Siddhartha Lal (CEO)', roleCode: ROLES.CEO },
|
{ email: 'jaya@royalenfield.com', fullName: 'Jaya', roleCode: ROLES.DD_LEAD },
|
||||||
{ email: 'spares@royalenfield.com', fullName: 'Spares Clearance Mgr', roleCode: ROLES.SPARES_MANAGER },
|
{ email: 'ganesh@royalenfield.com', fullName: 'ganesh', roleCode: ROLES.DD_HEAD },
|
||||||
{ email: 'service@royalenfield.com', fullName: 'Service Clearance Mgr', roleCode: ROLES.SERVICE_MANAGER },
|
{ email: 'yashwin@royalenfield.com', fullName: 'Yashwin', roleCode: ROLES.NBH },
|
||||||
{ email: 'accounts@royalenfield.com', fullName: 'Accounts Clearance Mgr', roleCode: ROLES.ACCOUNTS_MANAGER },
|
{ email: 'fdd@royalenfield.com', fullName: 'FDD Team', roleCode: ROLES.FDD },
|
||||||
{ email: 'finance@royalenfield.com', fullName: 'Rahul Verma (Finance)', roleCode: ROLES.FINANCE },
|
{ 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: '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) {
|
for (const u of users) {
|
||||||
@ -101,57 +97,7 @@ async function masterReset() {
|
|||||||
}
|
}
|
||||||
console.log('✅ Standard Users seeded.');
|
console.log('✅ Standard Users seeded.');
|
||||||
|
|
||||||
// 5. Seed a Dealer record for testing
|
console.log('ℹ️ Dealer user/profile not auto-seeded in stable reset (as requested).');
|
||||||
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('--- SYSTEM READY FOR OFFBOARDING TESTING ---');
|
console.log('--- SYSTEM READY FOR OFFBOARDING TESTING ---');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@ -45,6 +45,13 @@ const policies = [
|
|||||||
approvalMode: 'ROLE_MANDATORY',
|
approvalMode: 'ROLE_MANDATORY',
|
||||||
requiredRoles: ['DD Admin', 'Super Admin'],
|
requiredRoles: ['DD Admin', 'Super Admin'],
|
||||||
isActive: true
|
isActive: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stageCode: 'CONSTITUTIONAL_ZM_RBM_REVIEW',
|
||||||
|
minApprovals: 2,
|
||||||
|
approvalMode: 'ROLE_MANDATORY',
|
||||||
|
requiredRoles: ['DD-ZM', 'RBM'],
|
||||||
|
isActive: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -13,25 +13,18 @@ async function seedUsers() {
|
|||||||
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
const hashedPassword = await bcrypt.hash('Admin@123', 10);
|
||||||
|
|
||||||
const usersToSeed = [
|
const usersToSeed = [
|
||||||
{ email: 'rbm.ncr@royalenfield.com', fullName: 'Sanjay Dutt', password: hashedPassword, roleCode: ROLES.RBM, status: 'active' },
|
{ email: 'admin@royalenfield.com', fullName: 'Super Admin', password: hashedPassword, roleCode: ROLES.SUPER_ADMIN, status: 'active' },
|
||||||
{ email: 'zm.ncr@royalenfield.com', fullName: 'Rajesh Khanna', password: hashedPassword, roleCode: ROLES.DD_ZM, status: 'active' },
|
{ email: 'piyush@royalenfield.com', fullName: 'piyush', password: hashedPassword, roleCode: ROLES.DD_ZM, status: 'active' },
|
||||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', password: hashedPassword, roleCode: ROLES.LEGAL_ADMIN, status: 'active' },
|
{ email: 'manish@royalenfield.com', fullName: 'manish', password: hashedPassword, roleCode: ROLES.RBM, status: 'active' },
|
||||||
{ email: 'ddhead@royalenfield.com', fullName: 'Vikram Singh', password: hashedPassword, roleCode: ROLES.DD_HEAD, status: 'active' },
|
{ email: 'manav@royalenfield.com', fullName: 'manav', password: hashedPassword, roleCode: ROLES.ZBH, status: 'active' },
|
||||||
{ email: 'nbh@royalenfield.com', fullName: 'Alwyn John', password: hashedPassword, roleCode: ROLES.NBH, status: 'active' },
|
{ email: 'jaya@royalenfield.com', fullName: 'Jaya', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
|
||||||
{ email: 'ddlead@royalenfield.com', fullName: 'Meera Iyer', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
|
{ email: 'ganesh@royalenfield.com', fullName: 'ganesh', password: hashedPassword, roleCode: ROLES.DD_HEAD, status: 'active' },
|
||||||
{ email: 'finance@royalenfield.com', fullName: 'Rahul Verma', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' },
|
{ email: 'yashwin@royalenfield.com', fullName: 'Yashwin', password: hashedPassword, roleCode: ROLES.NBH, status: 'active' },
|
||||||
{ email: 'dealer@royalenfield.com', fullName: 'Amit Sharma', password: hashedPassword, roleCode: ROLES.DEALER, status: 'active', isExternal: true },
|
{ email: 'fdd@royalenfield.com', fullName: 'FDD Team', password: hashedPassword, roleCode: ROLES.FDD, status: 'active' },
|
||||||
{ email: 'admin@royalenfield.com', fullName: 'Laxman H', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
|
{ email: 'finance@royalenfield.com', fullName: 'Finance Admin', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' },
|
||||||
{ email: 'yashwin@gmail.com', fullName: 'Yashwin', password: hashedPassword, roleCode: ROLES.ZBH, status: 'active' },
|
{ email: 'abhishek@royalenfield.com', fullName: 'abhishek', password: hashedPassword, roleCode: ROLES.ASM, status: 'active' },
|
||||||
{ email: 'kenil@gmail.com', fullName: 'Kenil', password: hashedPassword, roleCode: ROLES.DD_LEAD, status: 'active' },
|
{ email: 'lince@royalenfield.com', fullName: 'Lince', password: hashedPassword, roleCode: ROLES.DD_ADMIN, status: 'active' },
|
||||||
{ email: 'lince@gmail.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' }
|
||||||
{ 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' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const u of usersToSeed) {
|
for (const u of usersToSeed) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import 'dotenv/config';
|
|||||||
import db from '../src/database/models/index.js';
|
import db from '../src/database/models/index.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { syncLocationManagers, syncRegionManager, syncZoneManager } from '../src/modules/master/syncHierarchy.service.js';
|
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';
|
import { resolveManagerCode } from '../src/services/userRoleCode.service.js';
|
||||||
|
|
||||||
const { Role, Zone, Region, State, District, User, UserRole } = db;
|
const { Role, Zone, Region, State, District, User, UserRole } = db;
|
||||||
@ -116,16 +115,13 @@ async function seed() {
|
|||||||
// 3. Create Key Management Users
|
// 3. Create Key Management Users
|
||||||
// National / Administrative
|
// National / Administrative
|
||||||
const nationalUsers = [
|
const nationalUsers = [
|
||||||
{ email: 'nbh@royalenfield.com', name: 'Alwyn John', role: 'NBH' },
|
{ email: 'yashwin@royalenfield.com', name: 'Yashwin', role: 'NBH' },
|
||||||
{ email: 'ddhead@royalenfield.com', name: 'Vikram Singh', role: 'DD Head' },
|
{ email: 'ganesh@royalenfield.com', name: 'ganesh', role: 'DD Head' },
|
||||||
{ email: 'finance@royalenfield.com', name: 'Rahul Verma', role: 'Finance' },
|
{ email: 'finance@royalenfield.com', name: 'Finance Admin', role: 'Finance' },
|
||||||
{ email: 'admin@royalenfield.com', name: 'Laxman H', role: 'Super Admin' },
|
{ email: 'admin@royalenfield.com', name: 'Super Admin', role: 'Super Admin' },
|
||||||
{ email: 'lince@gmail.com', name: 'Lince', role: 'DD Admin' },
|
{ email: 'lince@royalenfield.com', name: 'Lince', role: 'DD Admin' },
|
||||||
{ email: 'fdd@royalenfield.com', name: 'FDD Partner', role: 'FDD' },
|
{ email: 'fdd@royalenfield.com', name: 'FDD Team', role: 'FDD' },
|
||||||
{ email: 'architecture@royalenfield.com', name: 'RE Architect', role: 'ARCHITECTURE' },
|
|
||||||
{ email: 'legal@royalenfield.com', name: 'Legal Admin', role: 'Legal Admin' },
|
{ 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) {
|
for (const u of nationalUsers) {
|
||||||
const [user] = await User.findOrCreate({
|
const [user] = await User.findOrCreate({
|
||||||
@ -137,10 +133,11 @@ async function seed() {
|
|||||||
|
|
||||||
// Frontend Mock Users for Quick Login (Ensuring exact matches)
|
// Frontend Mock Users for Quick Login (Ensuring exact matches)
|
||||||
const frontendMocks = [
|
const frontendMocks = [
|
||||||
{ email: 'ddlead@royalenfield.com', name: 'Meera Iyer', role: 'DD Lead', zone: 'North Zone' },
|
{ email: 'jaya@royalenfield.com', name: 'Jaya', role: 'DD Lead', zone: 'North Zone' },
|
||||||
{ email: 'yashwin@gmail.com', name: 'Yashwin', role: 'ZBH', zone: 'North Zone' },
|
{ email: 'manav@royalenfield.com', name: 'manav', role: 'ZBH', zone: 'North Zone' },
|
||||||
{ email: 'kenil@gmail.com', name: 'Kenil', role: 'DD Lead', zone: 'North Zone' },
|
{ email: 'piyush@royalenfield.com', name: 'piyush', role: 'DD-ZM', zone: 'North Zone' },
|
||||||
{ email: 'dealer@royalenfield.com', name: 'Amit Sharma', role: 'Dealer', district: 'South Delhi' }
|
{ 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) {
|
for (const m of frontendMocks) {
|
||||||
const assignment: any = {};
|
const assignment: any = {};
|
||||||
@ -161,9 +158,7 @@ async function seed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Zonal Business Heads (Additional)
|
// Zonal Business Heads (Additional)
|
||||||
const zbhUsers = [
|
const zbhUsers: any[] = [];
|
||||||
{ email: 'zbh.south@royalenfield.com', name: 'Srinivasan K', zone: 'South Zone' }
|
|
||||||
];
|
|
||||||
for (const u of zbhUsers) {
|
for (const u of zbhUsers) {
|
||||||
const zone = zoneMap[u.zone];
|
const zone = zoneMap[u.zone];
|
||||||
const [user] = await User.findOrCreate({
|
const [user] = await User.findOrCreate({
|
||||||
@ -175,9 +170,7 @@ async function seed() {
|
|||||||
|
|
||||||
// Regional Managers (RBMs)
|
// Regional Managers (RBMs)
|
||||||
const rbmUsers = [
|
const rbmUsers = [
|
||||||
{ email: 'rbm.ncr@royalenfield.com', name: 'Sanjay Dutt', region: 'NCR Region' },
|
{ email: 'manish@royalenfield.com', name: 'manish', 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' }
|
|
||||||
];
|
];
|
||||||
for (const u of rbmUsers) {
|
for (const u of rbmUsers) {
|
||||||
const region = regionMap[u.region];
|
const region = regionMap[u.region];
|
||||||
@ -190,8 +183,7 @@ async function seed() {
|
|||||||
|
|
||||||
// Zonal Managers (DD-ZM) - Assigned to Regions
|
// Zonal Managers (DD-ZM) - Assigned to Regions
|
||||||
const zmUsers = [
|
const zmUsers = [
|
||||||
{ email: 'zm.ncr@royalenfield.com', name: 'Rajesh Khanna', region: 'NCR Region' },
|
{ email: 'piyush@royalenfield.com', name: 'piyush', region: 'NCR Region' }
|
||||||
{ email: 'zm.south@royalenfield.com', name: 'Kartik Subbaraj', region: 'Karnataka Region' }
|
|
||||||
];
|
];
|
||||||
for (const u of zmUsers) {
|
for (const u of zmUsers) {
|
||||||
const region = regionMap[u.region];
|
const region = regionMap[u.region];
|
||||||
@ -204,9 +196,7 @@ async function seed() {
|
|||||||
|
|
||||||
// ASMs (Assigned to Districts)
|
// ASMs (Assigned to Districts)
|
||||||
const asmUsers = [
|
const asmUsers = [
|
||||||
{ email: 'asm.sdelhi@royalenfield.com', name: 'Arun Jaitley', district: 'South Delhi' },
|
{ email: 'abhishek@royalenfield.com', name: 'abhishek', district: 'South Delhi' }
|
||||||
{ email: 'asm.noida@royalenfield.com', name: 'Kishan Reddy', district: 'NOIDA' },
|
|
||||||
{ email: 'asm.bangalore@royalenfield.com', name: 'Vishnu Dev', district: 'Bangalore Urban' }
|
|
||||||
];
|
];
|
||||||
for (const u of asmUsers) {
|
for (const u of asmUsers) {
|
||||||
const district = await District.findOne({ where: { name: u.district } });
|
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('ℹ️ Dealer test profile seeding skipped (using internal users-only seed set).');
|
||||||
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('--- Triggering Hierarchy Synchronization ---');
|
console.log('--- Triggering Hierarchy Synchronization ---');
|
||||||
// ... (rest same)
|
// ... (rest same)
|
||||||
|
|||||||
@ -8,6 +8,15 @@ export interface FnFLineItemAttributes {
|
|||||||
department: string;
|
department: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
addedBy: string | null;
|
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>, FnFLineItemAttributes { }
|
export interface FnFLineItemInstance extends Model<FnFLineItemAttributes>, FnFLineItemAttributes { }
|
||||||
@ -51,6 +60,46 @@ export default (sequelize: Sequelize) => {
|
|||||||
model: 'users',
|
model: 'users',
|
||||||
key: 'id'
|
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',
|
tableName: 'fnf_line_items',
|
||||||
@ -58,7 +107,9 @@ export default (sequelize: Sequelize) => {
|
|||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ['fnfId'] },
|
{ fields: ['fnfId'] },
|
||||||
{ fields: ['itemType'] },
|
{ fields: ['itemType'] },
|
||||||
{ fields: ['department'] }
|
{ fields: ['department'] },
|
||||||
|
{ fields: ['isActive'] },
|
||||||
|
{ fields: ['sourceType'] }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,16 +13,27 @@ import { WorkflowService } from '../../services/WorkflowService.js';
|
|||||||
export const getDealers = async (req: Request, res: Response) => {
|
export const getDealers = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
|
const query = (req.query || {}) as Record<string, any>;
|
||||||
if (String((req.query as any)?.onboarded || '') === 'true') {
|
if (String((req.query as any)?.onboarded || '') === 'true') {
|
||||||
where.onboardedAt = { [Op.ne]: null };
|
where.onboardedAt = { [Op.ne]: null };
|
||||||
}
|
}
|
||||||
|
if (String(query.activeOnly || '') === 'true') {
|
||||||
|
where.status = { [Op.iLike]: 'active' };
|
||||||
|
}
|
||||||
|
|
||||||
const dealers = await Dealer.findAll({
|
const dealers = await Dealer.findAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{ model: DealerCode, as: 'dealerCode' },
|
{ model: DealerCode, as: 'dealerCode' },
|
||||||
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
|
{ 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']]
|
order: [['createdAt', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import db from '../../database/models/index.js';
|
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 { Op } from 'sequelize';
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import {
|
import {
|
||||||
@ -361,6 +361,16 @@ const STAGE_FLOW_BACK: Record<string, string | undefined> = {
|
|||||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL
|
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONSTITUTIONAL_STAGE_POLICY_CODES: Record<string, string> = {
|
||||||
|
[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 actionSuccessMessage = (raw: string): string => {
|
||||||
const a = String(raw || '').trim().toLowerCase();
|
const a = String(raw || '').trim().toLowerCase();
|
||||||
if (a === 'reject') return 'Request rejected successfully';
|
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.' });
|
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];
|
const nextStage = STAGE_FLOW_FORWARD[request.currentStage];
|
||||||
if (!nextStage) {
|
if (!nextStage) {
|
||||||
return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' });
|
return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' });
|
||||||
|
|||||||
@ -728,14 +728,16 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentClearances = resignation.departmentalClearances || {};
|
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 documentUrl = req.file ? `/uploads/documents/${req.file.filename}` : (currentClearances[department]?.supportingDocument || null);
|
||||||
|
|
||||||
const clearances = {
|
const clearances = {
|
||||||
...currentClearances,
|
...currentClearances,
|
||||||
[department]: {
|
[department]: {
|
||||||
status: status || 'Pending',
|
status: normalizedDeptStatus,
|
||||||
remarks,
|
remarks,
|
||||||
amount: amount || 0,
|
amount: normalizedAmount,
|
||||||
type: type || 'Recovery',
|
type: type || 'Recovery',
|
||||||
supportingDocument: documentUrl,
|
supportingDocument: documentUrl,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@ -749,7 +751,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
stage: resignation.currentStage,
|
stage: resignation.currentStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
action: `Updated clearance for ${department}: ${status}`,
|
action: `Updated clearance for ${department}: ${normalizedDeptStatus}`,
|
||||||
remarks
|
remarks
|
||||||
}]
|
}]
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
@ -760,13 +762,13 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
action: 'CLEARANCE_UPDATED',
|
action: 'CLEARANCE_UPDATED',
|
||||||
remarks: remarks || `Cleared ${department}`,
|
remarks: remarks || `Cleared ${department}`,
|
||||||
details: { department, status, amount }
|
details: { department, status: normalizedDeptStatus, amount: normalizedAmount }
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
// Sync with F&F Clearance if settlement exists
|
// Sync with F&F Clearance if settlement exists
|
||||||
const fnf = await db.FnF.findOne({ where: { resignationId: resignation.id } });
|
const fnf = await db.FnF.findOne({ where: { resignationId: resignation.id } });
|
||||||
if (fnf) {
|
if (fnf) {
|
||||||
const numAmount = parseFloat(amount) || 0;
|
const numAmount = normalizedAmount;
|
||||||
const fnfStatus = normalizeClearanceStatus(status, numAmount);
|
const fnfStatus = normalizeClearanceStatus(status, numAmount);
|
||||||
|
|
||||||
const existingClearance = await db.FffClearance.findOne({
|
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' }
|
details: { department, status: fnfStatus, source: 'Resignation Workflow' }
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
// If there's an amount, create/update line item
|
// Write department claim in versioned, active-only model.
|
||||||
if (amount > 0) {
|
const enteredAmount = Math.abs(parseFloat(amount) || 0);
|
||||||
const existingItem = await db.FnFLineItem.findOne({
|
const existingClaim = await db.FnFLineItem.findOne({
|
||||||
where: {
|
where: {
|
||||||
fnfId: fnf.id,
|
fnfId: fnf.id,
|
||||||
department,
|
department,
|
||||||
description: { [Op.like]: `${department} Clearance:%` }
|
sourceType: 'DepartmentClaim',
|
||||||
},
|
isActive: true
|
||||||
transaction
|
},
|
||||||
});
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
const lineItemData = {
|
if (enteredAmount > 0) {
|
||||||
|
if (existingClaim) {
|
||||||
|
await existingClaim.update({ isActive: false }, { transaction });
|
||||||
|
}
|
||||||
|
await db.FnFLineItem.create({
|
||||||
fnfId: fnf.id,
|
fnfId: fnf.id,
|
||||||
itemType: type || 'Receivable',
|
itemType: type || 'Receivable',
|
||||||
description: `${department} Clearance: ${remarks || 'Outstanding Dues'}`,
|
description: '[DEPARTMENT_CLAIM] Department Clearance - Manual Update',
|
||||||
department,
|
department,
|
||||||
amount: Math.abs(parseFloat(amount)), // Always positive magnitude
|
amount: enteredAmount,
|
||||||
addedBy: req.user.id
|
addedBy: req.user.id,
|
||||||
};
|
sourceType: 'DepartmentClaim',
|
||||||
|
version: Number(existingClaim?.version || 1) + (existingClaim ? 1 : 0),
|
||||||
if (existingItem) {
|
isActive: true,
|
||||||
await existingItem.update(lineItemData, { transaction });
|
parentLineItemId: existingClaim?.parentLineItemId || existingClaim?.id || null,
|
||||||
} else {
|
claimAmount: enteredAmount,
|
||||||
await db.FnFLineItem.create(lineItemData, { transaction });
|
validatedAmount: null,
|
||||||
}
|
varianceAmount: 0,
|
||||||
|
financeDecision: null,
|
||||||
|
varianceReason: null
|
||||||
|
}, { transaction });
|
||||||
|
} else if (existingClaim) {
|
||||||
|
await existingClaim.update({ isActive: false }, { transaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate totals using the standardized formula
|
// Recalculate totals from active lines only.
|
||||||
const items = await db.FnFLineItem.findAll({ where: { fnfId: fnf.id }, transaction });
|
// 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 totalPayables = 0;
|
||||||
let totalReceivables = 0;
|
let totalReceivables = 0;
|
||||||
let totalDeductions = 0;
|
let totalDeductions = 0;
|
||||||
|
|
||||||
items.forEach((item: any) => {
|
calculationItems.forEach((item: any) => {
|
||||||
const val = Math.abs(parseFloat(item.amount) || 0);
|
const val = Math.abs(parseFloat(item.amount) || 0);
|
||||||
if (item.itemType === 'Payable') totalPayables += val;
|
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;
|
else if (item.itemType === 'Deduction') totalDeductions += val;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,74 @@ import { Request, Response } from 'express';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog } = db;
|
const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog } = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
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 { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js';
|
||||||
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
||||||
import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
import { normalizeFnFStatus, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js';
|
||||||
import { safeAuditLogCreate } from '../../services/applicationAuditLog.service.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) => {
|
export const getDepartments = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
res.json({ success: true, departments: FNF_DEPARTMENTS });
|
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) => {
|
export const getFnFById = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const fnf = await FnF.findByPk(id, {
|
const includeConfig = [
|
||||||
include: [
|
{ model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] },
|
||||||
{ model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] },
|
{ model: TerminationRequest, as: 'terminationRequest' },
|
||||||
{ model: TerminationRequest, as: 'terminationRequest' },
|
{
|
||||||
{
|
model: Outlet,
|
||||||
model: Outlet,
|
as: 'outlet',
|
||||||
as: 'outlet',
|
include: [{
|
||||||
include: [{
|
model: User,
|
||||||
model: User,
|
|
||||||
as: 'dealer',
|
|
||||||
include: [{
|
|
||||||
model: db.Dealer,
|
|
||||||
as: 'dealerProfile',
|
|
||||||
include: [
|
|
||||||
{ model: db.DealerCode, as: 'dealerCode' },
|
|
||||||
{ model: db.DealerBankDetail, as: 'bankDetails' }
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: db.Dealer,
|
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
include: [
|
include: [{
|
||||||
{ model: db.DealerCode, as: 'dealerCode' },
|
model: db.Dealer,
|
||||||
{ model: db.DealerBankDetail, as: 'bankDetails' }
|
as: 'dealerProfile',
|
||||||
]
|
include: [
|
||||||
},
|
{ model: db.DealerCode, as: 'dealerCode' },
|
||||||
{ model: FnFLineItem, as: 'lineItems' },
|
{ model: db.DealerBankDetail, as: 'bankDetails' }
|
||||||
{ model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] }
|
]
|
||||||
]
|
}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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' });
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error fetching F&F' });
|
res.status(500).json({ success: false, message: 'Error fetching F&F' });
|
||||||
}
|
}
|
||||||
@ -217,8 +290,26 @@ export const addLineItem = async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { itemType, description, department, amount } = req.body;
|
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({
|
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
|
// 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 { description, department, amount } = req.body;
|
||||||
const lineItem = await FnFLineItem.findByPk(itemId);
|
const lineItem = await FnFLineItem.findByPk(itemId);
|
||||||
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
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
|
const fnf = await FnF.findByPk(lineItem.fnfId);
|
||||||
// Update FnF progress and department statuses
|
if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' });
|
||||||
await calculateFnFLogic(lineItem.fnfId, req.user?.id);
|
|
||||||
|
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({
|
await AuditLog.create({
|
||||||
userId: req.user?.id || null,
|
userId: req.user?.id || null,
|
||||||
action: AUDIT_ACTIONS.FNF_UPDATED,
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
entityType: 'fnf',
|
entityType: 'fnf',
|
||||||
entityId: lineItem.fnfId,
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error updating line item' });
|
res.status(500).json({ success: false, message: 'Error updating line item' });
|
||||||
}
|
}
|
||||||
@ -270,8 +413,15 @@ export const deleteLineItem = async (req: AuthRequest, res: Response) => {
|
|||||||
const lineItem = await FnFLineItem.findByPk(itemId);
|
const lineItem = await FnFLineItem.findByPk(itemId);
|
||||||
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
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;
|
const fnfId = lineItem.fnfId;
|
||||||
await lineItem.destroy();
|
await lineItem.update({ isActive: false });
|
||||||
|
|
||||||
// Update FnF progress and department statuses
|
// Update FnF progress and department statuses
|
||||||
// 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,
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
entityType: 'fnf',
|
entityType: 'fnf',
|
||||||
entityId: fnfId,
|
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' });
|
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;
|
if (!fnf) return null;
|
||||||
|
|
||||||
const lineItems = (fnf as any).lineItems || [];
|
await ensureFinanceDraftsFromDepartmentClaims(id, userId);
|
||||||
const clearances = (fnf as any).clearances || [];
|
|
||||||
|
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 totalReceivables = 0;
|
||||||
let totalPayables = 0;
|
let totalPayables = 0;
|
||||||
let totalDeductions = 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);
|
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 === 'Payable') totalPayables += amt;
|
||||||
else if (item.itemType === 'Deduction') totalDeductions += 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)
|
// Only update if it's already been processed (not Pending)
|
||||||
if (clearance.status === 'Pending') continue;
|
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 totalDeptAmount = deptDues.reduce((sum: number, li: any) => sum + (parseFloat(li.amount) || 0), 0);
|
||||||
|
|
||||||
const targetStatus = totalDeptAmount > 0 ? 'Dues Pending' : 'NOC Submitted';
|
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
|
// Determine Overall F&F Status
|
||||||
let newStatus = normalizeFnFStatus(fnf.status);
|
let newStatus = normalizeFnFStatus(refreshedFnf.status);
|
||||||
if (fnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) {
|
if (refreshedFnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) {
|
||||||
newStatus = FNF_STATUS.DD_CLEARANCE;
|
newStatus = FNF_STATUS.DD_CLEARANCE;
|
||||||
}
|
}
|
||||||
if (allCleared && fnf.status !== FNF_STATUS.COMPLETED) {
|
if (allCleared && refreshedFnf.status !== FNF_STATUS.COMPLETED) {
|
||||||
newStatus = FNF_STATUS.FINANCE_APPROVAL;
|
newStatus = FNF_STATUS.FINANCE_APPROVAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fnf.update({
|
await refreshedFnf.update({
|
||||||
totalReceivables, totalPayables, totalDeductions, netAmount,
|
totalReceivables, totalPayables, totalDeductions, netAmount,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
progressPercentage
|
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 status moved to Completed, also update parent resignation/termination
|
||||||
if (newStatus === FNF_STATUS.COMPLETED || newStatus === 'Completed') {
|
if (newStatus === FNF_STATUS.COMPLETED || newStatus === 'Completed') {
|
||||||
if (fnf.resignationId) {
|
if (refreshedFnf.resignationId) {
|
||||||
const resignation = await Resignation.findByPk(fnf.resignationId);
|
const resignation = await Resignation.findByPk(refreshedFnf.resignationId);
|
||||||
if (resignation) {
|
if (resignation) {
|
||||||
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.COMPLETED, userId, {
|
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.COMPLETED, userId, {
|
||||||
action: 'F&F Settlement Completed',
|
action: 'F&F Settlement Completed',
|
||||||
@ -366,8 +528,8 @@ const calculateFnFLogic = async (id: string, userId: string | null = null) => {
|
|||||||
status: 'Completed'
|
status: 'Completed'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (fnf.terminationRequestId) {
|
} else if (refreshedFnf.terminationRequestId) {
|
||||||
const terminationRequest = await TerminationRequest.findByPk(fnf.terminationRequestId);
|
const terminationRequest = await TerminationRequest.findByPk(refreshedFnf.terminationRequestId);
|
||||||
if (terminationRequest) {
|
if (terminationRequest) {
|
||||||
await TerminationWorkflowService.transitionTermination(terminationRequest, TERMINATION_STAGES.TERMINATED, userId, {
|
await TerminationWorkflowService.transitionTermination(terminationRequest, TERMINATION_STAGES.TERMINATED, userId, {
|
||||||
action: 'F&F Settlement Completed',
|
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) => {
|
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 { status, remarks, documentId, supportingDocument, amount, type } = body;
|
||||||
const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } });
|
const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } });
|
||||||
if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' });
|
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 uploadedSupportingDocument = req.file ? `/uploads/documents/${req.file.filename}` : undefined;
|
||||||
const enteredAmount = Math.abs(Number(amount) || 0);
|
const enteredAmount = Math.abs(Number(amount) || 0);
|
||||||
@ -397,23 +568,38 @@ export const updateClearance = async (req: AuthRequest, res: Response) => {
|
|||||||
: clearanceType === 'deduction'
|
: clearanceType === 'deduction'
|
||||||
? 'Deduction'
|
? 'Deduction'
|
||||||
: 'Receivable';
|
: '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({
|
const existingSyntheticLine = await FnFLineItem.findOne({
|
||||||
where: {
|
where: {
|
||||||
fnfId: id,
|
fnfId: id,
|
||||||
department: clearance.department,
|
department: clearance.department,
|
||||||
description: syntheticDescription
|
description: syntheticDescription,
|
||||||
|
isActive: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (enteredAmount > 0) {
|
if (enteredAmount > 0) {
|
||||||
if (existingSyntheticLine) {
|
if (existingSyntheticLine) {
|
||||||
await existingSyntheticLine.update({
|
await existingSyntheticLine.update({ isActive: false });
|
||||||
|
|
||||||
|
await FnFLineItem.create({
|
||||||
|
fnfId: id,
|
||||||
itemType,
|
itemType,
|
||||||
|
description: syntheticDescription,
|
||||||
|
department: clearance.department,
|
||||||
amount: enteredAmount,
|
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 {
|
} else {
|
||||||
await FnFLineItem.create({
|
await FnFLineItem.create({
|
||||||
@ -422,11 +608,19 @@ export const updateClearance = async (req: AuthRequest, res: Response) => {
|
|||||||
description: syntheticDescription,
|
description: syntheticDescription,
|
||||||
department: clearance.department,
|
department: clearance.department,
|
||||||
amount: enteredAmount,
|
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) {
|
} else if (existingSyntheticLine) {
|
||||||
await existingSyntheticLine.destroy();
|
await existingSyntheticLine.update({ isActive: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedStatus = normalizeClearanceStatus(status || clearance.status, enteredAmount);
|
const normalizedStatus = normalizeClearanceStatus(status || clearance.status, enteredAmount);
|
||||||
|
|||||||
@ -40,14 +40,15 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
|
|||||||
proposedLwd,
|
proposedLwd,
|
||||||
comments,
|
comments,
|
||||||
initiatedBy: req.user.id,
|
initiatedBy: req.user.id,
|
||||||
currentStage: TERMINATION_STAGES.SUBMITTED,
|
currentStage: TERMINATION_STAGES.RBM_REVIEW,
|
||||||
status: getTerminationStatusForStage(TERMINATION_STAGES.SUBMITTED),
|
status: getTerminationStatusForStage(TERMINATION_STAGES.RBM_REVIEW),
|
||||||
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.SUBMITTED),
|
progressPercentage: TerminationWorkflowService.calculateProgress(TERMINATION_STAGES.RBM_REVIEW),
|
||||||
timeline: [{
|
timeline: [{
|
||||||
stage: 'Submitted',
|
stage: 'Submitted',
|
||||||
|
targetStage: TERMINATION_STAGES.RBM_REVIEW,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
action: 'Termination request initiated',
|
action: `Termination request initiated and forwarded to ${TERMINATION_STAGES.RBM_REVIEW}`,
|
||||||
remarks: comments
|
remarks: comments
|
||||||
}]
|
}]
|
||||||
}, { transaction });
|
}, { 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'] },
|
{ 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 });
|
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();
|
await transaction.commit();
|
||||||
res.status(201).json({ success: true, message: 'Document uploaded successfully', document });
|
res.status(201).json({ success: true, message: 'Document uploaded successfully', document });
|
||||||
} catch (error) {
|
} 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
|
// Record Personal Hearing Outcome
|
||||||
export const recordPersonalHearing = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
export const recordPersonalHearing = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
const transaction: Transaction = await db.sequelize.transaction();
|
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<string, string> = {
|
||||||
|
[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)
|
// Record Clearance from Departments (16-Department F&F)
|
||||||
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
const transaction: Transaction = await db.sequelize.transaction();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import express from 'express';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import {
|
import {
|
||||||
createTermination, getTerminations, getTerminationById, updateTerminationStatus,
|
createTermination, getTerminations, getTerminationById, updateTerminationStatus,
|
||||||
submitScnResponse, recordPersonalHearing, updateClearance, uploadTerminationDocument
|
submitScnResponse, recordPersonalHearing, updateClearance, uploadTerminationDocument, issueScn, uploadScnResponse, finalizeTermination
|
||||||
} from './termination.controller.js';
|
} from './termination.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
import { uploadSingle } from '../../common/middleware/upload.js';
|
import { uploadSingle } from '../../common/middleware/upload.js';
|
||||||
@ -14,6 +14,9 @@ router.get('/', getTerminations);
|
|||||||
router.get('/:id', getTerminationById);
|
router.get('/:id', getTerminationById);
|
||||||
router.put('/:id/status', updateTerminationStatus);
|
router.put('/:id/status', updateTerminationStatus);
|
||||||
router.post('/: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('/scn-response', submitScnResponse);
|
||||||
router.post('/hearing-record', recordPersonalHearing);
|
router.post('/hearing-record', recordPersonalHearing);
|
||||||
router.put('/:id/clearance', updateClearance);
|
router.put('/:id/clearance', updateClearance);
|
||||||
|
|||||||
@ -8,14 +8,14 @@ const PASSWORD = 'Admin@123';
|
|||||||
const STEP_DELAY_MS = Number(args.delayMs || 500);
|
const STEP_DELAY_MS = Number(args.delayMs || 500);
|
||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
DD_ADMIN: 'lince@gmail.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
DEALER: 'dealer@royalenfield.com',
|
DEALER: args.dealerEmail,
|
||||||
ASM: 'asm.sdelhi@royalenfield.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
RBM_L1: 'rbm.ncr@royalenfield.com',
|
RBM_L1: 'manish@royalenfield.com',
|
||||||
ZBH: 'yashwin@gmail.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'ddlead@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
DD_HEAD: 'ddhead@royalenfield.com',
|
DD_HEAD: 'ganesh@royalenfield.com',
|
||||||
NBH: 'nbh@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
SALES: 'sales@royalenfield.com',
|
SALES: 'sales@royalenfield.com',
|
||||||
SERVICE: 'service@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() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
console.log('--- STARTING CONSTITUTIONAL CHANGE E2E FLOW ---');
|
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}...`);
|
console.log(`[STEP 0] Logging in as Dealer: ${EMAILS.DEALER}...`);
|
||||||
const dealerToken = await login(EMAILS.DEALER);
|
const dealerToken = await login(EMAILS.DEALER);
|
||||||
|
|||||||
@ -9,15 +9,15 @@ const PASSWORD = "Admin@123";
|
|||||||
const STEP_DELAY_MS = Number(args.delayMs || 500);
|
const STEP_DELAY_MS = Number(args.delayMs || 500);
|
||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
DD_ADMIN: args.ddAdminEmail || "lince@gmail.com",
|
DD_ADMIN: args.ddAdminEmail || "lince@royalenfield.com",
|
||||||
DEALER: args.dealerEmail || "dealer@royalenfield.com",
|
DEALER: args.dealerEmail,
|
||||||
ASM: args.asmEmail || "asm.sdelhi@royalenfield.com",
|
ASM: args.asmEmail || "abhishek@royalenfield.com",
|
||||||
RBM: args.rbmEmail || "rbm.ncr@royalenfield.com",
|
RBM: args.rbmEmail || "manish@royalenfield.com",
|
||||||
DD_ZM: args.ddZmEmail || "zm.ncr@royalenfield.com",
|
DD_ZM: args.ddZmEmail || "piyush@royalenfield.com",
|
||||||
ZBH: args.zbhEmail || "yashwin@gmail.com",
|
ZBH: args.zbhEmail || "manav@royalenfield.com",
|
||||||
DD_LEAD: args.ddLeadEmail || "ddlead@royalenfield.com",
|
DD_LEAD: args.ddLeadEmail || "jaya@royalenfield.com",
|
||||||
DD_HEAD: args.ddHeadEmail || "ddhead@royalenfield.com",
|
DD_HEAD: args.ddHeadEmail || "ganesh@royalenfield.com",
|
||||||
NBH: args.nbhEmail || "nbh@royalenfield.com",
|
NBH: args.nbhEmail || "yashwin@royalenfield.com",
|
||||||
LEGAL: args.legalEmail || "legal@royalenfield.com",
|
LEGAL: args.legalEmail || "legal@royalenfield.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -103,6 +103,9 @@ async function resolveDealerOutlet(dealerToken) {
|
|||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
console.log("--- STARTING RELOCATION E2E FLOW ---");
|
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 adminToken = await login(EMAILS.DD_ADMIN);
|
||||||
const dealerToken = await login(EMAILS.DEALER);
|
const dealerToken = await login(EMAILS.DEALER);
|
||||||
|
|
||||||
|
|||||||
@ -10,13 +10,13 @@ const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true'
|
|||||||
const SHOULD_SKIP_FINAL_SETTLEMENT = String(args.skipSettlement || 'false') === 'true';
|
const SHOULD_SKIP_FINAL_SETTLEMENT = String(args.skipSettlement || 'false') === 'true';
|
||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
DD_ADMIN: 'lince@gmail.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
DEALER: 'dealer@royalenfield.com',
|
DEALER: args.dealerEmail,
|
||||||
ASM: 'asm.sdelhi@royalenfield.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
RBM: 'rbm.ncr@royalenfield.com',
|
RBM: 'manish@royalenfield.com',
|
||||||
ZBH: 'yashwin@gmail.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'ddlead@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
NBH: 'nbh@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
FINANCE: 'finance@royalenfield.com',
|
FINANCE: 'finance@royalenfield.com',
|
||||||
SALES: 'sales@royalenfield.com',
|
SALES: 'sales@royalenfield.com',
|
||||||
@ -52,9 +52,7 @@ async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
|||||||
async function login(email) {
|
async function login(email) {
|
||||||
if (!login.cache) login.cache = {};
|
if (!login.cache) login.cache = {};
|
||||||
if (login.cache[email]) return login.cache[email];
|
if (login.cache[email]) return login.cache[email];
|
||||||
const isInternal = email.endsWith('@royalenfield.com') ||
|
const isInternal = email.endsWith('@royalenfield.com');
|
||||||
email === 'lince@gmail.com' ||
|
|
||||||
email === 'yashwin@gmail.com';
|
|
||||||
const password = isInternal ? 'Admin@123' : 'Dealer@123';
|
const password = isInternal ? 'Admin@123' : 'Dealer@123';
|
||||||
|
|
||||||
const data = await apiRequest('/auth/login', 'POST', { email, password });
|
const data = await apiRequest('/auth/login', 'POST', { email, password });
|
||||||
|
|||||||
@ -9,15 +9,15 @@ const STEP_DELAY_MS = Number(args.delayMs || 500);
|
|||||||
const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true';
|
const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true';
|
||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
DD_ADMIN: 'lince@gmail.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
ASM: 'asm.sdelhi@royalenfield.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
RBM: 'rbm.ncr@royalenfield.com',
|
RBM: 'manish@royalenfield.com',
|
||||||
ZBH: 'yashwin@gmail.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'ddlead@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
NBH: 'nbh@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
CCO: 'cco@royalenfield.com',
|
CCO: 'admin@royalenfield.com',
|
||||||
CEO: 'ceo@royalenfield.com',
|
CEO: 'admin@royalenfield.com',
|
||||||
SALES: 'sales@royalenfield.com',
|
SALES: 'sales@royalenfield.com',
|
||||||
SERVICE: 'service@royalenfield.com',
|
SERVICE: 'service@royalenfield.com',
|
||||||
SPARES: 'spares@royalenfield.com',
|
SPARES: 'spares@royalenfield.com',
|
||||||
|
|||||||
@ -20,16 +20,16 @@ const PROSPECT_EMAIL = `ramesh_${timestamp}@gmail.com`;
|
|||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
PROSPECT: PROSPECT_EMAIL,
|
PROSPECT: PROSPECT_EMAIL,
|
||||||
RBM_L1: 'manish@gmail.com',
|
RBM_L1: 'manish@royalenfield.com',
|
||||||
ZM_L1: 'piyush@gmail.com',
|
ZM_L1: 'piyush@royalenfield.com',
|
||||||
DD_LEAD: 'jaya@gmail.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
ZBH: 'manav@gmail.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
NBH: 'yashwin@gmail.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
DD_HEAD: 'ganesh@gmail.com',
|
DD_HEAD: 'ganesh@royalenfield.com',
|
||||||
FDD: 'fdd@gmail.com',
|
FDD: 'fdd@royalenfield.com',
|
||||||
FINANCE: 'finance@gmail.com',
|
FINANCE: 'finance@royalenfield.com',
|
||||||
DD_ADMIN: 'aman@gmail.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
ASM: 'abhishek@gmail.com',
|
ASM: 'abhishek@royalenfield.com',
|
||||||
SALES: 'sales@royalenfield.com',
|
SALES: 'sales@royalenfield.com',
|
||||||
SERVICE: 'service@royalenfield.com',
|
SERVICE: 'service@royalenfield.com',
|
||||||
SPARES: 'spares@royalenfield.com',
|
SPARES: 'spares@royalenfield.com',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user