few de demo bugs and sla tracker implemeted alog with sla moitor screen
This commit is contained in:
parent
f5022b613d
commit
f5d7ccc1ab
15
.env.example
15
.env.example
@ -35,3 +35,18 @@ VAPID_EMAIL=admin@royalenfield.com
|
||||
# File Uploads
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
|
||||
# Redis / BullMQ (required for scheduled jobs: SLA, LWD, questionnaire reminders, notifications)
|
||||
ENABLE_REDIS=false
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
# DEBUG_SLA_FAST_MODE=true # Internal SLA checks every minute (dev only)
|
||||
# SLA_BUSINESS_HOURS=false # Disable Mon–Fri 9–18h TAT counting (default: on unless fast mode)
|
||||
# DEBUG_OFFBOARDING_LWD_FAST_MODE=true # LWD reminder sweep every 15 min (dev only)
|
||||
|
||||
# Prospect questionnaire reminders (scheduled — NOT internal SLA)
|
||||
QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED=true
|
||||
QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS=1
|
||||
QUESTIONNAIRE_REMINDER_INTERVAL_DAYS=2
|
||||
QUESTIONNAIRE_REMINDER_MAX_COUNT=5
|
||||
# DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE=true # Sweep every 15 min (dev only)
|
||||
|
||||
50
docs/sla/IMPLEMENTATION_STATUS.md
Normal file
50
docs/sla/IMPLEMENTATION_STATUS.md
Normal file
@ -0,0 +1,50 @@
|
||||
# SLA — SRS §9.4 vs implementation
|
||||
|
||||
**Last reviewed:** 2026-05-18
|
||||
|
||||
## Summary
|
||||
|
||||
| Area | Config | Runtime | UI |
|
||||
|------|--------|---------|-----|
|
||||
| Onboarding | Yes | Yes | Badges + ops monitor |
|
||||
| Termination | Yes | Yes | Badges + ops monitor |
|
||||
| Resignation | Yes | Yes | Badges + ops monitor |
|
||||
| Relocation | Yes | Yes | Badges + ops monitor |
|
||||
| Constitutional | Yes | Yes | Badges + ops monitor |
|
||||
| F&F | Yes | Yes (per dept) | Ops monitor (FNF filter) |
|
||||
|
||||
**Overall:** Core engine **~95%** · UX/reporting **~85%** · SRS calendar/pause rules **~85%**
|
||||
|
||||
---
|
||||
|
||||
## Capability checklist
|
||||
|
||||
| Requirement | Status |
|
||||
|-------------|--------|
|
||||
| Templates, reminders, L1–L3 escalation, work notes | **Done** |
|
||||
| Operations monitor + aging buckets | **Done** |
|
||||
| My queue (`mineOnly`) + CSV export | **Done** |
|
||||
| Batch status API + badges all modules | **Done** |
|
||||
| F&F clearance timers | **Done** |
|
||||
| Business hours Mon–Fri 9–18 | **Done** (`slaBusinessTime.ts`) |
|
||||
| Repeat overdue reminders | **Done** |
|
||||
| Pause on termination hold | **Done** |
|
||||
| Geography-aware escalation | **Done** (`slaGeographyResolver.ts`) |
|
||||
| 30-day analytics (breach rate, top stages) | **Done** |
|
||||
| Questionnaire reminder admin | **Done** |
|
||||
| E2E tests | **Added** (`sla-lifecycle.test.ts`) |
|
||||
|
||||
---
|
||||
|
||||
## APIs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /api/sla/operations/dashboard` | Queue + analytics (`?module=&mineOnly=`) |
|
||||
| `GET /api/sla/operations/export` | CSV export |
|
||||
| `POST /api/sla/status/batch` | `{ items: [{ entityType, entityId }] }` |
|
||||
| `GET/PUT /api/sla/settings/questionnaire-reminder` | Prospect reminder cadence |
|
||||
|
||||
---
|
||||
|
||||
See [PENDING_WORK.md](./PENDING_WORK.md) for remaining optional items.
|
||||
54
docs/sla/ONBOARDING_SLA_RULES.md
Normal file
54
docs/sla/ONBOARDING_SLA_RULES.md
Normal file
@ -0,0 +1,54 @@
|
||||
# Onboarding — what uses SLA vs applicant reminders
|
||||
|
||||
SRS §9.4 SLA applies to **internal role turnaround** (ASM, RBM, FDD, Finance, etc.).
|
||||
It does **not** apply to steps the **prospect/dealer** performs on the public portal.
|
||||
|
||||
## Do not configure internal SLA
|
||||
|
||||
| Pipeline step | Why | What to use instead |
|
||||
|---------------|-----|---------------------|
|
||||
| **Submitted** | Application is already submitted; no internal approver waiting. | None (audit only). |
|
||||
| **Questionnaire** | **Prospect** fills the questionnaire, not ASM/RBM. | **Scheduled BullMQ job** + manual bulk: `QUESTIONNAIRE_REMINDER` to applicant (`QuestionnaireReminderService`, requires `ENABLE_REDIS=true`). |
|
||||
|
||||
Runtime: `shouldTrackOnboardingSla()` skips these in `WorkflowService`. Re-seed sets `isActive: false` on old config rows.
|
||||
|
||||
## Internal SLA (configured)
|
||||
|
||||
| Step | Owner (internal) | Real scenario |
|
||||
|------|------------------|---------------|
|
||||
| Shortlist | DD Admin | Admin reviews leads and shortlists (§6.6). |
|
||||
| 1st / 2nd / 3rd Level Interview | RBM+DD-ZM, DD Lead+ZBH, NBH+DD Head | Panel feedback TAT after interviews are scheduled. |
|
||||
| FDD | FDD | External agency report upload/review. |
|
||||
| LOI Approval | NBH | Internal LOI approval gate. |
|
||||
| Security Deposit | Finance | Finance verifies payment proof uploaded by applicant. |
|
||||
| LOI Issue | DD Admin | LOI issuance processing. |
|
||||
| Dealer Code Generation | DD Admin | Code generation task. |
|
||||
| Architecture / Statutory Work | Architecture Team / DD Admin+Legal | Parallel compliance tracks. |
|
||||
| LOA | NBH | LOA approval. |
|
||||
| EOR Complete | DD Admin | EOR milestone. |
|
||||
| Inauguration | ASM | Post-EOR inauguration coordination. |
|
||||
|
||||
## Applicant-facing communications (not SLA matrix)
|
||||
|
||||
| Template | Audience | Trigger |
|
||||
|----------|----------|---------|
|
||||
| `OPPORTUNITY` / portal link | Applicant | Opportunity conversion |
|
||||
| `QUESTIONNAIRE_REMINDER` | Applicant | DD Admin bulk reminder (pending questionnaire) |
|
||||
| `QUESTIONNAIRE_SUBMITTED` | Applicant | On questionnaire submit |
|
||||
| `ONBOARDING_STATUS_UPDATE` | Applicant | Status changes |
|
||||
|
||||
## Scheduled prospect reminders (not SLA Configuration UI)
|
||||
|
||||
| Env variable | Default | Meaning |
|
||||
|--------------|---------|---------|
|
||||
| `ENABLE_REDIS` | `false` | Must be `true` for scheduler |
|
||||
| `QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED` | `true` | Master switch |
|
||||
| `QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS` | `1` | Wait after entering Questionnaire Pending |
|
||||
| `QUESTIONNAIRE_REMINDER_INTERVAL_DAYS` | `2` | Min days between repeat emails |
|
||||
| `QUESTIONNAIRE_REMINDER_MAX_COUNT` | `5` | Max auto reminders per application |
|
||||
| `DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE` | off | Sweep every 15 min (dev) |
|
||||
|
||||
**Cron:** daily 09:00 (same pattern as LWD admin sweep).
|
||||
**Code:** `questionnaire-reminder.queue.ts` → `QuestionnaireReminderService.processScheduledReminders()`.
|
||||
|
||||
Internal **SLA worker** (`sla.worker.ts`) only processes `sla_tracking` rows for **internal roles** — it does not email prospects.
|
||||
50
docs/sla/PENDING_WORK.md
Normal file
50
docs/sla/PENDING_WORK.md
Normal file
@ -0,0 +1,50 @@
|
||||
# SLA — pending implementation
|
||||
|
||||
Ordered by impact. Update this file when items ship.
|
||||
|
||||
## P0 — High impact
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 1 | F&F runtime hooks | **Done** |
|
||||
| 2 | SLA badges (all modules list + detail) | **Done** |
|
||||
| 3 | My SLA Queue (`mineOnly` + export) | **Done** |
|
||||
| 4 | Deactivate legacy SLA rows via seed | **Done** (run `seed-sla-configs.ts` per env) |
|
||||
|
||||
## P1 — SRS completeness
|
||||
|
||||
| # | Item | SRS ref | Status |
|
||||
|---|------|---------|--------|
|
||||
| 5 | Pause/resume SLA on **On Hold** | §9.4.3 | **Partial** — termination hold wired; resignation/relocation hold not implemented in workflow |
|
||||
| 6 | Working-day calendar + business hours | §9.4.5 | **Done** |
|
||||
| 7 | Repeat overdue reminder cadence | §9.4.2 | **Done** |
|
||||
| 8 | Geography-aware escalation (zone → RBM/ZBH) | §9.4.3 | **Done** — `slaGeographyResolver.ts` + `SLAService` notify/escalation |
|
||||
| 9 | Resignation clearance substages (optional per-dept TAT) | 02_Dealer_Resignation | Not started (F&F dept SLAs done separately) |
|
||||
|
||||
## P2 — Reporting & ops
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 10 | Breach rate / avg resolution / top delayed analytics | **Done** — 30-day panel on Operations monitor |
|
||||
| 11 | Aging buckets | **Done** |
|
||||
| 12 | Export compliance CSV | **Done** |
|
||||
| 13 | E2E automated tests | **Done** — `src/__tests__/sla-lifecycle.test.ts` |
|
||||
|
||||
## P3 — Optional
|
||||
|
||||
| # | Item | Status |
|
||||
|---|------|--------|
|
||||
| 14 | Per statutory sub-status SLA | Not started |
|
||||
| 15 | Admin UI for questionnaire reminder cadence | **Done** — Schedulers tab + `PUT /api/sla/settings/questionnaire-reminder` |
|
||||
|
||||
---
|
||||
|
||||
## Verification checklist
|
||||
|
||||
1. `npx tsx scripts/migrate-sla-tracking-schema.ts` (once per DB if needed)
|
||||
2. `npx tsx scripts/seed-sla-configs.ts`
|
||||
3. `ENABLE_REDIS=true` + restart API
|
||||
4. Operations monitor → analytics cards, **My queue**, **Export CSV**, **Schedulers** → questionnaire settings
|
||||
5. Termination **On Hold** → SLA **Paused**; resume on next transition
|
||||
6. Escalation to RBM/ZBH routes to district/region/zone mapped users (not global first match)
|
||||
7. `npm test -- sla-lifecycle`
|
||||
45
docs/sla/README.md
Normal file
45
docs/sla/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
# SLA implementation tracking
|
||||
|
||||
This folder tracks **SRS §9.4 SLA Configuration & Escalation Management** against the codebase.
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [IMPLEMENTATION_STATUS.md](./IMPLEMENTATION_STATUS.md) | What is built vs SRS (by capability) |
|
||||
| [ONBOARDING_SLA_RULES.md](./ONBOARDING_SLA_RULES.md) | Applicant steps vs internal SLA (Submitted, Questionnaire, etc.) |
|
||||
| [STAGE_CONFIGURATION_MATRIX.md](./STAGE_CONFIGURATION_MATRIX.md) | Every approval stage vs SLA config row |
|
||||
| [PENDING_WORK.md](./PENDING_WORK.md) | Ordered backlog to reach full compliance |
|
||||
|
||||
## Source of truth (code)
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| Activity catalog (all modules) | `backend/src/common/config/slaStageCatalog.ts` |
|
||||
| Seed script | `backend/scripts/seed-sla-configs.ts` |
|
||||
| Admin “Initialize defaults” API | `POST /api/master/sla-configs/initialize` |
|
||||
| Runtime engine | `backend/src/services/SLAService.ts` |
|
||||
| BullMQ worker | `backend/src/common/queues/sla.worker.ts` (`ENABLE_REDIS=true`) |
|
||||
| Stage sync on transition | `backend/src/common/utils/slaWorkflowSync.ts` |
|
||||
|
||||
## Apply / refresh configuration
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx scripts/seed-sla-configs.ts
|
||||
```
|
||||
|
||||
Or use **Master → SLA Configuration → Initialize defaults** in the UI.
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `ENABLE_REDIS=true` | Runs SLA breach checker on schedule |
|
||||
| `DEBUG_SLA_FAST_MODE=true` | 1 hour TAT ≈ 1 minute (dev only) |
|
||||
|
||||
## DB note
|
||||
|
||||
If `sla_tracking.metadata` (or `entityType` / `entityId`) is missing on an older database, run:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/migrate-sla-tracking-schema.ts
|
||||
```
|
||||
117
docs/sla/STAGE_CONFIGURATION_MATRIX.md
Normal file
117
docs/sla/STAGE_CONFIGURATION_MATRIX.md
Normal file
@ -0,0 +1,117 @@
|
||||
# SLA stage configuration matrix
|
||||
|
||||
`activityName` in `sla_configurations` **must equal** the workflow stage string when the case enters that step.
|
||||
|
||||
Legend: **Configured** = row in `SLA_STAGE_CATALOG` · **Runtime** = timer starts on transition today
|
||||
|
||||
---
|
||||
|
||||
## Onboarding (14 internal SLA rows — pipeline label)
|
||||
|
||||
See [ONBOARDING_SLA_RULES.md](./ONBOARDING_SLA_RULES.md) for applicant vs internal steps.
|
||||
|
||||
| Activity (config) | Owner role(s) | TAT | Runtime |
|
||||
|-------------------|---------------|-----|---------|
|
||||
| ~~Submitted~~ | — | — | **Excluded** (applicant action done) |
|
||||
| ~~Questionnaire~~ | — | — | **Excluded** — use `QUESTIONNAIRE_REMINDER` to **prospect** |
|
||||
| Shortlist | DD Admin | 3 days | Yes |
|
||||
| 1st Level Interview | RBM, DD-ZM | 2 days | Yes |
|
||||
| 2nd Level Interview | DD Lead, ZBH | 3 days | Yes |
|
||||
| 3rd Level Interview | NBH, DD Head | 5 days | Yes |
|
||||
| FDD | FDD | 10 days | Yes |
|
||||
| LOI Approval | NBH | 5 days | Yes |
|
||||
| Security Deposit | Finance | 7 days | Yes |
|
||||
| LOI Issue | DD Admin | 3 days | Yes |
|
||||
| Dealer Code Generation | DD Admin | 2 days | Yes |
|
||||
| Architecture Work | Architecture Team | 14 days | Yes |
|
||||
| Statutory Work | DD Admin, Legal | 14 days | Yes |
|
||||
| LOA | NBH | 5 days | Yes |
|
||||
| EOR Complete | DD Admin | 7 days | Yes |
|
||||
| Inauguration | ASM | 3 days | Yes |
|
||||
|
||||
**Not separately configured (optional future):** per-statutory sub-status (`Statutory GST`, …), interview scheduling pending states.
|
||||
|
||||
---
|
||||
|
||||
## Termination (12 configured — matches `TERMINATION_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| RBM + DD-ZM Review | RBM, DD-ZM | 3 days | Yes |
|
||||
| ZBH Review | ZBH | 3 days | Yes |
|
||||
| DD Lead Review | DD Lead | 5 days | Yes |
|
||||
| Legal Verification | Legal Admin | 7 days | Yes |
|
||||
| DD Head Review | DD Head | 5 days | Yes |
|
||||
| NBH Evaluation | NBH | 5 days | Yes |
|
||||
| Show Cause Notice (SCN) | Legal Admin, DD Admin | 5 days | Yes |
|
||||
| Evaluation of Dealer SCN Response | DD Lead, ZBH, RBM, DD Head | 5 days | Yes |
|
||||
| NBH Final Approval | NBH | 3 days | Yes |
|
||||
| CCO Approval | CCO | 3 days | Yes |
|
||||
| CEO Final Approval | CEO | 5 days | Yes |
|
||||
| Legal - Termination Letter | Legal Admin | 5 days | Yes |
|
||||
|
||||
**Skipped (terminal / no approval TAT):** Submitted, Terminated, Rejected.
|
||||
|
||||
---
|
||||
|
||||
## Resignation (8 configured — matches `RESIGNATION_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| ASM | ASM | 2 days | Yes |
|
||||
| RBM + DD-ZM Review | RBM, DD-ZM | 3 days | Yes |
|
||||
| ZBH | ZBH | 3 days | Yes |
|
||||
| DD Lead | DD Lead | 5 days | Yes |
|
||||
| NBH | NBH | 5 days | Yes |
|
||||
| Legal | Legal Admin | 7 days | Yes |
|
||||
| DD Admin | DD Admin | 3 days | Yes |
|
||||
| Awaiting F&F | DD Lead, DD Admin | 7 days | Yes |
|
||||
|
||||
**Not configured:** post–F&F department clearance rows (`Spares Clearance`, …) — use F&F module rows if enabled later.
|
||||
|
||||
---
|
||||
|
||||
## Relocation (7 configured — matches `RELOCATION_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| ASM Review | ASM | 2 days | Yes |
|
||||
| RBM Review | RBM | 3 days | Yes |
|
||||
| DD ZM Review | DD-ZM | 3 days | Yes |
|
||||
| ZBH Review | ZBH | 3 days | Yes |
|
||||
| DD Lead Review | DD Lead | 5 days | Yes |
|
||||
| NBH Approval | NBH | 5 days | Yes |
|
||||
| Legal Clearance | Legal Admin | 7 days | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Constitutional change (7 configured — matches `CONSTITUTIONAL_STAGES`)
|
||||
|
||||
| Activity | Owner | TAT | Runtime |
|
||||
|----------|-------|-----|---------|
|
||||
| ASM Review | ASM | 2 days | Yes |
|
||||
| ZM/RBM Review | RBM, DD-ZM | 3 days | Yes |
|
||||
| ZBH Review | ZBH | 3 days | Yes |
|
||||
| DD Lead Review | DD Lead | 5 days | Yes |
|
||||
| DD Head Review | DD Head | 5 days | Yes |
|
||||
| NBH Approval | NBH | 5 days | Yes |
|
||||
| Legal Review | Legal Admin | 7 days | Yes |
|
||||
|
||||
---
|
||||
|
||||
## F&F settlement (16 configured — **config only**)
|
||||
|
||||
| Activity pattern | Owner | TAT | Runtime |
|
||||
|------------------|-------|-----|---------|
|
||||
| F&F Clearance: {Department} × 16 | Finance / DD Admin | 5 days each | **Not wired** |
|
||||
|
||||
**Pending:** call `syncSlaOnStageTransition({ entityType: 'fnf', … })` when department clearance status → Pending and stop when Cleared.
|
||||
|
||||
---
|
||||
|
||||
## Legacy config rows (safe to deactivate)
|
||||
|
||||
After re-seed, old names no longer match workflows. Deactivate or delete in Master UI if still present:
|
||||
|
||||
- `ASM Review`, `ZM Review`, `Level 1 Interview` (old onboarding names)
|
||||
- `Resignation ASM Review`, `Termination Evaluation`, `Relocation ASM Review`, `Constitution Legal Review`, etc.
|
||||
34
scripts/migrate-sla-tracking-schema.ts
Normal file
34
scripts/migrate-sla-tracking-schema.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
/**
|
||||
* Aligns sla_tracking with SLATracking model (entity columns + metadata for reminder state).
|
||||
* Safe to run multiple times (IF NOT EXISTS).
|
||||
*/
|
||||
async function migrate() {
|
||||
const { sequelize } = db as any;
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
|
||||
const statements = [
|
||||
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS "entityType" VARCHAR(255)`,
|
||||
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS "entityId" UUID`,
|
||||
`ALTER TABLE sla_tracking ADD COLUMN IF NOT EXISTS metadata JSONB DEFAULT '{}'::jsonb`,
|
||||
// Backfill entity columns for legacy rows that only had applicationId
|
||||
`UPDATE sla_tracking SET "entityType" = 'application' WHERE "entityType" IS NULL AND "applicationId" IS NOT NULL`,
|
||||
`UPDATE sla_tracking SET "entityId" = "applicationId" WHERE "entityId" IS NULL AND "applicationId" IS NOT NULL`
|
||||
];
|
||||
|
||||
for (const sql of statements) {
|
||||
console.log('Running:', sql.slice(0, 80) + '...');
|
||||
await sequelize.query(sql);
|
||||
}
|
||||
|
||||
console.log('sla_tracking schema migration complete.');
|
||||
await sequelize.close();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,120 +1,30 @@
|
||||
import 'dotenv/config';
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
type SlaDefault = {
|
||||
stage: string;
|
||||
role: string;
|
||||
tat: number;
|
||||
unit: 'hours' | 'days';
|
||||
};
|
||||
|
||||
const defaults: SlaDefault[] = [
|
||||
// ONBOARDING
|
||||
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
|
||||
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
|
||||
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
|
||||
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
|
||||
// RESIGNATION
|
||||
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
|
||||
|
||||
// TERMINATION
|
||||
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
|
||||
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
|
||||
|
||||
// RELOCATION
|
||||
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
|
||||
// CONSTITUTIONAL CHANGE
|
||||
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
|
||||
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
];
|
||||
import { SLA_STAGE_CATALOG } from '../src/common/config/slaStageCatalog.js';
|
||||
import { seedSlaCatalogEntries } from '../src/common/utils/slaSeedUtils.js';
|
||||
|
||||
async function seedSlaConfigs() {
|
||||
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any;
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
const { sequelize } = db as any;
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected.');
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
for (const item of defaults) {
|
||||
const [config, created] = await SLAConfiguration.findOrCreate({
|
||||
where: { activityName: item.stage },
|
||||
defaults: {
|
||||
activityName: item.stage,
|
||||
ownerRole: item.role,
|
||||
tatHours: item.tat,
|
||||
tatUnit: item.unit,
|
||||
isActive: true,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await config.update(
|
||||
{
|
||||
ownerRole: item.role,
|
||||
tatHours: item.tat,
|
||||
tatUnit: item.unit,
|
||||
},
|
||||
{ transaction }
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
|
||||
await transaction.commit();
|
||||
console.log(
|
||||
`SLA configurations seeded successfully. Total activities: ${SLA_STAGE_CATALOG.length}`
|
||||
);
|
||||
}
|
||||
|
||||
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
|
||||
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
|
||||
|
||||
await SLAReminder.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true },
|
||||
{ slaConfigId: config.id, timeValue: 4, timeUnit: 'hours', isEnabled: true },
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await SLAEscalationConfig.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: config.id, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
|
||||
{ slaConfigId: config.id, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
|
||||
{ slaConfigId: config.id, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' },
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('SLA seed failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
console.log(`SLA configurations seeded successfully. Total stages: ${defaults.length}`);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('SLA seed failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
seedSlaConfigs().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
147
src/__tests__/sla-lifecycle.test.ts
Normal file
147
src/__tests__/sla-lifecycle.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* SLA lifecycle: start → breach → geography escalation → pause/resume
|
||||
*/
|
||||
|
||||
import { SLAService } from '../services/SLAService.js';
|
||||
import { resolveRecipientsForRoles } from '../services/slaGeographyResolver.js';
|
||||
import { NotificationService } from '../services/NotificationService.js';
|
||||
|
||||
const mockTrackUpdate = jest.fn().mockResolvedValue(true);
|
||||
const mockTrackCreate = jest.fn().mockResolvedValue({ id: 'track-1', stageName: 'ASM Review', metadata: {} });
|
||||
const mockFindAllTracks = jest.fn();
|
||||
const mockBreachCreate = jest.fn().mockResolvedValue({ id: 'breach-1' });
|
||||
|
||||
const mockConfig = {
|
||||
id: 'cfg-1',
|
||||
activityName: 'ASM Review',
|
||||
tatHours: 1,
|
||||
tatUnit: 'hours',
|
||||
ownerRole: 'ASM,RBM',
|
||||
reminders: [],
|
||||
escalationConfigs: [{ level: 1, timeValue: 0, timeUnit: 'hours', notifyRole: 'RBM' }]
|
||||
};
|
||||
|
||||
jest.mock('../database/models/index.js', () => {
|
||||
const mockUpdate = jest.fn().mockResolvedValue(true);
|
||||
return {
|
||||
default: {
|
||||
SLATracking: {
|
||||
findAll: (...args) => mockFindAllTracks(...args),
|
||||
update: mockUpdate,
|
||||
create: mockTrackCreate
|
||||
},
|
||||
SLAConfiguration: {
|
||||
findOne: jest.fn().mockResolvedValue(mockConfig),
|
||||
findByPk: jest.fn().mockResolvedValue({
|
||||
...mockConfig,
|
||||
reminders: [],
|
||||
escalationConfigs: mockConfig.escalationConfigs
|
||||
})
|
||||
},
|
||||
SLABreach: { create: (...args) => mockBreachCreate(...args) },
|
||||
Application: { findByPk: jest.fn().mockResolvedValue(null) },
|
||||
User: {
|
||||
findByPk: jest.fn().mockResolvedValue({ id: 'u-rbm', email: 'rbm@test.com', mobileNumber: '99' }),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null)
|
||||
},
|
||||
Worknote: { create: jest.fn().mockResolvedValue(true) },
|
||||
TerminationRequest: { findByPk: jest.fn() },
|
||||
Resignation: { findByPk: jest.fn() },
|
||||
RelocationRequest: { findByPk: jest.fn() },
|
||||
ConstitutionalChange: { findByPk: jest.fn() },
|
||||
FnF: { findByPk: jest.fn() },
|
||||
Dealer: { findByPk: jest.fn() },
|
||||
Outlet: { findOne: jest.fn() },
|
||||
District: {},
|
||||
Region: {},
|
||||
Zone: {}
|
||||
},
|
||||
__mocks__: { mockUpdate, mockTrackUpdate }
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../common/config/slaStageCatalog.js', () => ({
|
||||
slaConfigLookupNames: (name) => [name]
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/slaBusinessTime.js', () => ({
|
||||
effectiveElapsedMs: () => 10 * 60 * 60 * 1000
|
||||
}));
|
||||
|
||||
jest.mock('../services/NotificationService.js', () => ({
|
||||
NotificationService: { notify: jest.fn().mockResolvedValue(undefined) }
|
||||
}));
|
||||
|
||||
jest.mock('../services/slaGeographyResolver.js', () => ({
|
||||
resolveRecipientsForRoles: jest.fn().mockResolvedValue(['geo-rbm-1'])
|
||||
}));
|
||||
|
||||
jest.mock('../common/utils/workflowWorknote.js', () => ({
|
||||
writeWorkflowActivityWorknote: jest.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
const mockNotify = jest.spyOn(NotificationService, 'notify').mockResolvedValue(undefined);
|
||||
const mockGeo = resolveRecipientsForRoles;
|
||||
|
||||
describe('SLAService lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.DEBUG_SLA_FAST_MODE = 'true';
|
||||
mockFindAllTracks.mockReset();
|
||||
});
|
||||
|
||||
it('startTrack creates a row when config exists', async () => {
|
||||
await SLAService.startTrack({
|
||||
entityType: 'application',
|
||||
entityId: 'app-1',
|
||||
applicationId: 'app-1',
|
||||
stageName: 'ASM Review'
|
||||
});
|
||||
expect(mockTrackCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkBreaches triggers breach and geography-aware escalation', async () => {
|
||||
const track = {
|
||||
id: 'track-1',
|
||||
entityType: 'termination',
|
||||
entityId: 'term-1',
|
||||
applicationId: null,
|
||||
stageName: 'ASM Review',
|
||||
startTime: new Date(Date.now() - 3 * 60 * 60 * 1000),
|
||||
isBreached: false,
|
||||
isActive: true,
|
||||
endTime: null,
|
||||
metadata: {},
|
||||
update: mockTrackUpdate
|
||||
};
|
||||
|
||||
mockFindAllTracks.mockResolvedValueOnce([track]).mockResolvedValueOnce([{ ...track, isBreached: true }]);
|
||||
|
||||
await SLAService.checkBreaches();
|
||||
expect(mockBreachCreate).toHaveBeenCalled();
|
||||
expect(mockNotify).toHaveBeenCalled();
|
||||
|
||||
await SLAService.checkBreaches();
|
||||
expect(mockGeo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pauseEntityTracks and resumeEntityTracks adjust metadata', async () => {
|
||||
const track = {
|
||||
id: 'track-2',
|
||||
metadata: {},
|
||||
update: jest.fn().mockImplementation(async (payload) => {
|
||||
track.metadata = payload.metadata;
|
||||
})
|
||||
};
|
||||
mockFindAllTracks.mockResolvedValue([track]);
|
||||
|
||||
await SLAService.pauseEntityTracks('termination', 'term-1');
|
||||
expect(track.metadata.pausedAt).toBeDefined();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await SLAService.resumeEntityTracks('termination', 'term-1');
|
||||
expect(track.metadata.pausedAt).toBeUndefined();
|
||||
expect(Number(track.metadata.accumulatedPauseMs || 0)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
158
src/common/config/slaStageCatalog.ts
Normal file
158
src/common/config/slaStageCatalog.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Canonical SLA activity catalog — `activityName` MUST match workflow `currentStage`
|
||||
* (or onboarding pipeline label) for timers to start/stop correctly.
|
||||
*
|
||||
* SRS: Re_New_Dealer_Onboard_TWO.md §9.4
|
||||
*/
|
||||
import {
|
||||
TERMINATION_STAGES,
|
||||
RESIGNATION_STAGES,
|
||||
RELOCATION_STAGES,
|
||||
CONSTITUTIONAL_STAGES,
|
||||
FNF_DEPARTMENTS
|
||||
} from './constants.js';
|
||||
|
||||
export type SlaCatalogEntry = {
|
||||
module: 'ONBOARDING' | 'RESIGNATION' | 'TERMINATION' | 'RELOCATION' | 'CONSTITUTIONAL' | 'FNF';
|
||||
activityName: string;
|
||||
ownerRole: string;
|
||||
tatHours: number;
|
||||
tatUnit: 'hours' | 'days';
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding pipeline steps that must NOT use internal SLA (§9.4).
|
||||
* - Submitted: applicant action already complete — no internal approver TAT.
|
||||
* - Questionnaire: prospect/dealer fills form — use QUESTIONNAIRE_REMINDER (email/WhatsApp), not SLA to ASM/RBM.
|
||||
*/
|
||||
export const ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES = ['Submitted', 'Questionnaire'] as const;
|
||||
|
||||
/** Legacy rows to deactivate when re-seeding after catalog corrections */
|
||||
export const ONBOARDING_SLA_DEPRECATED_ACTIVITIES = [
|
||||
...ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES,
|
||||
'ASM Review',
|
||||
'ZM Review',
|
||||
'Level 1 Interview',
|
||||
'Level 2 Interview',
|
||||
'Level 3 Interview',
|
||||
'FDD Verification',
|
||||
'Finance Verification',
|
||||
'LOA Approval'
|
||||
] as const;
|
||||
|
||||
export function shouldTrackOnboardingSla(pipelineStage: string | null | undefined): boolean {
|
||||
if (!pipelineStage) return false;
|
||||
return !ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES.includes(
|
||||
pipelineStage as (typeof ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES)[number]
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-rename `sla_configurations.activityName` values still present until re-seed */
|
||||
export const SLA_ACTIVITY_LEGACY_ALIASES: Record<string, string[]> = {
|
||||
'1st Level Interview': ['Level 1 Interview'],
|
||||
'2nd Level Interview': ['Level 2 Interview'],
|
||||
'3rd Level Interview': ['Level 3 Interview']
|
||||
};
|
||||
|
||||
/** Names to match when resolving SLA config / stopping a timer for a pipeline stage */
|
||||
export function slaConfigLookupNames(pipelineStage: string): string[] {
|
||||
return [pipelineStage, ...(SLA_ACTIVITY_LEGACY_ALIASES[pipelineStage] || [])];
|
||||
}
|
||||
|
||||
const d = (module: SlaCatalogEntry['module'], activityName: string, ownerRole: string, tatHours: number, tatUnit: 'hours' | 'days' = 'days'): SlaCatalogEntry => ({
|
||||
module,
|
||||
activityName,
|
||||
ownerRole,
|
||||
tatHours,
|
||||
tatUnit
|
||||
});
|
||||
|
||||
/**
|
||||
* Onboarding — internal approver TAT only (`PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS`).
|
||||
* Excluded: Submitted, Questionnaire (see ONBOARDING_SLA_EXCLUDED_PIPELINE_STAGES).
|
||||
*/
|
||||
const ONBOARDING_SLA: SlaCatalogEntry[] = [
|
||||
d('ONBOARDING', 'Shortlist', 'DD Admin', 3),
|
||||
d('ONBOARDING', '1st Level Interview', 'RBM, DD-ZM', 2),
|
||||
d('ONBOARDING', '2nd Level Interview', 'DD Lead, ZBH', 3),
|
||||
d('ONBOARDING', '3rd Level Interview', 'NBH, DD Head', 5),
|
||||
d('ONBOARDING', 'FDD', 'FDD', 10),
|
||||
d('ONBOARDING', 'LOI Approval', 'NBH', 5),
|
||||
d('ONBOARDING', 'Security Deposit', 'Finance', 7),
|
||||
d('ONBOARDING', 'LOI Issue', 'DD Admin', 3),
|
||||
d('ONBOARDING', 'Dealer Code Generation', 'DD Admin', 2),
|
||||
d('ONBOARDING', 'Architecture Work', 'Architecture Team', 14),
|
||||
d('ONBOARDING', 'Statutory Work', 'DD Admin, Legal', 14),
|
||||
d('ONBOARDING', 'LOA', 'NBH', 5),
|
||||
d('ONBOARDING', 'EOR Complete', 'DD Admin', 7),
|
||||
d('ONBOARDING', 'Inauguration', 'ASM', 3)
|
||||
];
|
||||
|
||||
const TERMINATION_SLA: SlaCatalogEntry[] = [
|
||||
d('TERMINATION', TERMINATION_STAGES.RBM_REVIEW, 'RBM, DD-ZM', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.ZBH_REVIEW, 'ZBH', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.DD_LEAD_REVIEW, 'DD Lead', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.LEGAL_VERIFICATION, 'Legal Admin', 7),
|
||||
d('TERMINATION', TERMINATION_STAGES.DD_HEAD_REVIEW, 'DD Head', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.NBH_EVALUATION, 'NBH', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.SCN_ISSUED, 'Legal Admin, DD Admin', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.PERSONAL_HEARING, 'DD Lead, ZBH, RBM, DD Head', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.NBH_FINAL_APPROVAL, 'NBH', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.CCO_APPROVAL, 'CCO', 3),
|
||||
d('TERMINATION', TERMINATION_STAGES.CEO_APPROVAL, 'CEO', 5),
|
||||
d('TERMINATION', TERMINATION_STAGES.LEGAL_LETTER, 'Legal Admin', 5)
|
||||
];
|
||||
|
||||
const RESIGNATION_SLA: SlaCatalogEntry[] = [
|
||||
d('RESIGNATION', RESIGNATION_STAGES.ASM, 'ASM', 2),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.RBM, 'RBM, DD-ZM', 3),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.ZBH, 'ZBH', 3),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.DD_LEAD, 'DD Lead', 5),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.NBH, 'NBH', 5),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.LEGAL, 'Legal Admin', 7),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.DD_ADMIN, 'DD Admin', 3),
|
||||
d('RESIGNATION', RESIGNATION_STAGES.AWAITING_FNF, 'DD Lead, DD Admin', 7)
|
||||
];
|
||||
|
||||
const RELOCATION_SLA: SlaCatalogEntry[] = [
|
||||
d('RELOCATION', RELOCATION_STAGES.ASM_REVIEW, 'ASM', 2),
|
||||
d('RELOCATION', RELOCATION_STAGES.RBM_REVIEW, 'RBM', 3),
|
||||
d('RELOCATION', RELOCATION_STAGES.DD_ZM_REVIEW, 'DD-ZM', 3),
|
||||
d('RELOCATION', RELOCATION_STAGES.ZBH_REVIEW, 'ZBH', 3),
|
||||
d('RELOCATION', RELOCATION_STAGES.DD_LEAD_REVIEW, 'DD Lead', 5),
|
||||
d('RELOCATION', RELOCATION_STAGES.NBH_APPROVAL, 'NBH', 5),
|
||||
d('RELOCATION', RELOCATION_STAGES.LEGAL_CLEARANCE, 'Legal Admin', 7)
|
||||
];
|
||||
|
||||
const CONSTITUTIONAL_SLA: SlaCatalogEntry[] = [
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ASM_REVIEW, 'ASM', 2),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, 'RBM, DD-ZM', 3),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.ZBH_REVIEW, 'ZBH', 3),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.LEAD_REVIEW, 'DD Lead', 5),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.HEAD_REVIEW, 'DD Head', 5),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.NBH_APPROVAL, 'NBH', 5),
|
||||
d('CONSTITUTIONAL', CONSTITUTIONAL_STAGES.LEGAL_REVIEW, 'Legal Admin', 7)
|
||||
];
|
||||
|
||||
/** F&F departmental NOC — SRS §4 / modular_wise/04_FF_Settlement.md */
|
||||
const FNF_SLA: SlaCatalogEntry[] = FNF_DEPARTMENTS.map((dept) =>
|
||||
d('FNF', `F&F Clearance: ${dept}`, dept.includes('Finance') ? 'Finance' : 'DD Admin', 5)
|
||||
);
|
||||
|
||||
export const SLA_STAGE_CATALOG: SlaCatalogEntry[] = [
|
||||
...ONBOARDING_SLA,
|
||||
...TERMINATION_SLA,
|
||||
...RESIGNATION_SLA,
|
||||
...RELOCATION_SLA,
|
||||
...CONSTITUTIONAL_SLA,
|
||||
...FNF_SLA
|
||||
];
|
||||
|
||||
export const SLA_CATALOG_BY_MODULE = {
|
||||
ONBOARDING: ONBOARDING_SLA,
|
||||
RESIGNATION: RESIGNATION_SLA,
|
||||
TERMINATION: TERMINATION_SLA,
|
||||
RELOCATION: RELOCATION_SLA,
|
||||
CONSTITUTIONAL: CONSTITUTIONAL_SLA,
|
||||
FNF: FNF_SLA
|
||||
} as const;
|
||||
28
src/common/queues/offboarding-lwd.queue.ts
Normal file
28
src/common/queues/offboarding-lwd.queue.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
|
||||
export const offboardingLwdQueue = new Queue('offboardingLwdQueue', {
|
||||
connection: redisConfig
|
||||
});
|
||||
|
||||
/**
|
||||
* Daily sweep (08:00) + optional per-case delayed jobs when a case enters Awaiting F&F.
|
||||
*/
|
||||
export const scheduleOffboardingLwdReminders = async () => {
|
||||
const isFastMode = process.env.DEBUG_OFFBOARDING_LWD_FAST_MODE === 'true';
|
||||
const pattern = isFastMode ? '*/15 * * * *' : '0 8 * * *';
|
||||
|
||||
const jobs = await offboardingLwdQueue.getRepeatableJobs();
|
||||
for (const job of jobs) {
|
||||
await offboardingLwdQueue.removeRepeatableByKey(job.key);
|
||||
}
|
||||
|
||||
await offboardingLwdQueue.add('checkLwdFnfReminders', {}, {
|
||||
repeat: { pattern },
|
||||
jobId: 'offboarding-lwd-fnf-reminder'
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Offboarding LWD Queue] Repeatable job scheduled: ${isFastMode ? 'Every 15 minutes (FAST MODE)' : 'Daily at 08:00'}`
|
||||
);
|
||||
};
|
||||
35
src/common/queues/offboarding-lwd.worker.ts
Normal file
35
src/common/queues/offboarding-lwd.worker.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
import { OffboardingLwdReminderService } from '../../services/OffboardingLwdReminderService.js';
|
||||
|
||||
export const offboardingLwdWorker = new Worker(
|
||||
'offboardingLwdQueue',
|
||||
async (job) => {
|
||||
console.log(`[Offboarding LWD Worker] Processing job ${job.id} (${job.name})`);
|
||||
|
||||
if (job.name === 'checkLwdFnfReminders') {
|
||||
await OffboardingLwdReminderService.processAllPendingReminders();
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name === 'sendLwdFnfReminder') {
|
||||
const payload = job.data as { requestType: 'resignation' | 'termination'; requestId: string };
|
||||
await OffboardingLwdReminderService.processOne(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[Offboarding LWD Worker] Unknown job name: ${job.name}`);
|
||||
},
|
||||
{
|
||||
connection: redisConfig,
|
||||
concurrency: 2
|
||||
}
|
||||
);
|
||||
|
||||
offboardingLwdWorker.on('completed', (job) => {
|
||||
console.log(`[Offboarding LWD Worker] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
offboardingLwdWorker.on('failed', (job, err) => {
|
||||
console.error(`[Offboarding LWD Worker] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
29
src/common/queues/questionnaire-reminder.queue.ts
Normal file
29
src/common/queues/questionnaire-reminder.queue.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
|
||||
export const questionnaireReminderQueue = new Queue('questionnaireReminderQueue', {
|
||||
connection: redisConfig
|
||||
});
|
||||
|
||||
/**
|
||||
* Scheduled prospect reminders for pending questionnaires (not internal SLA).
|
||||
* Default: daily 09:00 — or every 15 min when DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE=true.
|
||||
*/
|
||||
export const scheduleQuestionnaireReminders = async () => {
|
||||
const isFastMode = process.env.DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE === 'true';
|
||||
const pattern = isFastMode ? '*/15 * * * *' : '0 9 * * *';
|
||||
|
||||
const jobs = await questionnaireReminderQueue.getRepeatableJobs();
|
||||
for (const job of jobs) {
|
||||
await questionnaireReminderQueue.removeRepeatableByKey(job.key);
|
||||
}
|
||||
|
||||
await questionnaireReminderQueue.add('checkQuestionnaireReminders', {}, {
|
||||
repeat: { pattern },
|
||||
jobId: 'questionnaire-pending-reminder-sweep'
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Questionnaire Reminder Queue] Scheduled: ${isFastMode ? 'every 15 min (FAST MODE)' : 'daily at 09:00'}`
|
||||
);
|
||||
};
|
||||
29
src/common/queues/questionnaire-reminder.worker.ts
Normal file
29
src/common/queues/questionnaire-reminder.worker.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import { redisConfig } from './config.js';
|
||||
import { QuestionnaireReminderService } from '../../services/QuestionnaireReminderService.js';
|
||||
|
||||
export const questionnaireReminderWorker = new Worker(
|
||||
'questionnaireReminderQueue',
|
||||
async (job) => {
|
||||
console.log(`[Questionnaire Reminder Worker] Processing job ${job.id} (${job.name})`);
|
||||
|
||||
if (job.name === 'checkQuestionnaireReminders') {
|
||||
await QuestionnaireReminderService.processScheduledReminders();
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[Questionnaire Reminder Worker] Unknown job name: ${job.name}`);
|
||||
},
|
||||
{
|
||||
connection: redisConfig,
|
||||
concurrency: 1
|
||||
}
|
||||
);
|
||||
|
||||
questionnaireReminderWorker.on('completed', (job) => {
|
||||
console.log(`[Questionnaire Reminder Worker] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
questionnaireReminderWorker.on('failed', (job, err) => {
|
||||
console.error(`[Questionnaire Reminder Worker] Job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
40
src/common/utils/offboardingLwd.ts
Normal file
40
src/common/utils/offboardingLwd.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Last Working Day (LWD) helpers for resignation & termination offboarding.
|
||||
*/
|
||||
|
||||
export const LWD_FNF_READY_REMINDER_ACTION = 'LWD_FNF_READY_REMINDER';
|
||||
|
||||
export function normalizeDateOnly(value: Date | string | null | undefined): Date | null {
|
||||
if (value == null || value === '') return null;
|
||||
const d = value instanceof Date ? new Date(value) : new Date(String(value));
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function isLwdReached(
|
||||
lwd: Date | string | null | undefined,
|
||||
today: Date = new Date()
|
||||
): boolean {
|
||||
const lwdDate = normalizeDateOnly(lwd);
|
||||
if (!lwdDate) return true;
|
||||
const t = normalizeDateOnly(today)!;
|
||||
return t >= lwdDate;
|
||||
}
|
||||
|
||||
export function formatLwdDisplay(lwd: Date | string | null | undefined): string {
|
||||
const d = normalizeDateOnly(lwd);
|
||||
if (!d) return 'N/A';
|
||||
return d.toLocaleDateString('en-IN', { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
/** Milliseconds from now until start of LWD date (00:00 local). Returns 0 if LWD is today or past. */
|
||||
export function msUntilLwdMorning(lwd: Date | string): number {
|
||||
const lwdDate = normalizeDateOnly(lwd);
|
||||
if (!lwdDate) return 0;
|
||||
const now = new Date();
|
||||
const target = new Date(lwdDate);
|
||||
target.setHours(8, 0, 0, 0); // 08:00 local — align with daily cron
|
||||
const diff = target.getTime() - now.getTime();
|
||||
return diff > 0 ? diff : 0;
|
||||
}
|
||||
54
src/common/utils/slaBusinessTime.ts
Normal file
54
src/common/utils/slaBusinessTime.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/** SRS §9.4.5 — business hours 09:00–18:00, Mon–Fri (local server timezone). */
|
||||
|
||||
const BUSINESS_START_HOUR = 9;
|
||||
const BUSINESS_END_HOUR = 18;
|
||||
|
||||
export function isBusinessHoursEnabled(): boolean {
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') return false;
|
||||
return process.env.SLA_BUSINESS_HOURS !== 'false';
|
||||
}
|
||||
|
||||
export function businessMsBetween(start: Date, end: Date): number {
|
||||
if (end.getTime() <= start.getTime()) return 0;
|
||||
|
||||
let total = 0;
|
||||
const cursor = new Date(start);
|
||||
|
||||
while (cursor.getTime() < end.getTime()) {
|
||||
const day = cursor.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
const windowStart = new Date(cursor);
|
||||
windowStart.setHours(BUSINESS_START_HOUR, 0, 0, 0);
|
||||
const windowEnd = new Date(cursor);
|
||||
windowEnd.setHours(BUSINESS_END_HOUR, 0, 0, 0);
|
||||
|
||||
const sliceStart = Math.max(cursor.getTime(), windowStart.getTime(), start.getTime());
|
||||
const sliceEnd = Math.min(end.getTime(), windowEnd.getTime());
|
||||
if (sliceEnd > sliceStart) {
|
||||
total += sliceEnd - sliceStart;
|
||||
}
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
export function effectiveElapsedMs(
|
||||
track: { startTime: Date | string; metadata?: Record<string, unknown> | null },
|
||||
nowMs: number
|
||||
): number {
|
||||
const meta = track.metadata || {};
|
||||
const start = new Date(track.startTime).getTime();
|
||||
const pausedAt = meta.pausedAt ? new Date(String(meta.pausedAt)).getTime() : null;
|
||||
const effectiveEnd = pausedAt ? Math.min(nowMs, pausedAt) : nowMs;
|
||||
|
||||
let elapsed = isBusinessHoursEnabled()
|
||||
? businessMsBetween(new Date(start), new Date(effectiveEnd))
|
||||
: effectiveEnd - start;
|
||||
|
||||
const accumulatedPause = Number(meta.accumulatedPauseMs || 0);
|
||||
elapsed = Math.max(0, elapsed - accumulatedPause);
|
||||
return elapsed;
|
||||
}
|
||||
36
src/common/utils/slaFnfSync.ts
Normal file
36
src/common/utils/slaFnfSync.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { FNF_DEPARTMENTS } from '../config/constants.js';
|
||||
import { SLAService } from '../../services/SLAService.js';
|
||||
|
||||
export function fnfSlaStageName(department: string): string {
|
||||
return `F&F Clearance: ${department}`;
|
||||
}
|
||||
|
||||
/** Start/stop departmental F&F SLA timer (non-fatal). */
|
||||
export async function syncFnfClearanceSla(fnfId: string, department: string, status: string) {
|
||||
const stageName = fnfSlaStageName(department);
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
|
||||
try {
|
||||
if (normalized === 'pending') {
|
||||
await SLAService.startTrack({
|
||||
entityType: 'fnf',
|
||||
entityId: fnfId,
|
||||
applicationId: null,
|
||||
stageName
|
||||
});
|
||||
} else {
|
||||
await SLAService.stopTrack({
|
||||
entityType: 'fnf',
|
||||
entityId: fnfId,
|
||||
applicationId: null,
|
||||
stageName
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[slaFnfSync] clearance SLA sync failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAllPendingFnfClearanceSlas(fnfId: string) {
|
||||
await Promise.all(FNF_DEPARTMENTS.map((dept) => syncFnfClearanceSla(fnfId, dept, 'Pending')));
|
||||
}
|
||||
67
src/common/utils/slaMetrics.ts
Normal file
67
src/common/utils/slaMetrics.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { effectiveElapsedMs } from './slaBusinessTime.js';
|
||||
|
||||
export type SlaBucket = 'healthy' | 'warning' | 'critical' | 'breached';
|
||||
|
||||
export function getTatInMs(value: number, unit: string): number {
|
||||
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
|
||||
factor = factor / 60;
|
||||
}
|
||||
return value * factor;
|
||||
}
|
||||
|
||||
export function bucketFromPercent(percent: number, isBreached: boolean): SlaBucket {
|
||||
if (isBreached || percent >= 100) return 'breached';
|
||||
if (percent >= 76) return 'critical';
|
||||
if (percent >= 26) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
export function formatSlaDuration(ms: number): string {
|
||||
const abs = Math.abs(ms);
|
||||
const mins = Math.floor(abs / 60000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function ownerRoleMatchesUser(ownerRole: string, userRoleCode: string | undefined | null): boolean {
|
||||
if (!ownerRole || !userRoleCode) return false;
|
||||
const userNorm = userRoleCode.replace(/_/g, '-').toUpperCase();
|
||||
return ownerRole.split(',').some((part) => {
|
||||
const p = part.trim().replace(/\s+/g, '-').toUpperCase();
|
||||
return p === userNorm || userNorm.includes(p) || p.includes(userNorm);
|
||||
});
|
||||
}
|
||||
|
||||
export function computeSlaTrackView(
|
||||
track: { startTime: Date | string; isBreached?: boolean; metadata?: Record<string, unknown> | null },
|
||||
config: { tatHours: number; tatUnit: string },
|
||||
now = Date.now()
|
||||
) {
|
||||
const startMs = new Date(track.startTime).getTime();
|
||||
const tatMs = getTatInMs(config.tatHours, config.tatUnit);
|
||||
const elapsedMs = effectiveElapsedMs(track, now);
|
||||
const deadline = new Date(startMs + tatMs);
|
||||
const percentUsed = tatMs > 0 ? Math.round((elapsedMs / tatMs) * 100) : 0;
|
||||
const isBreached = Boolean(track.isBreached) || elapsedMs >= tatMs;
|
||||
const bucket = bucketFromPercent(percentUsed, isBreached);
|
||||
const msRemaining = tatMs - elapsedMs;
|
||||
const meta = track.metadata || {};
|
||||
const isPaused = Boolean(meta.pausedAt);
|
||||
|
||||
return {
|
||||
deadline,
|
||||
percentUsed,
|
||||
bucket,
|
||||
isBreached,
|
||||
isPaused,
|
||||
remainingLabel: isPaused
|
||||
? 'Paused'
|
||||
: msRemaining > 0
|
||||
? `${formatSlaDuration(msRemaining)} left`
|
||||
: `${formatSlaDuration(-msRemaining)} overdue`
|
||||
};
|
||||
}
|
||||
72
src/common/utils/slaSeedUtils.ts
Normal file
72
src/common/utils/slaSeedUtils.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { SlaCatalogEntry } from '../config/slaStageCatalog.js';
|
||||
import { ONBOARDING_SLA_DEPRECATED_ACTIVITIES } from '../config/slaStageCatalog.js';
|
||||
|
||||
/** Default reminders / escalations per SRS §9.4.5 (T-24h, T-4h; L1 +4h, L2 +12h, L3 +24h). */
|
||||
export async function applySlaConfigChildren(
|
||||
db: any,
|
||||
configId: string,
|
||||
transaction: any
|
||||
) {
|
||||
await db.SLAReminder.destroy({ where: { slaConfigId: configId }, transaction });
|
||||
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: configId }, transaction });
|
||||
|
||||
await db.SLAReminder.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: configId, timeValue: 1, timeUnit: 'days', isEnabled: true },
|
||||
{ slaConfigId: configId, timeValue: 4, timeUnit: 'hours', isEnabled: true }
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await db.SLAEscalationConfig.bulkCreate(
|
||||
[
|
||||
{ slaConfigId: configId, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
|
||||
{ slaConfigId: configId, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
|
||||
{ slaConfigId: configId, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' }
|
||||
],
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedSlaCatalogEntries(
|
||||
db: any,
|
||||
entries: SlaCatalogEntry[],
|
||||
transaction?: any
|
||||
) {
|
||||
for (const item of entries) {
|
||||
const [config, created] = await db.SLAConfiguration.findOrCreate({
|
||||
where: { activityName: item.activityName },
|
||||
defaults: {
|
||||
activityName: item.activityName,
|
||||
ownerRole: item.ownerRole,
|
||||
tatHours: item.tatHours,
|
||||
tatUnit: item.tatUnit,
|
||||
isActive: true
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
await config.update(
|
||||
{
|
||||
ownerRole: item.ownerRole,
|
||||
tatHours: item.tatHours,
|
||||
tatUnit: item.tatUnit
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
await applySlaConfigChildren(db, config.id, transaction);
|
||||
}
|
||||
|
||||
if (ONBOARDING_SLA_DEPRECATED_ACTIVITIES.length > 0) {
|
||||
await db.SLAConfiguration.update(
|
||||
{ isActive: false },
|
||||
{
|
||||
where: { activityName: [...ONBOARDING_SLA_DEPRECATED_ACTIVITIES] },
|
||||
transaction
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/common/utils/slaWorkflowSync.ts
Normal file
35
src/common/utils/slaWorkflowSync.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { SLAService } from '../../services/SLAService.js';
|
||||
|
||||
export type SlaEntityType = 'application' | 'termination' | 'resignation' | 'relocation' | 'constitutional' | 'fnf';
|
||||
|
||||
/** Sync SLA timers when a workflow stage changes (non-fatal). */
|
||||
export async function syncSlaOnStageTransition(opts: {
|
||||
entityType: SlaEntityType;
|
||||
entityId: string;
|
||||
applicationId?: string | null;
|
||||
fromStage?: string | null;
|
||||
toStage?: string | null;
|
||||
}) {
|
||||
const { entityType, entityId, applicationId = null, fromStage, toStage } = opts;
|
||||
|
||||
try {
|
||||
if (fromStage) {
|
||||
await SLAService.stopTrack({
|
||||
entityType,
|
||||
entityId,
|
||||
applicationId,
|
||||
stageName: fromStage
|
||||
});
|
||||
}
|
||||
if (toStage) {
|
||||
await SLAService.startTrack({
|
||||
entityType,
|
||||
entityId,
|
||||
applicationId,
|
||||
stageName: toStage
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[slaWorkflowSync] SLA sync failed:', err);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
||||
'EOR_COMPLETED',
|
||||
'FDD_DOCUMENT_REQUEST',
|
||||
'FNF_INITIATED',
|
||||
'FNF_LWD_READY',
|
||||
'FNF_SUMMARY_PREPARED',
|
||||
'FNF_SETTLEMENT_APPROVED',
|
||||
'GENERIC_NOTIFICATION',
|
||||
|
||||
@ -11,6 +11,7 @@ export interface SLATrackingAttributes {
|
||||
duration: number | null; // minutes or hours
|
||||
isBreached: boolean;
|
||||
isActive: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SLATrackingInstance extends Model<SLATrackingAttributes>, SLATrackingAttributes { }
|
||||
@ -61,6 +62,11 @@ export default (sequelize: Sequelize) => {
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: {}
|
||||
}
|
||||
}, {
|
||||
tableName: 'sla_tracking',
|
||||
|
||||
@ -9,7 +9,9 @@ import { Op } from 'sequelize';
|
||||
import * as EmailService from '../../common/utils/email.service.js';
|
||||
import { APPLICATION_STAGES, APPLICATION_STATUS, AUDIT_ACTIONS } from '../../common/config/constants.js';
|
||||
import { WorkflowService } from '../../services/WorkflowService.js';
|
||||
import { syncApplicationProgress } from '../../common/utils/progress.js';
|
||||
import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../../common/utils/progress.js';
|
||||
import { shouldTrackOnboardingSla } from '../../common/config/slaStageCatalog.js';
|
||||
import { SLAService } from '../../services/SLAService.js';
|
||||
import { NotificationService } from '../../services/NotificationService.js';
|
||||
|
||||
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
|
||||
@ -677,6 +679,17 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
||||
reason: `Interview Level ${levelNum} Scheduled`
|
||||
});
|
||||
|
||||
// Ensure internal SLA timer runs even when status was already "Level N Interview Pending"
|
||||
const pipelineStage = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[newStatus];
|
||||
if (pipelineStage && shouldTrackOnboardingSla(pipelineStage)) {
|
||||
await SLAService.startTrack({
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
applicationId: application.id,
|
||||
stageName: pipelineStage
|
||||
}).catch((e) => console.error('[scheduleInterview] SLA start failed:', e));
|
||||
}
|
||||
|
||||
// 3. Panelist notifications (Applicant is already notified above with INTERVIEW_SCHEDULED_APPLICANT)
|
||||
if (participantIds.length > 0) {
|
||||
for (const userId of participantIds) {
|
||||
|
||||
@ -1700,117 +1700,10 @@ export const saveSlaConfig = async (req: Request, res: Response) => {
|
||||
export const initializeDefaultSlas = async (req: Request, res: Response) => {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const defaults = [
|
||||
// --- ONBOARDING ---
|
||||
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' }, // Per Doc §9.4.5
|
||||
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
|
||||
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
|
||||
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
|
||||
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
const { SLA_STAGE_CATALOG } = await import('../../common/config/slaStageCatalog.js');
|
||||
const { seedSlaCatalogEntries } = await import('../../common/utils/slaSeedUtils.js');
|
||||
|
||||
// --- RESIGNATION ---
|
||||
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
|
||||
|
||||
// --- TERMINATION ---
|
||||
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
|
||||
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
|
||||
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
|
||||
|
||||
// --- RELOCATION ---
|
||||
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
|
||||
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
|
||||
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
|
||||
// --- CONSTITUTIONAL CHANGE ---
|
||||
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
|
||||
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
|
||||
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' }
|
||||
];
|
||||
|
||||
for (const item of defaults) {
|
||||
const [config, created] = await db.SLAConfiguration.findOrCreate({
|
||||
where: { activityName: item.stage },
|
||||
defaults: {
|
||||
activityName: item.stage,
|
||||
ownerRole: item.role,
|
||||
tatHours: item.tat,
|
||||
tatUnit: item.unit as any,
|
||||
isActive: true
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
// Update existing to match new standard defaults if they haven't been customized too much?
|
||||
// Actually, let's just make sure they have the right roles as per new document alignment
|
||||
await config.update({
|
||||
ownerRole: item.role,
|
||||
tatHours: item.tat,
|
||||
tatUnit: item.unit as any
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
// Cleanup old reminders/escalations to avoid duplicates if re-running
|
||||
await db.SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
|
||||
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
|
||||
|
||||
// 1. Default Reminders (Per Doc §9.4.5: T-24h and T-4h)
|
||||
await db.SLAReminder.bulkCreate([
|
||||
{
|
||||
slaConfigId: config.id,
|
||||
timeValue: 1,
|
||||
timeUnit: 'days',
|
||||
isEnabled: true
|
||||
},
|
||||
{
|
||||
slaConfigId: config.id,
|
||||
timeValue: 4,
|
||||
timeUnit: 'hours',
|
||||
isEnabled: true
|
||||
}
|
||||
], { transaction });
|
||||
|
||||
// 2. Escalation Matrix (Per Doc §9.4.5)
|
||||
// L1: +4h, L2: +12h, L3: +24h
|
||||
await db.SLAEscalationConfig.bulkCreate([
|
||||
{
|
||||
slaConfigId: config.id,
|
||||
level: 1,
|
||||
timeValue: 4,
|
||||
timeUnit: 'hours',
|
||||
notifyRole: 'ZBH' // Example default escalation path
|
||||
},
|
||||
{
|
||||
slaConfigId: config.id,
|
||||
level: 2,
|
||||
timeValue: 12,
|
||||
timeUnit: 'hours',
|
||||
notifyRole: 'DD Lead'
|
||||
},
|
||||
{
|
||||
slaConfigId: config.id,
|
||||
level: 3,
|
||||
timeValue: 24,
|
||||
timeUnit: 'hours',
|
||||
notifyRole: 'NBH'
|
||||
}
|
||||
], { transaction });
|
||||
}
|
||||
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
@ -1818,13 +1711,16 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
|
||||
module: SYSTEM_AUDIT_MODULES.SLA_CONFIG,
|
||||
entityType: 'sla_defaults',
|
||||
entityId: null,
|
||||
entityLabel: `Default SLA matrix (${defaults.length} stages)`,
|
||||
entityLabel: `Default SLA matrix (${SLA_STAGE_CATALOG.length} stages)`,
|
||||
action: SYSTEM_AUDIT_ACTIONS.INITIALIZED,
|
||||
description: `Initialized comprehensive SLA defaults across onboarding, resignation, termination, relocation, and constitutional flows (${defaults.length} stages)`,
|
||||
metadata: { stageCount: defaults.length }
|
||||
description: `Initialized SLA catalog aligned to workflow stage names (${SLA_STAGE_CATALOG.length} activities)`,
|
||||
metadata: { stageCount: SLA_STAGE_CATALOG.length }
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Comprehensive default SLA configurations initialized across all modules' });
|
||||
res.json({
|
||||
success: true,
|
||||
message: `SLA configurations initialized (${SLA_STAGE_CATALOG.length} activities across all modules)`
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Init SLA error:', error);
|
||||
|
||||
@ -1718,29 +1718,9 @@ export const sendBulkReminders = async (req: AuthRequest, res: Response) => {
|
||||
where: { id: { [Op.in]: applicationIds } }
|
||||
});
|
||||
|
||||
const { QuestionnaireReminderService } = await import('../../services/QuestionnaireReminderService.js');
|
||||
for (const app of applications) {
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
app.email,
|
||||
app.phone,
|
||||
app.applicantName,
|
||||
{
|
||||
location: app.preferredLocation,
|
||||
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/questionnaire/${app.applicationId}`
|
||||
}
|
||||
);
|
||||
|
||||
// Log Audit
|
||||
await safeAuditLogCreate({
|
||||
userId: req.user?.id || null,
|
||||
action: 'REMINDER_SENT',
|
||||
entityType: 'application',
|
||||
entityId: app.id,
|
||||
newData: {
|
||||
template: 'QUESTIONNAIRE_REMINDER',
|
||||
sentAt: new Date(),
|
||||
context: pickApplicationAuditContext(app)
|
||||
}
|
||||
});
|
||||
await QuestionnaireReminderService.sendReminderForApplication(app, 'manual');
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Reminders sent to ${applications.length} applicants` });
|
||||
|
||||
@ -412,21 +412,15 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
// LWD gate applies to that manual push (SRS §4.2.2.8).
|
||||
let shouldTriggerFnF = false;
|
||||
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED && targetOverride) {
|
||||
const today = new Date();
|
||||
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||
const { force } = req.body;
|
||||
const { isLwdReached, formatLwdDisplay } = await import('../../common/utils/offboardingLwd.js');
|
||||
|
||||
const lwd = lwdString ? new Date(lwdString) : null;
|
||||
if (lwd) {
|
||||
today.setHours(0, 0, 0, 0);
|
||||
lwd.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
if (!force && lwd && today < lwd) {
|
||||
if (!force && lwdString && !isLwdReached(lwdString)) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `F&F settlement process is initiated only on the Last Working Day (${lwdString}) of the dealership.`,
|
||||
message: `F&F settlement process is initiated only on the Last Working Day (${formatLwdDisplay(lwdString)}) of the dealership.`,
|
||||
canForce: true
|
||||
});
|
||||
}
|
||||
@ -540,6 +534,15 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
||||
if (nextStage === RESIGNATION_STAGES.AWAITING_FNF) {
|
||||
message =
|
||||
'DD Admin approval recorded. Use Push to F&F when ready to create the Full & Final settlement (Last Working Day rules apply).';
|
||||
const lwdForSchedule =
|
||||
resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||
if (lwdForSchedule) {
|
||||
const { OffboardingLwdReminderService } = await import('../../services/OffboardingLwdReminderService.js');
|
||||
await OffboardingLwdReminderService.scheduleDelayedReminderIfNeeded(
|
||||
{ requestType: 'resignation', requestId: resignation.id },
|
||||
lwdForSchedule
|
||||
);
|
||||
}
|
||||
} else if (sourceStage === RESIGNATION_STAGES.LEGAL && nextStage === RESIGNATION_STAGES.DD_ADMIN) {
|
||||
message =
|
||||
'Legal stage approved successfully. After DD Admin review, use Push to F&F to start settlement when ready.';
|
||||
@ -1013,6 +1016,12 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (fnf) {
|
||||
const { syncFnfClearanceSla } = await import('../../common/utils/slaFnfSync.js');
|
||||
await syncFnfClearanceSla(fnf.id, department, normalizedDeptStatus);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Clearance updated for ${department}`, resignation });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
|
||||
@ -788,6 +788,9 @@ export const updateClearance = async (req: AuthRequest, res: Response) => {
|
||||
clearedAt: normalizedStatus !== 'Pending' ? new Date() : clearance.clearedAt
|
||||
});
|
||||
|
||||
const { syncFnfClearanceSla } = await import('../../common/utils/slaFnfSync.js');
|
||||
await syncFnfClearanceSla(String(id), clearance.department, normalizedStatus);
|
||||
|
||||
// Automatically update FnF progress
|
||||
console.log(`[SettlementController] Updating clearance for F&F: ${id}`);
|
||||
const fnfRecord = await calculateFnFLogic(id as string, req.user?.id);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import type { AuthRequest } from '../../types/express.types.js';
|
||||
import db from '../../database/models/index.js';
|
||||
const { SLAConfiguration, SLATracking, SLABreach } = db;
|
||||
|
||||
@ -71,7 +72,93 @@ export const getTracking = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getOperationsDashboard = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const module = typeof req.query.module === 'string' ? req.query.module : undefined;
|
||||
const breachedOnly = String(req.query.breachedOnly || 'false') === 'true';
|
||||
const mineOnly = String(req.query.mineOnly || 'false') === 'true';
|
||||
const { SlaOperationsService } = await import('../../services/SlaOperationsService.js');
|
||||
const data = await SlaOperationsService.getDashboard({
|
||||
module,
|
||||
breachedOnly,
|
||||
mineOnly,
|
||||
userRoleCode: req.user?.roleCode
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('SLA operations dashboard error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error loading SLA operations dashboard' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getBatchStatus = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const items = Array.isArray(req.body?.items) ? req.body.items : [];
|
||||
const refs = items
|
||||
.filter((i: any) => i?.entityType && i?.entityId)
|
||||
.map((i: any) => ({ entityType: String(i.entityType), entityId: String(i.entityId) }));
|
||||
const { SlaStatusService } = await import('../../services/SlaStatusService.js');
|
||||
const data = await SlaStatusService.getBatchStatus(refs);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('SLA batch status error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error fetching SLA status' });
|
||||
}
|
||||
};
|
||||
|
||||
export const exportOperationsCsv = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const module = typeof req.query.module === 'string' ? req.query.module : undefined;
|
||||
const mineOnly = String(req.query.mineOnly || 'false') === 'true';
|
||||
const { SlaOperationsService } = await import('../../services/SlaOperationsService.js');
|
||||
const data = await SlaOperationsService.getDashboard({
|
||||
module,
|
||||
mineOnly,
|
||||
userRoleCode: req.user?.roleCode
|
||||
});
|
||||
const csv = SlaOperationsService.toCsv(data);
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="sla-queue-${Date.now()}.csv"`);
|
||||
res.send(csv);
|
||||
} catch (error) {
|
||||
console.error('SLA export error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error exporting SLA queue' });
|
||||
}
|
||||
};
|
||||
|
||||
// --- Debug Endpoint ---
|
||||
export const getQuestionnaireReminderSettings = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { getQuestionnaireReminderSettings } = await import(
|
||||
'../../services/questionnaireReminderSettings.js'
|
||||
);
|
||||
const data = await getQuestionnaireReminderSettings();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('Get questionnaire reminder settings error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error loading questionnaire reminder settings' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateQuestionnaireReminderSettings = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { saveQuestionnaireReminderSettings } = await import(
|
||||
'../../services/questionnaireReminderSettings.js'
|
||||
);
|
||||
const { enabled, firstAfterDays, intervalDays, maxCount } = req.body || {};
|
||||
const data = await saveQuestionnaireReminderSettings({
|
||||
enabled: enabled !== undefined ? Boolean(enabled) : undefined,
|
||||
firstAfterDays: firstAfterDays !== undefined ? Number(firstAfterDays) : undefined,
|
||||
intervalDays: intervalDays !== undefined ? Number(intervalDays) : undefined,
|
||||
maxCount: maxCount !== undefined ? Number(maxCount) : undefined
|
||||
});
|
||||
res.json({ success: true, data, message: 'Questionnaire reminder settings saved' });
|
||||
} catch (error) {
|
||||
console.error('Update questionnaire reminder settings error:', error);
|
||||
res.status(500).json({ success: false, message: 'Error saving questionnaire reminder settings' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueueStatus = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { slaQueue } = await import('../../common/queues/sla.queue.js');
|
||||
|
||||
@ -7,6 +7,11 @@ router.use(authenticate as any);
|
||||
|
||||
router.get('/configs', slaController.getConfigs);
|
||||
router.put('/configs/:id', slaController.updateConfig);
|
||||
router.get('/operations/dashboard', slaController.getOperationsDashboard);
|
||||
router.get('/operations/export', slaController.exportOperationsCsv);
|
||||
router.get('/settings/questionnaire-reminder', slaController.getQuestionnaireReminderSettings);
|
||||
router.put('/settings/questionnaire-reminder', slaController.updateQuestionnaireReminderSettings);
|
||||
router.post('/status/batch', slaController.getBatchStatus);
|
||||
router.get('/tracking/:applicationId', slaController.getTracking);
|
||||
router.get('/debug/queue', slaController.getQueueStatus);
|
||||
|
||||
|
||||
@ -364,6 +364,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
|
||||
const fromStage = termination.currentStage;
|
||||
let approvedToStage: string | null = null;
|
||||
let scheduleLwdFnfReminder = false;
|
||||
|
||||
if (action === OFFBOARDING_ACTIONS.REJECT) {
|
||||
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
||||
@ -391,6 +392,10 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
const { SLAService } = await import('../../services/SLAService.js');
|
||||
await SLAService.pauseEntityTracks('termination', termination.id);
|
||||
|
||||
return res.json({ success: true, message: 'Termination case placed on hold.' });
|
||||
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
||||
// Validation: Remarks mandatory for Revoke
|
||||
@ -424,24 +429,62 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
remarks
|
||||
});
|
||||
} else if (action === 'pushfnf') {
|
||||
if (termination.currentStage !== TERMINATION_STAGES.TERMINATED && termination.currentStage !== TERMINATION_STAGES.LEGAL_LETTER) {
|
||||
// FnF row is created ONLY here — never on stage approval or LWD scheduler.
|
||||
const awaitingFnfStatuses = ['Awaiting F&F', 'Awaiting F&F (LWD Pending)', 'Terminated'];
|
||||
const atTerminatedStage = termination.currentStage === TERMINATION_STAGES.TERMINATED;
|
||||
const awaitingFnfStatus = awaitingFnfStatuses.includes(String(termination.status || ''));
|
||||
|
||||
if (!atTerminatedStage && !awaitingFnfStatus) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Cannot trigger F&F from ${termination.currentStage}. Complete the CEO and Legal approvals first.`
|
||||
success: false,
|
||||
message: `Cannot trigger F&F from ${termination.currentStage}. Complete Legal approval so the case reaches Terminated / Awaiting F&F first.`
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[TerminationController] Forcibly initiating F&F (pushfnf) for Termination ${termination.requestId}`);
|
||||
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction);
|
||||
|
||||
// Maintain timeline visibility
|
||||
const existingFnf = await db.FnF.findOne({
|
||||
where: { terminationRequestId: termination.id },
|
||||
transaction
|
||||
});
|
||||
if (existingFnf) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'F&F settlement already exists for this termination. Open the F&F module to continue clearance.'
|
||||
});
|
||||
}
|
||||
|
||||
const { force } = req.body;
|
||||
const { isLwdReached, formatLwdDisplay } = await import('../../common/utils/offboardingLwd.js');
|
||||
if (!force && !isLwdReached(termination.proposedLwd)) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `F&F settlement can be initiated only on or after the Last Working Day (${formatLwdDisplay(termination.proposedLwd)}).`,
|
||||
canForce: true
|
||||
});
|
||||
}
|
||||
|
||||
const forceInitiated = Boolean(force);
|
||||
logger.info(
|
||||
`[TerminationController] Manual Push to F&F for Termination ${termination.requestId} (force=${forceInitiated})`
|
||||
);
|
||||
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction, {
|
||||
manualTrigger: true
|
||||
});
|
||||
|
||||
// Maintain timeline visibility — "Forced" only when LWD was bypassed
|
||||
const timeline = [...(termination.timeline || []), {
|
||||
stage: termination.currentStage,
|
||||
timestamp: new Date(),
|
||||
user: req.user.fullName,
|
||||
action: 'Forced F&F Initiation',
|
||||
remarks: remarks || 'F&F settlement initiated manually via Push to F&F'
|
||||
role: req.user.roleCode,
|
||||
action: forceInitiated ? 'Forced F&F Initiation' : 'F&F Initiated',
|
||||
remarks:
|
||||
remarks ||
|
||||
(forceInitiated
|
||||
? 'F&F settlement initiated before Last Working Day (exception / force).'
|
||||
: 'F&F settlement initiated via Push to F&F after Last Working Day.')
|
||||
}];
|
||||
await termination.update({
|
||||
currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals
|
||||
@ -630,10 +673,20 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
||||
logger.info(
|
||||
`[TerminationController] Termination reached TERMINATED. F&F must be started manually (Push to F&F). LWD=${termination.proposedLwd}, status=${statusAfterTerm}`
|
||||
);
|
||||
scheduleLwdFnfReminder = true;
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (scheduleLwdFnfReminder) {
|
||||
const { OffboardingLwdReminderService } = await import('../../services/OffboardingLwdReminderService.js');
|
||||
await OffboardingLwdReminderService.scheduleDelayedReminderIfNeeded(
|
||||
{ requestType: 'termination', requestId: termination.id },
|
||||
termination.proposedLwd
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Termination updated', termination });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
@ -913,10 +966,19 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
||||
updatedBy: req.user.fullName
|
||||
};
|
||||
|
||||
const fnf = await db.FnF.findOne({ where: { terminationRequestId: resolvedId }, transaction });
|
||||
if (!fnf) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
'F&F settlement has not been started for this termination. Use Push to F&F on the termination case first.'
|
||||
});
|
||||
}
|
||||
|
||||
await termination.update({ departmentalClearances: clearances }, { transaction });
|
||||
|
||||
// Update individual clearance record for unified dashboard
|
||||
const fnf = await db.FnF.findOne({ where: { terminationRequestId: resolvedId } });
|
||||
if (fnf) {
|
||||
await db.FffClearance.update(
|
||||
{ status: normalizedStatus, remarks, amount: Number(amount) || 0 },
|
||||
@ -943,6 +1005,12 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (fnf) {
|
||||
const { syncFnfClearanceSla } = await import('../../common/utils/slaFnfSync.js');
|
||||
await syncFnfClearanceSla(fnf.id, department, normalizedStatus);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Clearance updated for ${department}`, clearances });
|
||||
} catch (error) {
|
||||
if (transaction) await transaction.rollback();
|
||||
|
||||
@ -129,6 +129,13 @@ const seedTemplates = async () => {
|
||||
fileName: 'fnf_initiated.html',
|
||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'initiatedBy', 'lwd', 'link', 'ctaLabel']
|
||||
},
|
||||
{
|
||||
templateCode: 'FNF_LWD_READY',
|
||||
description: 'Notifies DD Admin / DD Lead when Last Working Day is reached and Push to F&F can be used',
|
||||
subject: 'LWD Reached — Initiate F&F for {{requestId}}',
|
||||
fileName: 'fnf_lwd_ready.html',
|
||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'requestType', 'lwd', 'link', 'ctaLabel']
|
||||
},
|
||||
{
|
||||
templateCode: 'FNF_SUMMARY_PREPARED',
|
||||
description: 'Notification to Finance team when F&F summary is ready for review',
|
||||
|
||||
@ -141,6 +141,32 @@ const seedMissingTemplates = async () => {
|
||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'lwd', 'initiatedBy', 'link']
|
||||
},
|
||||
|
||||
{
|
||||
templateCode: 'FNF_LWD_READY',
|
||||
description: 'Scheduled reminder to authorized admins when LWD is reached — use Push to F&F',
|
||||
subject: 'Last Working Day Reached — Push to F&F: {{requestId}}',
|
||||
body: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="background:#1e3a5f;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#fff;margin:0;font-size:20px;">Last Working Day Reached</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border:1px solid #eee;border-radius:0 0 8px 8px;">
|
||||
<p>Dear {{recipientName}},</p>
|
||||
<p>
|
||||
The Last Working Day (<strong>{{lwd}}</strong>) has been reached for
|
||||
<strong>{{requestType}}</strong> case <strong>{{requestId}}</strong> (dealer: <strong>{{dealerName}}</strong>).
|
||||
</p>
|
||||
<p>You may now initiate Full & Final settlement using <strong>Push to F&F</strong> on the case details page.</p>
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||
{{ctaLabel}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'requestType', 'lwd', 'link', 'ctaLabel']
|
||||
},
|
||||
|
||||
// ── 4. EOR Completed ───────────────────────────────────────────────────
|
||||
// Used in: eor.controller.ts (when all EOR checklist items are complete)
|
||||
// Recipients: DD-Head, NBH
|
||||
|
||||
@ -207,9 +207,19 @@ const startServer = async () => {
|
||||
if (process.env.ENABLE_REDIS === 'true') {
|
||||
const { notificationWorker } = await import('./common/queues/notification.worker.js');
|
||||
const { slaWorker } = await import('./common/queues/sla.worker.js');
|
||||
const { offboardingLwdWorker } = await import('./common/queues/offboarding-lwd.worker.js');
|
||||
const { questionnaireReminderWorker } = await import('./common/queues/questionnaire-reminder.worker.js');
|
||||
const { scheduleSLACheck } = await import('./common/queues/sla.queue.js');
|
||||
const { scheduleOffboardingLwdReminders } = await import('./common/queues/offboarding-lwd.queue.js');
|
||||
const { scheduleQuestionnaireReminders } = await import('./common/queues/questionnaire-reminder.queue.js');
|
||||
|
||||
await scheduleSLACheck(); // Register repeatable job
|
||||
await scheduleOffboardingLwdReminders();
|
||||
await scheduleQuestionnaireReminders();
|
||||
void notificationWorker;
|
||||
void slaWorker;
|
||||
void offboardingLwdWorker;
|
||||
void questionnaireReminderWorker;
|
||||
|
||||
logger.info('BullMQ Workers initialized and repeatable jobs scheduled');
|
||||
} else {
|
||||
|
||||
@ -4,6 +4,7 @@ import { NotificationService } from './NotificationService.js';
|
||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||
|
||||
export class ConstitutionalWorkflowService {
|
||||
private static normalizeDocLabel(input: string): string {
|
||||
@ -120,6 +121,13 @@ export class ConstitutionalWorkflowService {
|
||||
|
||||
await request.update(updateData);
|
||||
|
||||
await syncSlaOnStageTransition({
|
||||
entityType: 'constitutional',
|
||||
entityId: request.id,
|
||||
fromStage: sourceStage,
|
||||
toStage: targetStage
|
||||
});
|
||||
|
||||
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) {
|
||||
await ConstitutionalWorkflowService.syncDealerProfileConstitution(request, userId);
|
||||
}
|
||||
|
||||
251
src/services/OffboardingLwdReminderService.ts
Normal file
251
src/services/OffboardingLwdReminderService.ts
Normal file
@ -0,0 +1,251 @@
|
||||
import db from '../database/models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
import {
|
||||
RESIGNATION_STAGES,
|
||||
ROLES,
|
||||
TERMINATION_STAGES
|
||||
} from '../common/config/constants.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import {
|
||||
formatLwdDisplay,
|
||||
isLwdReached,
|
||||
LWD_FNF_READY_REMINDER_ACTION,
|
||||
msUntilLwdMorning
|
||||
} from '../common/utils/offboardingLwd.js';
|
||||
import logger from '../common/utils/logger.js';
|
||||
|
||||
const FNF_PUSH_ROLES = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN];
|
||||
|
||||
export type LwdReminderJobPayload = {
|
||||
requestType: 'resignation' | 'termination';
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export class OffboardingLwdReminderService {
|
||||
static async wasReminderSent(requestType: 'resignation' | 'termination', entityId: string): Promise<boolean> {
|
||||
if (requestType === 'resignation') {
|
||||
const row = await db.ResignationAudit.findOne({
|
||||
where: { resignationId: entityId, action: LWD_FNF_READY_REMINDER_ACTION },
|
||||
attributes: ['id']
|
||||
});
|
||||
return !!row;
|
||||
}
|
||||
const row = await db.TerminationAudit.findOne({
|
||||
where: { terminationRequestId: entityId, action: LWD_FNF_READY_REMINDER_ACTION },
|
||||
attributes: ['id']
|
||||
});
|
||||
return !!row;
|
||||
}
|
||||
|
||||
static async notifyAdminsToPushFnf(params: {
|
||||
requestType: 'resignation' | 'termination';
|
||||
caseCode: string;
|
||||
entityId: string;
|
||||
dealerName: string;
|
||||
lwd: Date | string;
|
||||
detailPath: string;
|
||||
}): Promise<void> {
|
||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const lwdStr = formatLwdDisplay(params.lwd);
|
||||
const link = `${portalBase}${params.detailPath}`;
|
||||
|
||||
const admins = await db.User.findAll({
|
||||
where: { roleCode: { [Op.in]: FNF_PUSH_ROLES }, isActive: true },
|
||||
attributes: ['id', 'email', 'fullName', 'mobileNumber']
|
||||
});
|
||||
|
||||
for (const admin of admins) {
|
||||
const phone = (admin as any).mobileNumber || '';
|
||||
await NotificationService.notify(admin.id, admin.email, {
|
||||
title: `LWD reached — Push to F&F: ${params.caseCode}`,
|
||||
message: `Last Working Day (${lwdStr}) has been reached for ${params.dealerName}. You may now initiate Full & Final settlement using Push to F&F.`,
|
||||
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
||||
templateCode: 'FNF_LWD_READY',
|
||||
placeholders: {
|
||||
recipientName: admin.fullName || 'Team Member',
|
||||
dealerName: params.dealerName,
|
||||
requestId: params.caseCode,
|
||||
requestType: params.requestType === 'resignation' ? 'Resignation' : 'Termination',
|
||||
lwd: lwdStr,
|
||||
link,
|
||||
ctaLabel: 'Open case & Push to F&F',
|
||||
phone: String(phone || '')
|
||||
}
|
||||
}).catch((e: unknown) =>
|
||||
logger.error('[OffboardingLwdReminder] notify failed:', e)
|
||||
);
|
||||
}
|
||||
|
||||
if (params.requestType === 'resignation') {
|
||||
await db.ResignationAudit.create({
|
||||
userId: null,
|
||||
resignationId: params.entityId,
|
||||
action: LWD_FNF_READY_REMINDER_ACTION,
|
||||
remarks: `Admin notified: LWD (${lwdStr}) reached — Push to F&F is available.`,
|
||||
details: { lwd: lwdStr, link }
|
||||
});
|
||||
} else {
|
||||
await db.TerminationAudit.create({
|
||||
userId: null,
|
||||
terminationRequestId: params.entityId,
|
||||
action: LWD_FNF_READY_REMINDER_ACTION,
|
||||
remarks: `Admin notified: LWD (${lwdStr}) reached — Push to F&F is available.`,
|
||||
details: { lwd: lwdStr, link }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Process a single resignation or termination (from delayed job or batch). */
|
||||
static async processOne(payload: LwdReminderJobPayload): Promise<void> {
|
||||
if (payload.requestType === 'resignation') {
|
||||
await this.processResignation(payload.requestId);
|
||||
} else {
|
||||
await this.processTermination(payload.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
static async processResignation(resignationId: string): Promise<void> {
|
||||
const resignation = await db.Resignation.findByPk(resignationId, {
|
||||
include: [
|
||||
{ model: db.User, as: 'dealer', attributes: ['id', 'fullName'] },
|
||||
{ model: db.Outlet, as: 'outlet', attributes: ['name'] }
|
||||
]
|
||||
});
|
||||
if (!resignation) return;
|
||||
if (resignation.currentStage !== RESIGNATION_STAGES.AWAITING_FNF) return;
|
||||
|
||||
const existingFnf = await db.FnF.findOne({ where: { resignationId: resignation.id } });
|
||||
if (existingFnf) return;
|
||||
if (await this.wasReminderSent('resignation', resignation.id)) return;
|
||||
|
||||
const lwd =
|
||||
resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||
if (!isLwdReached(lwd)) return;
|
||||
|
||||
const dealerName =
|
||||
(resignation as any).dealer?.fullName ||
|
||||
(resignation as any).outlet?.name ||
|
||||
resignation.resignationId;
|
||||
|
||||
await this.notifyAdminsToPushFnf({
|
||||
requestType: 'resignation',
|
||||
caseCode: resignation.resignationId,
|
||||
entityId: resignation.id,
|
||||
dealerName,
|
||||
lwd: lwd!,
|
||||
detailPath: `/dealer-resignation/${resignation.id}`
|
||||
});
|
||||
}
|
||||
|
||||
static async processTermination(terminationId: string): Promise<void> {
|
||||
const termination = await db.TerminationRequest.findByPk(terminationId, {
|
||||
include: [{ model: db.Dealer, as: 'dealer', attributes: ['legalName', 'businessName'] }]
|
||||
});
|
||||
if (!termination) return;
|
||||
|
||||
const awaitingFnf =
|
||||
termination.currentStage === TERMINATION_STAGES.TERMINATED ||
|
||||
termination.currentStage === TERMINATION_STAGES.LEGAL_LETTER;
|
||||
if (!awaitingFnf) return;
|
||||
|
||||
const status = String(termination.status || '');
|
||||
if (status === 'F&F Initiated' || status === 'FNF_INITIATED' || status === 'Settled') return;
|
||||
|
||||
const existingFnf = await db.FnF.findOne({
|
||||
where: { terminationRequestId: termination.id }
|
||||
});
|
||||
if (existingFnf) return;
|
||||
if (await this.wasReminderSent('termination', termination.id)) return;
|
||||
|
||||
if (!isLwdReached(termination.proposedLwd)) return;
|
||||
|
||||
if (status === 'Awaiting F&F (LWD Pending)') {
|
||||
await termination.update({ status: 'Awaiting F&F' });
|
||||
}
|
||||
|
||||
const dealerName =
|
||||
(termination as any).dealer?.businessName ||
|
||||
(termination as any).dealer?.legalName ||
|
||||
termination.requestId;
|
||||
|
||||
await this.notifyAdminsToPushFnf({
|
||||
requestType: 'termination',
|
||||
caseCode: termination.requestId,
|
||||
entityId: termination.id,
|
||||
dealerName,
|
||||
lwd: termination.proposedLwd,
|
||||
detailPath: `/termination/${termination.id}`
|
||||
});
|
||||
}
|
||||
|
||||
/** Daily / batch sweep for any cases that missed a delayed job. */
|
||||
static async processAllPendingReminders(): Promise<{ resignation: number; termination: number }> {
|
||||
let resignationCount = 0;
|
||||
let terminationCount = 0;
|
||||
|
||||
const resignations = await db.Resignation.findAll({
|
||||
where: { currentStage: RESIGNATION_STAGES.AWAITING_FNF },
|
||||
attributes: ['id']
|
||||
});
|
||||
for (const r of resignations) {
|
||||
const before = await this.wasReminderSent('resignation', r.id);
|
||||
await this.processResignation(r.id);
|
||||
const after = await this.wasReminderSent('resignation', r.id);
|
||||
if (!before && after) resignationCount++;
|
||||
}
|
||||
|
||||
const terminations = await db.TerminationRequest.findAll({
|
||||
where: {
|
||||
currentStage: { [Op.in]: [TERMINATION_STAGES.TERMINATED, TERMINATION_STAGES.LEGAL_LETTER] },
|
||||
status: {
|
||||
[Op.in]: ['Awaiting F&F', 'Awaiting F&F (LWD Pending)', 'Terminated']
|
||||
}
|
||||
},
|
||||
attributes: ['id']
|
||||
});
|
||||
for (const t of terminations) {
|
||||
const before = await this.wasReminderSent('termination', t.id);
|
||||
await this.processTermination(t.id);
|
||||
const after = await this.wasReminderSent('termination', t.id);
|
||||
if (!before && after) terminationCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[OffboardingLwdReminder] Batch complete: ${resignationCount} resignation, ${terminationCount} termination reminders sent`
|
||||
);
|
||||
return { resignation: resignationCount, termination: terminationCount };
|
||||
}
|
||||
|
||||
/** Queue a delayed reminder at LWD morning (no-op if LWD already passed — batch job will pick up). */
|
||||
static async scheduleDelayedReminderIfNeeded(
|
||||
payload: LwdReminderJobPayload,
|
||||
lwd: Date | string
|
||||
): Promise<void> {
|
||||
if (process.env.ENABLE_REDIS !== 'true') return;
|
||||
|
||||
const delay = msUntilLwdMorning(lwd);
|
||||
if (delay <= 0) {
|
||||
await this.processOne(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { offboardingLwdQueue } = await import('../common/queues/offboarding-lwd.queue.js');
|
||||
const jobId = `lwd-fnf-${payload.requestType}-${payload.requestId}`;
|
||||
const existing = await offboardingLwdQueue.getJob(jobId);
|
||||
if (existing) await existing.remove();
|
||||
|
||||
await offboardingLwdQueue.add('sendLwdFnfReminder', payload, {
|
||||
jobId,
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
});
|
||||
logger.info(
|
||||
`[OffboardingLwdReminder] Scheduled reminder in ${Math.round(delay / 3600000)}h for ${payload.requestType} ${payload.requestId}`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[OffboardingLwdReminder] Failed to schedule delayed job:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/services/QuestionnaireReminderService.ts
Normal file
176
src/services/QuestionnaireReminderService.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import db from '../database/models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { APPLICATION_STATUS, AUDIT_ACTIONS } from '../common/config/constants.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { safeAuditLogCreate } from './applicationAuditLog.service.js';
|
||||
import { pickApplicationAuditContext } from './applicationAuditLog.service.js';
|
||||
import logger from '../common/utils/logger.js';
|
||||
import { getQuestionnaireReminderSettings } from './questionnaireReminderSettings.js';
|
||||
|
||||
const TEMPLATE = 'QUESTIONNAIRE_REMINDER';
|
||||
|
||||
export class QuestionnaireReminderService {
|
||||
static async isEnabled(): Promise<boolean> {
|
||||
return (await getQuestionnaireReminderSettings()).enabled;
|
||||
}
|
||||
|
||||
static async getIntervalDays(): Promise<number> {
|
||||
return (await getQuestionnaireReminderSettings()).intervalDays;
|
||||
}
|
||||
|
||||
static async getFirstReminderAfterDays(): Promise<number> {
|
||||
return (await getQuestionnaireReminderSettings()).firstAfterDays;
|
||||
}
|
||||
|
||||
static async getMaxReminders(): Promise<number> {
|
||||
return (await getQuestionnaireReminderSettings()).maxCount;
|
||||
}
|
||||
|
||||
static async getPendingSince(applicationId: string, fallbackCreatedAt: Date): Promise<Date> {
|
||||
const row = await db.ApplicationStatusHistory.findOne({
|
||||
where: {
|
||||
applicationId,
|
||||
newStatus: APPLICATION_STATUS.QUESTIONNAIRE_PENDING
|
||||
},
|
||||
order: [['createdAt', 'ASC']],
|
||||
attributes: ['createdAt']
|
||||
});
|
||||
return row?.createdAt ? new Date(row.createdAt) : new Date(fallbackCreatedAt);
|
||||
}
|
||||
|
||||
static async countQuestionnaireRemindersSent(applicationId: string): Promise<number> {
|
||||
const logs = await db.AuditLog.findAll({
|
||||
where: {
|
||||
entityType: 'application',
|
||||
entityId: applicationId,
|
||||
action: AUDIT_ACTIONS.REMINDER_SENT
|
||||
},
|
||||
attributes: ['newData', 'createdAt'],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
return logs.filter((log: any) => log.newData?.template === TEMPLATE).length;
|
||||
}
|
||||
|
||||
static async getLastQuestionnaireReminderAt(applicationId: string): Promise<Date | null> {
|
||||
const logs = await db.AuditLog.findAll({
|
||||
where: {
|
||||
entityType: 'application',
|
||||
entityId: applicationId,
|
||||
action: AUDIT_ACTIONS.REMINDER_SENT
|
||||
},
|
||||
attributes: ['newData', 'createdAt'],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 20
|
||||
});
|
||||
const hit = logs.find((log: any) => log.newData?.template === TEMPLATE);
|
||||
return hit?.createdAt ? new Date(hit.createdAt) : null;
|
||||
}
|
||||
|
||||
static async sendReminderForApplication(application: any, source: 'scheduled' | 'manual' = 'scheduled') {
|
||||
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const link = `${base}/questionnaire/${application.applicationId}`;
|
||||
|
||||
await NotificationService.sendQuestionnaireReminder(
|
||||
application.email,
|
||||
application.phone || application.mobileNumber,
|
||||
application.applicantName,
|
||||
{
|
||||
location: application.preferredLocation,
|
||||
link
|
||||
}
|
||||
);
|
||||
|
||||
await safeAuditLogCreate({
|
||||
userId: null,
|
||||
action: AUDIT_ACTIONS.REMINDER_SENT,
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
newData: {
|
||||
template: TEMPLATE,
|
||||
sentAt: new Date().toISOString(),
|
||||
source,
|
||||
context: pickApplicationAuditContext(application)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BullMQ sweep: email/WhatsApp prospects still in Questionnaire Pending.
|
||||
* Not part of internal SLA matrix — uses same template as DD Admin bulk reminder.
|
||||
*/
|
||||
static async processScheduledReminders(): Promise<{ scanned: number; sent: number; skipped: number }> {
|
||||
if (!(await this.isEnabled())) {
|
||||
logger.info('[QuestionnaireReminder] Scheduler disabled');
|
||||
return { scanned: 0, sent: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const intervalDays = await this.getIntervalDays();
|
||||
const firstAfterDays = await this.getFirstReminderAfterDays();
|
||||
const maxReminders = await this.getMaxReminders();
|
||||
const now = Date.now();
|
||||
const msDay = 24 * 60 * 60 * 1000;
|
||||
|
||||
const applications = await db.Application.findAll({
|
||||
where: {
|
||||
overallStatus: APPLICATION_STATUS.QUESTIONNAIRE_PENDING,
|
||||
email: { [Op.ne]: null }
|
||||
},
|
||||
attributes: [
|
||||
'id',
|
||||
'applicationId',
|
||||
'email',
|
||||
'phone',
|
||||
'mobileNumber',
|
||||
'applicantName',
|
||||
'preferredLocation',
|
||||
'createdAt'
|
||||
]
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const app of applications) {
|
||||
const pendingSince = await this.getPendingSince(app.id, app.createdAt);
|
||||
const daysPending = (now - pendingSince.getTime()) / msDay;
|
||||
|
||||
if (daysPending < firstAfterDays) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reminderCount = await this.countQuestionnaireRemindersSent(app.id);
|
||||
if (reminderCount >= maxReminders) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastAt = await this.getLastQuestionnaireReminderAt(app.id);
|
||||
if (lastAt) {
|
||||
const daysSinceLast = (now - lastAt.getTime()) / msDay;
|
||||
if (daysSinceLast < intervalDays) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendReminderForApplication(app, 'scheduled');
|
||||
sent++;
|
||||
logger.info(
|
||||
`[QuestionnaireReminder] Sent to ${app.applicationId} (${app.email}) — reminder #${reminderCount + 1}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
skipped++;
|
||||
logger.error(
|
||||
`[QuestionnaireReminder] Failed for ${app.applicationId}: ${err?.message || err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[QuestionnaireReminder] Sweep complete: scanned=${applications.length} sent=${sent} skipped=${skipped}`
|
||||
);
|
||||
return { scanned: applications.length, sent, skipped };
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
|
||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||
import logger from '../common/utils/logger.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||
|
||||
export class RelocationWorkflowService {
|
||||
/**
|
||||
@ -48,6 +49,16 @@ export class RelocationWorkflowService {
|
||||
const updatedTimeline = [...(request.timeline || []), timelineEntry];
|
||||
await request.update({ timeline: updatedTimeline });
|
||||
|
||||
const toStage = updateData.currentStage;
|
||||
if (toStage && toStage !== sourceStage) {
|
||||
await syncSlaOnStageTransition({
|
||||
entityType: 'relocation',
|
||||
entityId: request.id,
|
||||
fromStage: sourceStage,
|
||||
toStage
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Create Audit Log using standardized mapper
|
||||
const { actionType } = metadata;
|
||||
let resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RELOCATION);
|
||||
|
||||
@ -8,6 +8,7 @@ import logger from '../common/utils/logger.js';
|
||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||
|
||||
|
||||
export class ResignationWorkflowService {
|
||||
@ -45,6 +46,13 @@ export class ResignationWorkflowService {
|
||||
timeline: updatedTimeline
|
||||
}, transaction ? { transaction } : undefined);
|
||||
|
||||
await syncSlaOnStageTransition({
|
||||
entityType: 'resignation',
|
||||
entityId: resignation.id,
|
||||
fromStage: sourceStage,
|
||||
toStage: targetStage
|
||||
});
|
||||
|
||||
// 3. Create Audit Log using standardized mapper
|
||||
const { actionType } = metadata;
|
||||
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RESIGNATION);
|
||||
@ -214,6 +222,9 @@ export class ResignationWorkflowService {
|
||||
);
|
||||
await Promise.all(clearancePromises);
|
||||
|
||||
const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js');
|
||||
await startAllPendingFnfClearanceSlas(fnf.id);
|
||||
|
||||
// 3. Create Audit Trail
|
||||
await db.FnFAudit.create({
|
||||
userId,
|
||||
|
||||
@ -1,324 +1,461 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db;
|
||||
import { Op } from 'sequelize';
|
||||
import { slaConfigLookupNames } from '../common/config/slaStageCatalog.js';
|
||||
import { effectiveElapsedMs } from '../common/utils/slaBusinessTime.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { resolveRecipientsForRoles } from './slaGeographyResolver.js';
|
||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||
import type { WorkflowActivityRequestType } from '../common/utils/workflowWorknote.js';
|
||||
|
||||
export type SlaTrackRef = {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
applicationId?: string | null;
|
||||
stageName: string;
|
||||
};
|
||||
|
||||
export class SLAService {
|
||||
/**
|
||||
* Periodically check for SLA breaches, reminders and escalations
|
||||
*/
|
||||
static async checkBreaches() {
|
||||
console.log('[SLA Service] Starting SLA status check...');
|
||||
const now = new Date();
|
||||
|
||||
// 1. Handle Active Tracks (Reminders and Initial Breach)
|
||||
const activeTracking = await SLATracking.findAll({
|
||||
where: { isActive: true, endTime: null },
|
||||
include: [{ model: Application, as: 'application' }]
|
||||
private static async findConfigForStage(stageName: string) {
|
||||
const names = slaConfigLookupNames(stageName);
|
||||
return SLAConfiguration.findOne({
|
||||
where: { activityName: { [Op.in]: names }, isActive: true }
|
||||
});
|
||||
}
|
||||
|
||||
private static resolveTrackRef(
|
||||
refOrAppId: string | SlaTrackRef,
|
||||
stageName?: string
|
||||
): SlaTrackRef {
|
||||
if (typeof refOrAppId === 'string') {
|
||||
return {
|
||||
entityType: 'application',
|
||||
entityId: refOrAppId,
|
||||
applicationId: refOrAppId,
|
||||
stageName: stageName!
|
||||
};
|
||||
}
|
||||
return refOrAppId;
|
||||
}
|
||||
|
||||
static async checkBreaches() {
|
||||
console.log('[SLA Service] Starting SLA status check...');
|
||||
const now = new Date();
|
||||
|
||||
const activeTracking = await SLATracking.findAll({
|
||||
where: { isActive: true, endTime: null },
|
||||
include: [{ model: Application, as: 'application', required: false }]
|
||||
});
|
||||
|
||||
for (const track of activeTracking) {
|
||||
const config = await this.findConfigForStage(track.stageName);
|
||||
const configWithChildren = config
|
||||
? await SLAConfiguration.findByPk(config.id, {
|
||||
include: [
|
||||
{ model: SLAReminder, as: 'reminders' },
|
||||
{ model: SLAEscalationConfig, as: 'escalationConfigs' }
|
||||
]
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!configWithChildren) continue;
|
||||
const configResolved = configWithChildren;
|
||||
|
||||
const meta = track.metadata || {};
|
||||
if (meta.pausedAt) continue;
|
||||
|
||||
const tatMs = this.getTatInMs(configResolved.tatHours, configResolved.tatUnit);
|
||||
const elapsedMs = effectiveElapsedMs(track, now.getTime());
|
||||
const deadline = new Date(new Date(track.startTime).getTime() + tatMs);
|
||||
|
||||
if (!track.isBreached && elapsedMs < tatMs) {
|
||||
await this.processReminders(track, configResolved, now, deadline, tatMs, elapsedMs);
|
||||
} else if (!track.isBreached && elapsedMs >= tatMs) {
|
||||
await this.triggerBreach(track, now);
|
||||
} else if (track.isBreached) {
|
||||
await this.processEscalations(track, configResolved, now, deadline);
|
||||
await this.processRepeatOverdueReminder(track, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async startTrack(refOrAppId: string | SlaTrackRef, stageName?: string) {
|
||||
const ref = this.resolveTrackRef(refOrAppId, stageName);
|
||||
console.log(
|
||||
`[SLA Service] Starting SLA track for ${ref.entityType}:${ref.entityId}, Stage: ${ref.stageName}`
|
||||
);
|
||||
|
||||
const deactivateWhere: Record<string, unknown> = {
|
||||
entityId: ref.entityId,
|
||||
isActive: true,
|
||||
endTime: null
|
||||
};
|
||||
// F&F: multiple department timers run in parallel on the same settlement
|
||||
if (ref.entityType === 'fnf') {
|
||||
deactivateWhere.stageName = ref.stageName;
|
||||
}
|
||||
await SLATracking.update({ isActive: false, endTime: new Date() }, { where: deactivateWhere });
|
||||
|
||||
const config = await this.findConfigForStage(ref.stageName);
|
||||
|
||||
if (config) {
|
||||
await SLATracking.create({
|
||||
applicationId: ref.applicationId ?? null,
|
||||
entityType: ref.entityType,
|
||||
entityId: ref.entityId,
|
||||
stageName: ref.stageName,
|
||||
startTime: new Date(),
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Pause all active SLA timers for an entity (e.g. case On Hold). */
|
||||
static async pauseEntityTracks(entityType: string, entityId: string) {
|
||||
const tracks = await SLATracking.findAll({
|
||||
where: { entityType, entityId, isActive: true, endTime: null }
|
||||
});
|
||||
const pausedAt = new Date().toISOString();
|
||||
for (const track of tracks) {
|
||||
const meta = { ...(track.metadata || {}), pausedAt };
|
||||
await track.update({ metadata: meta });
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume paused SLA timers after hold / waiting period ends. */
|
||||
static async resumeEntityTracks(entityType: string, entityId: string) {
|
||||
const tracks = await SLATracking.findAll({
|
||||
where: { entityType, entityId, isActive: true, endTime: null }
|
||||
});
|
||||
const now = Date.now();
|
||||
for (const track of tracks) {
|
||||
const meta = { ...(track.metadata || {}) };
|
||||
if (!meta.pausedAt) continue;
|
||||
const pauseStart = new Date(String(meta.pausedAt)).getTime();
|
||||
meta.accumulatedPauseMs = Number(meta.accumulatedPauseMs || 0) + (now - pauseStart);
|
||||
delete meta.pausedAt;
|
||||
await track.update({ metadata: meta });
|
||||
}
|
||||
}
|
||||
|
||||
static async stopTrack(refOrAppId: string | SlaTrackRef, stageName?: string) {
|
||||
const ref = this.resolveTrackRef(refOrAppId, stageName);
|
||||
console.log(
|
||||
`[SLA Service] Stopping SLA track for ${ref.entityType}:${ref.entityId}, Stage: ${ref.stageName}`
|
||||
);
|
||||
const stageNames = slaConfigLookupNames(ref.stageName);
|
||||
await SLATracking.update(
|
||||
{ isActive: false, endTime: new Date() },
|
||||
{
|
||||
where: {
|
||||
entityId: ref.entityId,
|
||||
stageName: { [Op.in]: stageNames },
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static getTatInMs(value: number, unit: string): number {
|
||||
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
|
||||
factor = factor / 60;
|
||||
}
|
||||
return value * factor;
|
||||
}
|
||||
|
||||
private static async getCaseLabel(track: any): Promise<string> {
|
||||
if (track.application?.applicationId) return track.application.applicationId;
|
||||
const type = track.entityType;
|
||||
const id = track.entityId;
|
||||
if (type === 'termination') {
|
||||
const row = await db.TerminationRequest.findByPk(id, { attributes: ['requestId'] });
|
||||
return row?.requestId || id;
|
||||
}
|
||||
if (type === 'resignation') {
|
||||
const row = await db.Resignation.findByPk(id, { attributes: ['resignationId'] });
|
||||
return row?.resignationId || id;
|
||||
}
|
||||
if (type === 'relocation') {
|
||||
const row = await db.RelocationRequest.findByPk(id, { attributes: ['requestId'] });
|
||||
return row?.requestId || id;
|
||||
}
|
||||
if (type === 'constitutional') {
|
||||
const row = await db.ConstitutionalChange.findByPk(id, { attributes: ['requestId'] });
|
||||
return row?.requestId || id;
|
||||
}
|
||||
if (type === 'fnf') {
|
||||
const row = await db.FnF.findByPk(id, { attributes: ['settlementId'] });
|
||||
return row?.settlementId || id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private static async processRepeatOverdueReminder(track: any, now: Date) {
|
||||
const meta = { ...(track.metadata || {}) };
|
||||
let repeatMs = 24 * 60 * 60 * 1000;
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
|
||||
repeatMs = 60 * 1000;
|
||||
}
|
||||
const last = meta.lastOverdueReminderAt ? new Date(String(meta.lastOverdueReminderAt)).getTime() : 0;
|
||||
if (now.getTime() - last < repeatMs) return;
|
||||
|
||||
const caseLabel = await this.getCaseLabel(track);
|
||||
await this.notifyStakeholder(track, 'SLA_REMINDER', {
|
||||
title: `SLA Still Overdue: ${track.stageName}`,
|
||||
message: `Case ${caseLabel} remains overdue for ${track.stageName}. Please close or escalate.`
|
||||
});
|
||||
await this.logSlaActivity(track, `[SLA] Repeat overdue reminder: ${track.stageName} still open.`);
|
||||
meta.lastOverdueReminderAt = now.toISOString();
|
||||
await track.update({ metadata: meta });
|
||||
}
|
||||
|
||||
private static async processReminders(
|
||||
track: any,
|
||||
config: any,
|
||||
now: Date,
|
||||
deadline: Date,
|
||||
tatMs: number,
|
||||
elapsedMs: number
|
||||
) {
|
||||
const msRemaining = Math.max(0, tatMs - elapsedMs);
|
||||
const caseLabel = await this.getCaseLabel(track);
|
||||
|
||||
for (const reminder of config.reminders || []) {
|
||||
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
|
||||
|
||||
if (msRemaining <= reminderMs) {
|
||||
const metadata = track.metadata || {};
|
||||
const reminderKey = `reminder_sent_${reminder.id}`;
|
||||
if (metadata[reminderKey]) continue;
|
||||
|
||||
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
|
||||
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
|
||||
|
||||
await this.notifyStakeholder(track, 'SLA_REMINDER', {
|
||||
title: `SLA Reminder: ${track.stageName}`,
|
||||
message: `Case ${caseLabel} is approaching its SLA deadline for ${track.stageName}.`
|
||||
});
|
||||
|
||||
for (const track of activeTracking) {
|
||||
const config = await SLAConfiguration.findOne({
|
||||
where: { activityName: track.stageName, isActive: true },
|
||||
include: [
|
||||
{ model: SLAReminder, as: 'reminders' },
|
||||
{ model: SLAEscalationConfig, as: 'escalationConfigs' }
|
||||
]
|
||||
});
|
||||
|
||||
if (!config) continue;
|
||||
|
||||
const startTime = new Date(track.startTime);
|
||||
const tatMs = this.getTatInMs(config.tatHours, config.tatUnit);
|
||||
const deadline = new Date(startTime.getTime() + tatMs);
|
||||
|
||||
// CASE A: Not Breached Yet - Check Reminders
|
||||
if (!track.isBreached && now < deadline) {
|
||||
await this.processReminders(track, config, now, deadline);
|
||||
}
|
||||
// CASE B: Just Breached - Mark it and trigger Level 1 Escalation
|
||||
else if (!track.isBreached && now >= deadline) {
|
||||
await this.triggerBreach(track, now);
|
||||
}
|
||||
// CASE C: Already Breached - Check for Escalations
|
||||
else if (track.isBreached) {
|
||||
await this.processEscalations(track, config, now, deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking SLA for a new stage
|
||||
*/
|
||||
static async startTrack(applicationId: string, stageName: string) {
|
||||
console.log(`[SLA Service] Starting SLA track for App: ${applicationId}, Stage: ${stageName}`);
|
||||
|
||||
// Ensure NO other active tracks for this application exist
|
||||
await SLATracking.update(
|
||||
{ isActive: false, endTime: new Date() },
|
||||
{ where: { applicationId, isActive: true, endTime: null } }
|
||||
await this.logSlaActivity(
|
||||
track,
|
||||
`[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`
|
||||
);
|
||||
|
||||
const config = await SLAConfiguration.findOne({
|
||||
where: { activityName: stageName, isActive: true }
|
||||
});
|
||||
|
||||
if (config) {
|
||||
await SLATracking.create({
|
||||
applicationId,
|
||||
stageName,
|
||||
startTime: new Date(),
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
metadata[reminderKey] = true;
|
||||
await track.update({ metadata });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking SLA for a stage
|
||||
*/
|
||||
static async stopTrack(applicationId: string, stageName: string) {
|
||||
console.log(`[SLA Service] Stopping SLA track for App: ${applicationId}, Stage: ${stageName}`);
|
||||
await SLATracking.update(
|
||||
{ isActive: false, endTime: new Date() },
|
||||
{ where: { applicationId, stageName, isActive: true } }
|
||||
private static async triggerBreach(track: any, now: Date) {
|
||||
const caseLabel = await this.getCaseLabel(track);
|
||||
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${caseLabel}`);
|
||||
await track.update({ isBreached: true });
|
||||
|
||||
await SLABreach.create({
|
||||
trackingId: track.id,
|
||||
breachedAt: now,
|
||||
status: 'Open'
|
||||
});
|
||||
|
||||
await this.notifyStakeholder(track, 'SLA_BREACH', {
|
||||
title: `SLA BREACHED: ${track.stageName}`,
|
||||
message: `Case ${caseLabel} has breached its SLA for ${track.stageName}.`
|
||||
});
|
||||
|
||||
await this.logSlaActivity(
|
||||
track,
|
||||
`[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`
|
||||
);
|
||||
}
|
||||
|
||||
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
|
||||
const msSinceBreach = now.getTime() - deadline.getTime();
|
||||
const caseLabel = await this.getCaseLabel(track);
|
||||
|
||||
for (const esc of config.escalationConfigs || []) {
|
||||
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
|
||||
|
||||
if (msSinceBreach >= escMs) {
|
||||
const metadata = track.metadata || {};
|
||||
const escKey = `esc_sent_L${esc.level}`;
|
||||
if (metadata[escKey]) continue;
|
||||
|
||||
console.log(
|
||||
`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'})`
|
||||
);
|
||||
}
|
||||
|
||||
private static getTatInMs(value: number, unit: string): number {
|
||||
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
||||
|
||||
// Debug Mode: 1 hour = 1 minute (60x speedup)
|
||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
|
||||
factor = factor / 60;
|
||||
const recipientIds: string[] = [];
|
||||
if (esc.notifyRole) {
|
||||
recipientIds.push(
|
||||
...(await resolveRecipientsForRoles(
|
||||
{
|
||||
entityType: track.entityType,
|
||||
entityId: track.entityId,
|
||||
applicationId: track.applicationId
|
||||
},
|
||||
[esc.notifyRole]
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return value * factor;
|
||||
}
|
||||
|
||||
private static async processReminders(track: any, config: any, now: Date, deadline: Date) {
|
||||
const msRemaining = deadline.getTime() - now.getTime();
|
||||
|
||||
for (const reminder of config.reminders || []) {
|
||||
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
|
||||
|
||||
if (msRemaining <= reminderMs) {
|
||||
const metadata = track.metadata || {};
|
||||
const reminderKey = `reminder_sent_${reminder.id}`;
|
||||
|
||||
if (!metadata[reminderKey]) {
|
||||
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
|
||||
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
|
||||
|
||||
await this.notifyStakeholder(track, 'SLA_REMINDER', {
|
||||
title: `SLA Reminder: ${track.stageName}`,
|
||||
message: `The application ${track.application?.applicationId} is approaching its SLA deadline for ${track.stageName}.`
|
||||
});
|
||||
|
||||
// §9.4.1 — Auto-log in Work Notes
|
||||
await this.logWorkNote(track.applicationId, `[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`);
|
||||
|
||||
metadata[reminderKey] = true;
|
||||
await track.update({ metadata });
|
||||
}
|
||||
if (recipientIds.length === 0 && esc.notifyEmail) {
|
||||
await NotificationService.notify(null, esc.notifyEmail, {
|
||||
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
|
||||
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
|
||||
channels: ['email', 'system'],
|
||||
templateCode: 'SLA_ESCALATION',
|
||||
placeholders: {
|
||||
applicationId: caseLabel,
|
||||
stageName: track.stageName,
|
||||
level: esc.level,
|
||||
timeValue: esc.timeValue,
|
||||
timeUnit: esc.timeUnit,
|
||||
phone: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async triggerBreach(track: any, now: Date) {
|
||||
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${track.application?.applicationId}`);
|
||||
await track.update({ isBreached: true });
|
||||
|
||||
await SLABreach.create({
|
||||
trackingId: track.id,
|
||||
applicationId: track.applicationId,
|
||||
stageCode: track.stageName,
|
||||
breachedAt: now,
|
||||
severity: 'High',
|
||||
status: 'Open'
|
||||
for (const recipientId of recipientIds) {
|
||||
const user = await User.findByPk(recipientId);
|
||||
if (!user?.email) continue;
|
||||
const phone = user.mobileNumber || user.phone;
|
||||
await NotificationService.notify(recipientId, user.email, {
|
||||
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
|
||||
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
|
||||
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
||||
templateCode: 'SLA_ESCALATION',
|
||||
placeholders: {
|
||||
applicationId: caseLabel,
|
||||
stageName: track.stageName,
|
||||
level: esc.level,
|
||||
timeValue: esc.timeValue,
|
||||
timeUnit: esc.timeUnit,
|
||||
phone: phone || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.logSlaActivity(
|
||||
track,
|
||||
`[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyRole || esc.notifyEmail || 'supervisor'} for stage ${track.stageName}.`
|
||||
);
|
||||
|
||||
metadata[escKey] = true;
|
||||
await track.update({ metadata });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async logSlaActivity(track: any, text: string) {
|
||||
const admin =
|
||||
(await User.findOne({ where: { roleCode: 'Super Admin', status: 'active' } })) ||
|
||||
(await User.findOne());
|
||||
|
||||
if (!admin) return;
|
||||
|
||||
if (track.entityType === 'application' && track.applicationId) {
|
||||
try {
|
||||
await db.Worknote.create({
|
||||
applicationId: track.applicationId,
|
||||
userId: admin.id,
|
||||
noteText: text,
|
||||
noteType: 'system',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
await this.notifyStakeholder(track, 'SLA_BREACH', {
|
||||
title: `SLA BREACHED: ${track.stageName}`,
|
||||
message: `The application ${track.application?.applicationId} has breached its SLA for ${track.stageName}.`
|
||||
});
|
||||
|
||||
// §9.4.1 — Auto-log in Work Notes
|
||||
await this.logWorkNote(track.applicationId, `[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`);
|
||||
} catch (err) {
|
||||
console.error('[SLA Service] Failed to log application work note:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
|
||||
const msSinceBreach = now.getTime() - deadline.getTime();
|
||||
const requestTypeMap: Record<string, WorkflowActivityRequestType | undefined> = {
|
||||
termination: 'termination',
|
||||
resignation: 'resignation',
|
||||
relocation: 'relocation',
|
||||
constitutional: 'constitutional',
|
||||
fnf: 'fnf'
|
||||
};
|
||||
const requestType = requestTypeMap[track.entityType];
|
||||
if (!requestType) return;
|
||||
|
||||
for (const esc of config.escalationConfigs || []) {
|
||||
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
|
||||
|
||||
if (msSinceBreach >= escMs) {
|
||||
const metadata = track.metadata || {};
|
||||
const escKey = `esc_sent_L${esc.level}`;
|
||||
|
||||
if (!metadata[escKey]) {
|
||||
console.log(`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'}, Email: ${esc.notifyEmail || 'N/A'})`);
|
||||
|
||||
const { User, Application, District, Region, Zone } = db;
|
||||
let targetEmail = esc.notifyEmail;
|
||||
let recipientId = null;
|
||||
|
||||
// Runtime Resolution: Resolve role to a specific user/email if role is provided
|
||||
if (esc.notifyRole) {
|
||||
const app = await Application.findByPk(track.applicationId, {
|
||||
include: [{
|
||||
model: District,
|
||||
as: 'district',
|
||||
include: [
|
||||
{ model: Region, as: 'region' },
|
||||
{ model: Zone, as: 'zone' }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
if (app?.district) {
|
||||
const d = app.district;
|
||||
const r = d.region;
|
||||
const z = d.zone;
|
||||
|
||||
// Map geography-bound roles
|
||||
const roleMap: Record<string, string | null> = {
|
||||
'ASM': d.asmId,
|
||||
'DD-ZM': d.zmId,
|
||||
'RBM': r?.rbmId || null,
|
||||
'ZBH': z?.zbhId || null
|
||||
};
|
||||
|
||||
if (roleMap[esc.notifyRole]) {
|
||||
recipientId = roleMap[esc.notifyRole];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback/National roles: Resolve by roleCode singleton
|
||||
if (!recipientId) {
|
||||
const user = await User.findOne({
|
||||
where: { roleCode: esc.notifyRole, status: 'active' },
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
if (user) recipientId = user.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve final email and phone if we have a recipientId
|
||||
let phone = null;
|
||||
if (recipientId) {
|
||||
const user = await User.findByPk(recipientId);
|
||||
if (user) {
|
||||
targetEmail = user.email;
|
||||
phone = user.mobileNumber || user.phone || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetEmail) {
|
||||
await NotificationService.notify(recipientId, targetEmail, {
|
||||
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
|
||||
message: `The application ${track.application?.applicationId} remains incomplete after SLA breach for ${track.stageName}.`,
|
||||
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
||||
templateCode: 'SLA_ESCALATION',
|
||||
placeholders: {
|
||||
applicationId: track.application?.applicationId || '',
|
||||
stageName: track.stageName,
|
||||
level: esc.level,
|
||||
timeValue: esc.timeValue,
|
||||
timeUnit: esc.timeUnit,
|
||||
phone: phone || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// §9.4.1 — Auto-log in Work Notes
|
||||
await this.logWorkNote(track.applicationId, `[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyEmail} for stage ${track.stageName}.`);
|
||||
|
||||
metadata[escKey] = true;
|
||||
await track.update({ metadata });
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await writeWorkflowActivityWorknote({
|
||||
requestId: track.entityId,
|
||||
requestType,
|
||||
userId: admin.id,
|
||||
noteText: text,
|
||||
noteType: 'workflow'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[SLA Service] Failed to log offboarding work note:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private static async logWorkNote(applicationId: string, text: string) {
|
||||
try {
|
||||
const { Worknote, User } = db;
|
||||
// Find a system user or admin to be the author
|
||||
const admin = await User.findOne({ where: { role: 'Super Admin' } });
|
||||
await Worknote.create({
|
||||
applicationId,
|
||||
userId: admin?.id || (await User.findOne())?.id,
|
||||
noteText: text,
|
||||
noteType: 'system',
|
||||
status: 'active'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[SLA Service] Failed to log work note:', err);
|
||||
}
|
||||
private static linkForEntity(entityType: string, entityId: string): string {
|
||||
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
switch (entityType) {
|
||||
case 'application':
|
||||
return `${base}/applications/${entityId}`;
|
||||
case 'termination':
|
||||
return `${base}/termination/${entityId}`;
|
||||
case 'resignation':
|
||||
return `${base}/resignation/${entityId}`;
|
||||
case 'relocation':
|
||||
return `${base}/relocation/${entityId}`;
|
||||
case 'constitutional':
|
||||
return `${base}/constitutional-change/${entityId}`;
|
||||
case 'fnf':
|
||||
return `${base}/fnf/${entityId}`;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
private static async notifyStakeholder(track: any, template: string, content: { title: string, message: string }) {
|
||||
const { Application, User, SLAConfiguration } = db;
|
||||
|
||||
// 1. Get the configuration for this stage to find the Owner Role(s)
|
||||
const config = await SLAConfiguration.findOne({ where: { activityName: track.stageName, isActive: true } });
|
||||
if (!config || !config.ownerRole) return;
|
||||
private static async notifyStakeholder(
|
||||
track: any,
|
||||
template: string,
|
||||
content: { title: string; message: string }
|
||||
) {
|
||||
const config = await this.findConfigForStage(track.stageName);
|
||||
if (!config?.ownerRole) return;
|
||||
|
||||
// 2. Resolve multiple roles (comma-separated)
|
||||
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
|
||||
const application = await Application.findByPk(track.applicationId, {
|
||||
include: [{ model: db.District, as: 'district', include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] }]
|
||||
});
|
||||
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
|
||||
const recipientIds = await resolveRecipientsForRoles(
|
||||
{
|
||||
entityType: track.entityType,
|
||||
entityId: track.entityId,
|
||||
applicationId: track.applicationId
|
||||
},
|
||||
roles
|
||||
);
|
||||
if (recipientIds.length === 0) return;
|
||||
|
||||
if (!application) return;
|
||||
const caseLabel = await this.getCaseLabel(track);
|
||||
const link = this.linkForEntity(track.entityType, track.entityId);
|
||||
|
||||
const recipientIds = new Set<string>();
|
||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
for (const role of roles) {
|
||||
let foundUserId = null;
|
||||
|
||||
// Resolve geography-bound roles
|
||||
if (application.district) {
|
||||
const d = application.district;
|
||||
const roleMap: Record<string, string | null> = {
|
||||
'ASM': d.asmId,
|
||||
'DD-ZM': d.zmId,
|
||||
'RBM': d.region?.rbmId || null,
|
||||
'ZBH': d.zone?.zbhId || null
|
||||
};
|
||||
if (roleMap[role]) foundUserId = roleMap[role];
|
||||
}
|
||||
|
||||
if (foundUserId) {
|
||||
recipientIds.add(foundUserId);
|
||||
} else {
|
||||
// Fallback: Resolve all active users with this role
|
||||
const users = await User.findAll({ where: { roleCode: role, status: 'active' } });
|
||||
users.forEach((u: any) => recipientIds.add(u.id));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send notifications to all resolved recipients
|
||||
for (const userId of recipientIds) {
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) continue;
|
||||
|
||||
const phone = user.mobileNumber || user.phone || null;
|
||||
await NotificationService.notify(userId, user.email, {
|
||||
title: content.title,
|
||||
message: content.message,
|
||||
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
||||
templateCode: template,
|
||||
placeholders: {
|
||||
applicationId: application.applicationId || String(application.id),
|
||||
stageName: track.stageName,
|
||||
link: `${portalBase}/applications/${application.id}`,
|
||||
phone: phone || ''
|
||||
},
|
||||
metadata: { applicationId: application.id }
|
||||
});
|
||||
for (const userId of recipientIds) {
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) continue;
|
||||
const phone = user.mobileNumber || user.phone || null;
|
||||
await NotificationService.notify(userId, user.email, {
|
||||
title: content.title,
|
||||
message: content.message,
|
||||
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
||||
templateCode: template,
|
||||
placeholders: {
|
||||
applicationId: caseLabel,
|
||||
stageName: track.stageName,
|
||||
link,
|
||||
phone: phone || ''
|
||||
},
|
||||
metadata: {
|
||||
entityType: track.entityType,
|
||||
entityId: track.entityId,
|
||||
applicationId: track.applicationId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
398
src/services/SlaOperationsService.ts
Normal file
398
src/services/SlaOperationsService.ts
Normal file
@ -0,0 +1,398 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { SLATracking, SLAConfiguration, SLABreach, Application } = db;
|
||||
import { Op } from 'sequelize';
|
||||
import { slaConfigLookupNames } from '../common/config/slaStageCatalog.js';
|
||||
import { computeSlaTrackView, formatSlaDuration, type SlaBucket } from '../common/utils/slaMetrics.js';
|
||||
import { SlaStatusService } from './SlaStatusService.js';
|
||||
|
||||
export type { SlaBucket };
|
||||
|
||||
function moduleFromEntityType(entityType: string): string {
|
||||
const map: Record<string, string> = {
|
||||
application: 'ONBOARDING',
|
||||
termination: 'TERMINATION',
|
||||
resignation: 'RESIGNATION',
|
||||
relocation: 'RELOCATION',
|
||||
constitutional: 'CONSTITUTIONAL',
|
||||
fnf: 'FNF'
|
||||
};
|
||||
return map[entityType] || entityType.toUpperCase();
|
||||
}
|
||||
|
||||
function linkForEntity(entityType: string, entityId: string): string {
|
||||
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
switch (entityType) {
|
||||
case 'application':
|
||||
return `${base}/applications/${entityId}`;
|
||||
case 'termination':
|
||||
return `${base}/termination/${entityId}`;
|
||||
case 'resignation':
|
||||
return `${base}/resignation/${entityId}`;
|
||||
case 'relocation':
|
||||
return `${base}/relocation/${entityId}`;
|
||||
case 'constitutional':
|
||||
return `${base}/constitutional-change/${entityId}`;
|
||||
case 'fnf':
|
||||
return `${base}/fnf/${entityId}`;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCaseRef(entityType: string, entityId: string, applicationId: string | null): Promise<string> {
|
||||
if (entityType === 'application') {
|
||||
const app = await Application.findByPk(entityId, { attributes: ['applicationId'] });
|
||||
return app?.applicationId || entityId;
|
||||
}
|
||||
if (entityType === 'termination') {
|
||||
const row = await db.TerminationRequest.findByPk(entityId, { attributes: ['requestId'] });
|
||||
return row?.requestId || entityId;
|
||||
}
|
||||
if (entityType === 'resignation') {
|
||||
const row = await db.Resignation.findByPk(entityId, { attributes: ['resignationId'] });
|
||||
return row?.resignationId || entityId;
|
||||
}
|
||||
if (entityType === 'relocation') {
|
||||
const row = await db.RelocationRequest.findByPk(entityId, { attributes: ['requestId'] });
|
||||
return row?.requestId || entityId;
|
||||
}
|
||||
if (entityType === 'constitutional') {
|
||||
const row = await db.ConstitutionalChange.findByPk(entityId, { attributes: ['requestId'] });
|
||||
return row?.requestId || entityId;
|
||||
}
|
||||
if (entityType === 'fnf') {
|
||||
const row = await db.FnF.findByPk(entityId, { attributes: ['settlementId'] });
|
||||
return row?.settlementId || entityId;
|
||||
}
|
||||
if (applicationId) {
|
||||
const app = await Application.findByPk(applicationId, { attributes: ['applicationId'] });
|
||||
return app?.applicationId || applicationId;
|
||||
}
|
||||
return entityId;
|
||||
}
|
||||
|
||||
async function getSchedulerSnapshot() {
|
||||
const redisEnabled = process.env.ENABLE_REDIS === 'true';
|
||||
const snapshot: any = {
|
||||
redisEnabled,
|
||||
slaFastMode: process.env.DEBUG_SLA_FAST_MODE === 'true',
|
||||
questionnaireFastMode: process.env.DEBUG_QUESTIONNAIRE_REMINDER_FAST_MODE === 'true',
|
||||
queues: [] as any[]
|
||||
};
|
||||
|
||||
if (!redisEnabled) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
try {
|
||||
const { slaQueue } = await import('../common/queues/sla.queue.js');
|
||||
const slaCounts = await slaQueue.getJobCounts('active', 'waiting', 'completed', 'failed', 'delayed');
|
||||
const slaRepeat = await slaQueue.getRepeatableJobs();
|
||||
snapshot.queues.push({
|
||||
name: 'SLA breach checker',
|
||||
key: 'slaQueue',
|
||||
counts: slaCounts,
|
||||
repeatable: slaRepeat
|
||||
});
|
||||
} catch (e) {
|
||||
snapshot.queues.push({ name: 'SLA breach checker', error: String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
const { questionnaireReminderQueue } = await import('../common/queues/questionnaire-reminder.queue.js');
|
||||
const qCounts = await questionnaireReminderQueue.getJobCounts(
|
||||
'active',
|
||||
'waiting',
|
||||
'completed',
|
||||
'failed',
|
||||
'delayed'
|
||||
);
|
||||
const qRepeat = await questionnaireReminderQueue.getRepeatableJobs();
|
||||
snapshot.queues.push({
|
||||
name: 'Prospect questionnaire reminders',
|
||||
key: 'questionnaireReminderQueue',
|
||||
counts: qCounts,
|
||||
repeatable: qRepeat
|
||||
});
|
||||
} catch (e) {
|
||||
snapshot.queues.push({ name: 'Prospect questionnaire reminders', error: String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
const { offboardingLwdQueue } = await import('../common/queues/offboarding-lwd.queue.js');
|
||||
const lwdCounts = await offboardingLwdQueue.getJobCounts(
|
||||
'active',
|
||||
'waiting',
|
||||
'completed',
|
||||
'failed',
|
||||
'delayed'
|
||||
);
|
||||
const lwdRepeat = await offboardingLwdQueue.getRepeatableJobs();
|
||||
snapshot.queues.push({
|
||||
name: 'Offboarding LWD → F&F',
|
||||
key: 'offboardingLwdQueue',
|
||||
counts: lwdCounts,
|
||||
repeatable: lwdRepeat
|
||||
});
|
||||
} catch (e) {
|
||||
snapshot.queues.push({ name: 'Offboarding LWD', error: String(e) });
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function computeAnalytics(moduleFilter?: string) {
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const tracksStarted = await SLATracking.findAll({
|
||||
where: { startTime: { [Op.gte]: since } },
|
||||
attributes: ['id', 'entityType', 'stageName', 'startTime', 'endTime', 'isBreached']
|
||||
});
|
||||
|
||||
const filteredTracks = moduleFilter
|
||||
? tracksStarted.filter((t: any) => moduleFromEntityType(t.entityType || 'application') === moduleFilter)
|
||||
: tracksStarted;
|
||||
|
||||
const breaches30d = await SLABreach.findAll({
|
||||
where: { breachedAt: { [Op.gte]: since } },
|
||||
include: [{ model: SLATracking, as: 'slaTracking', required: true, attributes: ['entityType', 'stageName'] }]
|
||||
});
|
||||
|
||||
const filteredBreaches = moduleFilter
|
||||
? breaches30d.filter((b: any) => {
|
||||
const t = b.slaTracking;
|
||||
return t && moduleFromEntityType(t.entityType || 'application') === moduleFilter;
|
||||
})
|
||||
: breaches30d;
|
||||
|
||||
const startedCount = filteredTracks.length;
|
||||
const breachCount = filteredBreaches.length;
|
||||
const breachRatePercent =
|
||||
startedCount > 0 ? Math.round((breachCount / startedCount) * 1000) / 10 : 0;
|
||||
|
||||
const completed = filteredTracks.filter((t: any) => t.endTime);
|
||||
let avgResolutionHours: number | null = null;
|
||||
if (completed.length > 0) {
|
||||
const totalMs = completed.reduce((sum: number, t: any) => {
|
||||
return sum + (new Date(t.endTime).getTime() - new Date(t.startTime).getTime());
|
||||
}, 0);
|
||||
avgResolutionHours = Math.round((totalMs / completed.length / (60 * 60 * 1000)) * 10) / 10;
|
||||
}
|
||||
|
||||
const stageCounts = new Map<string, { breaches: number; activeBreached: number }>();
|
||||
for (const b of filteredBreaches) {
|
||||
const stage = (b as any).slaTracking?.stageName || 'Unknown';
|
||||
const cur = stageCounts.get(stage) || { breaches: 0, activeBreached: 0 };
|
||||
cur.breaches++;
|
||||
stageCounts.set(stage, cur);
|
||||
}
|
||||
|
||||
const activeBreached = await SLATracking.findAll({
|
||||
where: { isActive: true, endTime: null, isBreached: true },
|
||||
attributes: ['stageName', 'entityType']
|
||||
});
|
||||
for (const t of activeBreached) {
|
||||
const mod = moduleFromEntityType((t as any).entityType || 'application');
|
||||
if (moduleFilter && mod !== moduleFilter) continue;
|
||||
const stage = t.stageName;
|
||||
const cur = stageCounts.get(stage) || { breaches: 0, activeBreached: 0 };
|
||||
cur.activeBreached++;
|
||||
stageCounts.set(stage, cur);
|
||||
}
|
||||
|
||||
const topDelayedStages = [...stageCounts.entries()]
|
||||
.map(([stageName, counts]) => ({
|
||||
stageName,
|
||||
breachCount: counts.breaches,
|
||||
currentlyBreached: counts.activeBreached,
|
||||
score: counts.breaches + counts.activeBreached * 2
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 8);
|
||||
|
||||
const moduleBreachCounts: Record<string, number> = {};
|
||||
for (const b of filteredBreaches) {
|
||||
const mod = moduleFromEntityType((b as any).slaTracking?.entityType || 'application');
|
||||
moduleBreachCounts[mod] = (moduleBreachCounts[mod] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
periodDays: 30,
|
||||
tracksStarted: startedCount,
|
||||
breachesRecorded: breachCount,
|
||||
breachRatePercent,
|
||||
avgResolutionHours,
|
||||
completedTracks: completed.length,
|
||||
topDelayedStages,
|
||||
breachesByModule: moduleBreachCounts
|
||||
};
|
||||
}
|
||||
|
||||
export class SlaOperationsService {
|
||||
static async getDashboard(
|
||||
filters: { module?: string; breachedOnly?: boolean; mineOnly?: boolean; userRoleCode?: string | null } = {}
|
||||
) {
|
||||
const now = Date.now();
|
||||
const configs = await SLAConfiguration.findAll({
|
||||
where: { isActive: true },
|
||||
attributes: ['activityName', 'ownerRole', 'tatHours', 'tatUnit']
|
||||
});
|
||||
const configByStage = new Map(configs.map((c: any) => [c.activityName, c]));
|
||||
|
||||
const activeTracks = await SLATracking.findAll({
|
||||
where: { isActive: true, endTime: null },
|
||||
include: [{ model: Application, as: 'application', required: false, attributes: ['applicationId', 'applicantName'] }],
|
||||
order: [['startTime', 'ASC']]
|
||||
});
|
||||
|
||||
const queueItems: any[] = [];
|
||||
const buckets = { healthy: 0, warning: 0, critical: 0, breached: 0 };
|
||||
const byModule: Record<string, number> = {};
|
||||
|
||||
for (const track of activeTracks) {
|
||||
const entityType = (track as any).entityType || 'application';
|
||||
const entityId = (track as any).entityId || track.applicationId;
|
||||
const mod = moduleFromEntityType(entityType);
|
||||
|
||||
if (filters.module && mod !== filters.module) continue;
|
||||
|
||||
const config = slaConfigLookupNames(track.stageName)
|
||||
.map((name) => configByStage.get(name))
|
||||
.find(Boolean) as { ownerRole: string; tatHours: number; tatUnit: string } | undefined;
|
||||
if (!config) continue;
|
||||
|
||||
const view = computeSlaTrackView(track, config, now);
|
||||
const { deadline, percentUsed, bucket, isBreached } = view;
|
||||
|
||||
if (filters.mineOnly && filters.userRoleCode) {
|
||||
if (!SlaStatusService.filterQueueByOwnerRole([{ ownerRole: config.ownerRole }], filters.userRoleCode).length) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.breachedOnly && bucket !== 'breached') continue;
|
||||
|
||||
buckets[bucket]++;
|
||||
byModule[mod] = (byModule[mod] || 0) + 1;
|
||||
|
||||
const caseRef = await resolveCaseRef(entityType, entityId, track.applicationId);
|
||||
const msRemaining = deadline.getTime() - now;
|
||||
|
||||
queueItems.push({
|
||||
trackingId: track.id,
|
||||
entityType,
|
||||
entityId,
|
||||
applicationId: track.applicationId,
|
||||
module: mod,
|
||||
caseRef,
|
||||
stageName: track.stageName,
|
||||
ownerRole: config.ownerRole,
|
||||
startTime: track.startTime,
|
||||
deadline,
|
||||
percentUsed,
|
||||
bucket,
|
||||
isBreached,
|
||||
remainingLabel:
|
||||
msRemaining > 0
|
||||
? formatSlaDuration(msRemaining) + ' left'
|
||||
: formatSlaDuration(-msRemaining) + ' overdue',
|
||||
link: linkForEntity(entityType, entityId)
|
||||
});
|
||||
}
|
||||
|
||||
queueItems.sort((a, b) => {
|
||||
const order = { breached: 0, critical: 1, warning: 2, healthy: 3 };
|
||||
const bo = (order[a.bucket as SlaBucket] ?? 9) - (order[b.bucket as SlaBucket] ?? 9);
|
||||
if (bo !== 0) return bo;
|
||||
return b.percentUsed - a.percentUsed;
|
||||
});
|
||||
|
||||
const openBreaches = await SLABreach.findAll({
|
||||
where: { status: { [Op.in]: ['Open', 'open', 'OPEN'] } },
|
||||
include: [
|
||||
{
|
||||
model: SLATracking,
|
||||
as: 'slaTracking',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['breachedAt', 'DESC']],
|
||||
limit: 100
|
||||
});
|
||||
|
||||
const breachRows: any[] = [];
|
||||
for (const breach of openBreaches) {
|
||||
const tracking = (breach as any).slaTracking;
|
||||
if (!tracking) continue;
|
||||
const entityType = tracking.entityType || 'application';
|
||||
const entityId = tracking.entityId || tracking.applicationId;
|
||||
const mod = moduleFromEntityType(entityType);
|
||||
if (filters.module && mod !== filters.module) continue;
|
||||
|
||||
breachRows.push({
|
||||
id: breach.id,
|
||||
breachedAt: breach.breachedAt,
|
||||
status: breach.status,
|
||||
stageName: tracking.stageName,
|
||||
module: mod,
|
||||
caseRef: await resolveCaseRef(entityType, entityId, tracking.applicationId),
|
||||
link: linkForEntity(entityType, entityId)
|
||||
});
|
||||
}
|
||||
|
||||
const configStats = await SLAConfiguration.findAll({
|
||||
attributes: ['isActive'],
|
||||
raw: true
|
||||
});
|
||||
const configsActive = configStats.filter((c: any) => c.isActive).length;
|
||||
const configsInactive = configStats.length - configsActive;
|
||||
|
||||
const activeCount = queueItems.length;
|
||||
const breachedCount = queueItems.filter((q) => q.bucket === 'breached').length;
|
||||
const dueSoonCount = queueItems.filter((q) => q.bucket === 'critical' || q.bucket === 'warning').length;
|
||||
const onTrackCount = queueItems.filter((q) => q.bucket === 'healthy').length;
|
||||
|
||||
const tracksWithoutConfig = activeTracks.filter((t: any) => {
|
||||
return !slaConfigLookupNames(t.stageName).some((name) => configByStage.has(name));
|
||||
}).length;
|
||||
|
||||
const analytics = await computeAnalytics(filters.module);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
scheduler: await getSchedulerSnapshot(),
|
||||
analytics,
|
||||
summary: {
|
||||
activeCount,
|
||||
breachedCount,
|
||||
dueSoonCount,
|
||||
onTrackCount,
|
||||
openBreachesCount: breachRows.length,
|
||||
tracksWithoutConfig,
|
||||
configsActive,
|
||||
configsInactive,
|
||||
buckets,
|
||||
byModule
|
||||
},
|
||||
activeQueue: queueItems,
|
||||
breaches: breachRows
|
||||
};
|
||||
}
|
||||
|
||||
static toCsv(dashboard: Awaited<ReturnType<typeof SlaOperationsService.getDashboard>>): string {
|
||||
const header = 'caseRef,module,stage,owner,percentUsed,bucket,remaining,deadline\n';
|
||||
const rows = dashboard.activeQueue.map((q) =>
|
||||
[
|
||||
q.caseRef,
|
||||
q.module,
|
||||
`"${String(q.stageName).replace(/"/g, '""')}"`,
|
||||
`"${String(q.ownerRole).replace(/"/g, '""')}"`,
|
||||
q.percentUsed,
|
||||
q.bucket,
|
||||
`"${q.remainingLabel}"`,
|
||||
new Date(q.deadline).toISOString()
|
||||
].join(',')
|
||||
);
|
||||
return header + rows.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
93
src/services/SlaStatusService.ts
Normal file
93
src/services/SlaStatusService.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { SLATracking, SLAConfiguration } = db;
|
||||
import { Op } from 'sequelize';
|
||||
import { slaConfigLookupNames } from '../common/config/slaStageCatalog.js';
|
||||
import { computeSlaTrackView, ownerRoleMatchesUser } from '../common/utils/slaMetrics.js';
|
||||
|
||||
export type SlaEntityRef = { entityType: string; entityId: string };
|
||||
|
||||
export type SlaStatusSnapshot = {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
stageName: string;
|
||||
ownerRole: string;
|
||||
percentUsed: number;
|
||||
bucket: string;
|
||||
isBreached: boolean;
|
||||
isPaused?: boolean;
|
||||
remainingLabel: string;
|
||||
deadline: string;
|
||||
};
|
||||
|
||||
export class SlaStatusService {
|
||||
static refKey(entityType: string, entityId: string) {
|
||||
return `${entityType}:${entityId}`;
|
||||
}
|
||||
|
||||
static async getBatchStatus(refs: SlaEntityRef[]): Promise<Record<string, SlaStatusSnapshot | null>> {
|
||||
const result: Record<string, SlaStatusSnapshot | null> = {};
|
||||
if (!refs.length) return result;
|
||||
|
||||
const configs = await SLAConfiguration.findAll({
|
||||
where: { isActive: true },
|
||||
attributes: ['activityName', 'ownerRole', 'tatHours', 'tatUnit']
|
||||
});
|
||||
const configByStage = new Map(configs.map((c: any) => [c.activityName, c]));
|
||||
|
||||
const entityIds = [...new Set(refs.map((r) => r.entityId))];
|
||||
const entityTypes = [...new Set(refs.map((r) => r.entityType))];
|
||||
|
||||
const tracks = await SLATracking.findAll({
|
||||
where: {
|
||||
entityId: { [Op.in]: entityIds },
|
||||
entityType: { [Op.in]: entityTypes },
|
||||
isActive: true,
|
||||
endTime: null
|
||||
},
|
||||
order: [['startTime', 'DESC']]
|
||||
});
|
||||
|
||||
const trackByRef = new Map<string, any>();
|
||||
for (const track of tracks) {
|
||||
const key = this.refKey((track as any).entityType, (track as any).entityId);
|
||||
if (!trackByRef.has(key)) trackByRef.set(key, track);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const ref of refs) {
|
||||
const key = this.refKey(ref.entityType, ref.entityId);
|
||||
const track = trackByRef.get(key);
|
||||
if (!track) {
|
||||
result[key] = null;
|
||||
continue;
|
||||
}
|
||||
const config = slaConfigLookupNames(track.stageName)
|
||||
.map((name) => configByStage.get(name))
|
||||
.find(Boolean) as { ownerRole: string; tatHours: number; tatUnit: string } | undefined;
|
||||
if (!config) {
|
||||
result[key] = null;
|
||||
continue;
|
||||
}
|
||||
const view = computeSlaTrackView(track, config, now);
|
||||
result[key] = {
|
||||
entityType: ref.entityType,
|
||||
entityId: ref.entityId,
|
||||
stageName: track.stageName,
|
||||
ownerRole: config.ownerRole,
|
||||
percentUsed: view.percentUsed,
|
||||
bucket: view.bucket,
|
||||
isBreached: view.isBreached,
|
||||
isPaused: view.isPaused,
|
||||
remainingLabel: view.remainingLabel,
|
||||
deadline: view.deadline.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static filterQueueByOwnerRole<T extends { ownerRole: string }>(items: T[], userRoleCode?: string | null): T[] {
|
||||
if (!userRoleCode) return items;
|
||||
return items.filter((item) => ownerRoleMatchesUser(item.ownerRole, userRoleCode));
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||
import { ParticipantService } from './ParticipantService.js';
|
||||
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||
|
||||
export class TerminationWorkflowService {
|
||||
/**
|
||||
@ -18,6 +19,7 @@ export class TerminationWorkflowService {
|
||||
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
||||
const { action, remarks, status, transaction } = metadata;
|
||||
const sourceStage = termination.currentStage;
|
||||
const wasOnHold = String(termination.status || '').toLowerCase() === 'on hold';
|
||||
|
||||
const updateData: any = {
|
||||
currentStage: targetStage,
|
||||
@ -48,6 +50,18 @@ export class TerminationWorkflowService {
|
||||
timeline: updatedTimeline
|
||||
}, transaction ? { transaction } : undefined);
|
||||
|
||||
await syncSlaOnStageTransition({
|
||||
entityType: 'termination',
|
||||
entityId: termination.id,
|
||||
fromStage: sourceStage,
|
||||
toStage: targetStage
|
||||
});
|
||||
|
||||
if (wasOnHold) {
|
||||
const { SLAService } = await import('./SLAService.js');
|
||||
await SLAService.resumeEntityTracks('termination', termination.id);
|
||||
}
|
||||
|
||||
// 4. Create Audit Log using standardized mapper
|
||||
const { actionType } = metadata;
|
||||
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.TERMINATION);
|
||||
@ -161,9 +175,19 @@ export class TerminationWorkflowService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates Full & Final Settlement for a terminated dealer
|
||||
* Creates the FnF settlement record — ONLY call from explicit Push to F&F (`manualTrigger: true`).
|
||||
*/
|
||||
static async initiateFnF(termination: any, userId: string, transaction: any) {
|
||||
static async initiateFnF(
|
||||
termination: any,
|
||||
userId: string,
|
||||
transaction: any,
|
||||
options: { manualTrigger?: boolean } = {}
|
||||
) {
|
||||
if (!options.manualTrigger) {
|
||||
throw new Error(
|
||||
'F&F settlement for termination must be started via Push to F&F (manual trigger only).'
|
||||
);
|
||||
}
|
||||
// 1. Get Dealer User with associated Outlets
|
||||
const dealerUser = await User.findOne({
|
||||
where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER },
|
||||
@ -180,25 +204,47 @@ export class TerminationWorkflowService {
|
||||
let fnfId = fnf?.id;
|
||||
|
||||
if (!fnf) {
|
||||
fnf = await db.FnF.create({
|
||||
settlementId: await NomenclatureService.generateFnFId(),
|
||||
terminationRequestId: termination.id,
|
||||
dealerId: termination.dealerId,
|
||||
outletId: primaryOutlet?.id || null,
|
||||
status: 'Initiated',
|
||||
totalReceivables: 0,
|
||||
totalPayables: 0,
|
||||
netAmount: 0
|
||||
});
|
||||
fnf = await db.FnF.create(
|
||||
{
|
||||
settlementId: await NomenclatureService.generateFnFId(),
|
||||
terminationRequestId: termination.id,
|
||||
dealerId: termination.dealerId,
|
||||
outletId: primaryOutlet?.id || null,
|
||||
status: 'Initiated',
|
||||
totalReceivables: 0,
|
||||
totalPayables: 0,
|
||||
netAmount: 0
|
||||
},
|
||||
transaction ? { transaction } : undefined
|
||||
);
|
||||
|
||||
await db.FffClearance.bulkCreate(
|
||||
FNF_DEPARTMENTS.map(dept => ({
|
||||
fnfId: fnf.id,
|
||||
department: dept,
|
||||
status: 'Pending'
|
||||
}))
|
||||
})),
|
||||
transaction ? { transaction } : undefined
|
||||
);
|
||||
|
||||
|
||||
const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js');
|
||||
await startAllPendingFnfClearanceSlas(fnf.id);
|
||||
|
||||
await db.FnFAudit.create(
|
||||
{
|
||||
userId,
|
||||
fnfId: fnf.id,
|
||||
action: 'INITIATED',
|
||||
remarks: 'F&F settlement created via manual Push to F&F (termination)',
|
||||
details: {
|
||||
source: 'Termination Workflow',
|
||||
terminationRequestId: termination.requestId,
|
||||
manualTrigger: true
|
||||
}
|
||||
},
|
||||
transaction ? { transaction } : undefined
|
||||
);
|
||||
|
||||
fnfId = fnf.id;
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import db from '../database/models/index.js';
|
||||
const { Application, ApplicationStatusHistory, User, Dealer } = db;
|
||||
import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../common/utils/progress.js';
|
||||
import { shouldTrackOnboardingSla } from '../common/config/slaStageCatalog.js';
|
||||
import { SLAService } from './SLAService.js';
|
||||
import {
|
||||
AUDIT_ACTIONS,
|
||||
APPLICATION_STAGES,
|
||||
OVERALL_STATUS_TO_DB_CURRENT_STAGE,
|
||||
} from '../common/config/constants.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { SLAService } from './SLAService.js';
|
||||
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
|
||||
|
||||
export class WorkflowService {
|
||||
@ -106,9 +107,31 @@ export class WorkflowService {
|
||||
try {
|
||||
const tasks = [];
|
||||
|
||||
// SLA Tracking
|
||||
if (previousStage) tasks.push(SLAService.stopTrack(application.id, previousStage).catch(e => console.error('[WorkflowService] SLA stop failed:', e)));
|
||||
if (stageForDbColumn) tasks.push(SLAService.startTrack(application.id, stageForDbColumn).catch(e => console.error('[WorkflowService] SLA start failed:', e)));
|
||||
// SLA Tracking — use pipeline milestone label when available (matches slaStageCatalog)
|
||||
const slaPrevStage =
|
||||
(previousStatus && PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[previousStatus]) ||
|
||||
previousStage;
|
||||
const slaNextStage = pipelineStageLabel || stageForDbColumn;
|
||||
if (slaPrevStage && shouldTrackOnboardingSla(slaPrevStage)) {
|
||||
tasks.push(
|
||||
SLAService.stopTrack({
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
applicationId: application.id,
|
||||
stageName: slaPrevStage
|
||||
}).catch((e) => console.error('[WorkflowService] SLA stop failed:', e))
|
||||
);
|
||||
}
|
||||
if (slaNextStage && shouldTrackOnboardingSla(slaNextStage)) {
|
||||
tasks.push(
|
||||
SLAService.startTrack({
|
||||
entityType: 'application',
|
||||
entityId: application.id,
|
||||
applicationId: application.id,
|
||||
stageName: slaNextStage
|
||||
}).catch((e) => console.error('[WorkflowService] SLA start failed:', e))
|
||||
);
|
||||
}
|
||||
|
||||
// Progress Sync
|
||||
tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e)));
|
||||
|
||||
126
src/services/questionnaireReminderSettings.ts
Normal file
126
src/services/questionnaireReminderSettings.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import db from '../database/models/index.js';
|
||||
|
||||
const CATEGORY = 'sla';
|
||||
const KEYS = {
|
||||
enabled: 'questionnaire_reminder.enabled',
|
||||
firstAfterDays: 'questionnaire_reminder.first_after_days',
|
||||
intervalDays: 'questionnaire_reminder.interval_days',
|
||||
maxCount: 'questionnaire_reminder.max_count'
|
||||
} as const;
|
||||
|
||||
export type QuestionnaireReminderSettings = {
|
||||
enabled: boolean;
|
||||
firstAfterDays: number;
|
||||
intervalDays: number;
|
||||
maxCount: number;
|
||||
source: 'database' | 'environment';
|
||||
};
|
||||
|
||||
function envBool(name: string, fallback: boolean): boolean {
|
||||
const v = String(process.env[name] ?? '').trim().toLowerCase();
|
||||
if (v === 'true' || v === '1') return true;
|
||||
if (v === 'false' || v === '0') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const n = Number(process.env[name]);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||
}
|
||||
|
||||
function envDefaults(): QuestionnaireReminderSettings {
|
||||
return {
|
||||
enabled: envBool('QUESTIONNAIRE_REMINDER_SCHEDULER_ENABLED', true),
|
||||
firstAfterDays: envInt('QUESTIONNAIRE_REMINDER_FIRST_AFTER_DAYS', 1),
|
||||
intervalDays: envInt('QUESTIONNAIRE_REMINDER_INTERVAL_DAYS', 2),
|
||||
maxCount: envInt('QUESTIONNAIRE_REMINDER_MAX_COUNT', 5),
|
||||
source: 'environment'
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredValue(key: string, raw: string): boolean | number | null {
|
||||
if (key === KEYS.enabled) {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === 'true' || v === '1') return true;
|
||||
if (v === 'false' || v === '0') return false;
|
||||
return null;
|
||||
}
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
export async function getQuestionnaireReminderSettings(): Promise<QuestionnaireReminderSettings> {
|
||||
const defaults = envDefaults();
|
||||
const rows = await db.SystemConfiguration.findAll({
|
||||
where: { key: Object.values(KEYS), isActive: true }
|
||||
});
|
||||
if (!rows.length) return defaults;
|
||||
|
||||
const map = new Map(rows.map((r: any) => [r.key, r.value]));
|
||||
let fromDb = false;
|
||||
|
||||
const enabledRaw = map.get(KEYS.enabled);
|
||||
const firstRaw = map.get(KEYS.firstAfterDays);
|
||||
const intervalRaw = map.get(KEYS.intervalDays);
|
||||
const maxRaw = map.get(KEYS.maxCount);
|
||||
|
||||
const enabledParsed = enabledRaw != null ? parseStoredValue(KEYS.enabled, String(enabledRaw)) : null;
|
||||
const firstParsed = firstRaw != null ? parseStoredValue(KEYS.firstAfterDays, String(firstRaw)) : null;
|
||||
const intervalParsed = intervalRaw != null ? parseStoredValue(KEYS.intervalDays, String(intervalRaw)) : null;
|
||||
const maxParsed = maxRaw != null ? parseStoredValue(KEYS.maxCount, String(maxRaw)) : null;
|
||||
|
||||
if (enabledParsed !== null) fromDb = true;
|
||||
if (firstParsed !== null) fromDb = true;
|
||||
if (intervalParsed !== null) fromDb = true;
|
||||
if (maxParsed !== null) fromDb = true;
|
||||
|
||||
return {
|
||||
enabled: (enabledParsed as boolean | null) ?? defaults.enabled,
|
||||
firstAfterDays: (firstParsed as number | null) ?? defaults.firstAfterDays,
|
||||
intervalDays: (intervalParsed as number | null) ?? defaults.intervalDays,
|
||||
maxCount: (maxParsed as number | null) ?? defaults.maxCount,
|
||||
source: fromDb ? 'database' : 'environment'
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveQuestionnaireReminderSettings(
|
||||
input: Partial<Omit<QuestionnaireReminderSettings, 'source'>>
|
||||
): Promise<QuestionnaireReminderSettings> {
|
||||
const upsert = async (key: string, value: string, description: string) => {
|
||||
const existing = await db.SystemConfiguration.findOne({ where: { key } });
|
||||
if (existing) {
|
||||
await existing.update({ value, category: CATEGORY, isActive: true });
|
||||
} else {
|
||||
await db.SystemConfiguration.create({
|
||||
key,
|
||||
value,
|
||||
category: CATEGORY,
|
||||
description,
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (input.enabled !== undefined) {
|
||||
await upsert(KEYS.enabled, String(input.enabled), 'Enable scheduled prospect questionnaire reminders');
|
||||
}
|
||||
if (input.firstAfterDays !== undefined) {
|
||||
await upsert(
|
||||
KEYS.firstAfterDays,
|
||||
String(input.firstAfterDays),
|
||||
'Days after Questionnaire Pending before first reminder'
|
||||
);
|
||||
}
|
||||
if (input.intervalDays !== undefined) {
|
||||
await upsert(
|
||||
KEYS.intervalDays,
|
||||
String(input.intervalDays),
|
||||
'Minimum days between repeat questionnaire reminders'
|
||||
);
|
||||
}
|
||||
if (input.maxCount !== undefined) {
|
||||
await upsert(KEYS.maxCount, String(input.maxCount), 'Maximum auto questionnaire reminders per application');
|
||||
}
|
||||
|
||||
return getQuestionnaireReminderSettings();
|
||||
}
|
||||
189
src/services/slaGeographyResolver.ts
Normal file
189
src/services/slaGeographyResolver.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import db from '../database/models/index.js';
|
||||
|
||||
const { Application, Dealer, Outlet, TerminationRequest, Resignation, RelocationRequest, ConstitutionalChange, FnF } =
|
||||
db;
|
||||
|
||||
export type GeographicContext = {
|
||||
asmId: string | null;
|
||||
zmId: string | null;
|
||||
rbmId: string | null;
|
||||
zbhId: string | null;
|
||||
};
|
||||
|
||||
const districtInclude = [
|
||||
{
|
||||
model: db.District,
|
||||
as: 'district',
|
||||
include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }]
|
||||
}
|
||||
];
|
||||
|
||||
function contextFromDistrict(district: any): GeographicContext | null {
|
||||
if (!district) return null;
|
||||
return {
|
||||
asmId: district.asmId || null,
|
||||
zmId: district.zmId || null,
|
||||
rbmId: district.region?.rbmId || null,
|
||||
zbhId: district.zone?.zbhId || null
|
||||
};
|
||||
}
|
||||
|
||||
async function districtFromDealer(dealerId: string): Promise<GeographicContext | null> {
|
||||
const dealer = await Dealer.findByPk(dealerId, {
|
||||
attributes: ['id', 'applicationId', 'asmId'],
|
||||
include: [
|
||||
{
|
||||
model: Application,
|
||||
as: 'application',
|
||||
attributes: ['id'],
|
||||
include: districtInclude
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!dealer) return null;
|
||||
|
||||
const fromApp = contextFromDistrict((dealer as any).application?.district);
|
||||
if (fromApp) return fromApp;
|
||||
|
||||
if (dealer.asmId) {
|
||||
return { asmId: dealer.asmId, zmId: null, rbmId: null, zbhId: null };
|
||||
}
|
||||
|
||||
const dealerUser = await db.User.findOne({
|
||||
where: { dealerId: dealer.id },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (dealerUser) {
|
||||
const outlet = await Outlet.findOne({
|
||||
where: { dealerId: dealerUser.id },
|
||||
attributes: ['id'],
|
||||
include: districtInclude
|
||||
});
|
||||
const fromOutlet = contextFromDistrict((outlet as any)?.district);
|
||||
if (fromOutlet) return fromOutlet;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function districtFromOutlet(outletId: string): Promise<GeographicContext | null> {
|
||||
const outlet = await Outlet.findByPk(outletId, {
|
||||
attributes: ['id'],
|
||||
include: districtInclude
|
||||
});
|
||||
return contextFromDistrict((outlet as any)?.district);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ASM / DD-ZM / RBM / ZBH user IDs for an SLA track from dealer geography (SRS §9.4.3).
|
||||
*/
|
||||
export async function resolveGeographicContext(track: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
applicationId?: string | null;
|
||||
}): Promise<GeographicContext | null> {
|
||||
if (track.entityType === 'application' && track.applicationId) {
|
||||
const application = await Application.findByPk(track.applicationId, {
|
||||
attributes: ['id'],
|
||||
include: districtInclude
|
||||
});
|
||||
return contextFromDistrict((application as any)?.district);
|
||||
}
|
||||
|
||||
if (track.entityType === 'termination') {
|
||||
const row = await TerminationRequest.findByPk(track.entityId, { attributes: ['dealerId'] });
|
||||
if (!row?.dealerId) return null;
|
||||
return districtFromDealer(row.dealerId);
|
||||
}
|
||||
|
||||
if (track.entityType === 'resignation') {
|
||||
const row = await Resignation.findByPk(track.entityId, { attributes: ['outletId', 'dealerId'] });
|
||||
if (row?.outletId) return districtFromOutlet(row.outletId);
|
||||
if (row?.dealerId) return districtFromDealer(row.dealerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (track.entityType === 'relocation') {
|
||||
const row = await RelocationRequest.findByPk(track.entityId, { attributes: ['outletId'] });
|
||||
if (!row?.outletId) return null;
|
||||
return districtFromOutlet(row.outletId);
|
||||
}
|
||||
|
||||
if (track.entityType === 'constitutional') {
|
||||
const row = await ConstitutionalChange.findByPk(track.entityId, { attributes: ['outletId'] });
|
||||
if (row?.outletId) return districtFromOutlet(row.outletId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (track.entityType === 'fnf') {
|
||||
const fnf = await FnF.findByPk(track.entityId, {
|
||||
attributes: ['terminationRequestId', 'resignationId']
|
||||
});
|
||||
if (fnf?.terminationRequestId) {
|
||||
const term = await TerminationRequest.findByPk(fnf.terminationRequestId, { attributes: ['dealerId'] });
|
||||
if (term?.dealerId) return districtFromDealer(term.dealerId);
|
||||
}
|
||||
if (fnf?.resignationId) {
|
||||
const res = await Resignation.findByPk(fnf.resignationId, { attributes: ['outletId', 'dealerId'] });
|
||||
if (res?.outletId) return districtFromOutlet(res.outletId);
|
||||
if (res?.dealerId) return districtFromDealer(res.dealerId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROLE_TO_FIELD: Record<string, keyof GeographicContext> = {
|
||||
ASM: 'asmId',
|
||||
'DD-ZM': 'zmId',
|
||||
RBM: 'rbmId',
|
||||
ZBH: 'zbhId'
|
||||
};
|
||||
|
||||
export function userIdsForGeographicRoles(ctx: GeographicContext, roles: string[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const role of roles) {
|
||||
const field = ROLE_TO_FIELD[role.trim()];
|
||||
if (!field) continue;
|
||||
const uid = ctx[field];
|
||||
if (uid) ids.add(uid);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Geography-aware user IDs for roles; falls back to global active users by role when unmapped. */
|
||||
export async function resolveRecipientsForRoles(
|
||||
track: { entityType: string; entityId: string; applicationId?: string | null },
|
||||
roles: string[]
|
||||
): Promise<string[]> {
|
||||
const recipientIds = new Set<string>();
|
||||
const geo = await resolveGeographicContext(track);
|
||||
const geoRoles = roles.filter((r) => ROLE_TO_FIELD[r.trim()]);
|
||||
const otherRoles = roles.filter((r) => !ROLE_TO_FIELD[r.trim()]);
|
||||
|
||||
if (geo && geoRoles.length > 0) {
|
||||
for (const id of userIdsForGeographicRoles(geo, geoRoles)) {
|
||||
recipientIds.add(id);
|
||||
}
|
||||
const missingGeoRoles = geoRoles.filter((role) => {
|
||||
const field = ROLE_TO_FIELD[role.trim()];
|
||||
return field && !geo[field];
|
||||
});
|
||||
for (const role of missingGeoRoles) {
|
||||
const users = await db.User.findAll({ where: { roleCode: role.trim(), status: 'active' } });
|
||||
users.forEach((u: any) => recipientIds.add(u.id));
|
||||
}
|
||||
} else if (geoRoles.length > 0) {
|
||||
for (const role of geoRoles) {
|
||||
const users = await db.User.findAll({ where: { roleCode: role.trim(), status: 'active' } });
|
||||
users.forEach((u: any) => recipientIds.add(u.id));
|
||||
}
|
||||
}
|
||||
|
||||
for (const role of otherRoles) {
|
||||
const users = await db.User.findAll({ where: { roleCode: role.trim(), status: 'active' } });
|
||||
users.forEach((u: any) => recipientIds.add(u.id));
|
||||
}
|
||||
|
||||
return [...recipientIds];
|
||||
}
|
||||
@ -1,11 +1,20 @@
|
||||
/**
|
||||
* Dealer termination E2E — follows the same stage order and APIs as the UI.
|
||||
*
|
||||
* Usage:
|
||||
* node trigger-termination.js
|
||||
* node trigger-termination.js --terminationId=<uuid> --delayMs=800
|
||||
* node trigger-termination.js --category=Unethical --skipClearances=true
|
||||
*/
|
||||
const args = Object.fromEntries(
|
||||
process.argv.slice(2)
|
||||
.map(arg => arg.replace(/^--/, '').split('='))
|
||||
process.argv
|
||||
.slice(2)
|
||||
.map((arg) => arg.replace(/^--/, '').split('='))
|
||||
.map(([k, v]) => [k, v ?? 'true'])
|
||||
);
|
||||
const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api';
|
||||
const PASSWORD = 'Admin@123';
|
||||
const STEP_DELAY_MS = Number(args.delayMs || 500);
|
||||
const STEP_DELAY_MS = Number(args.delayMs || 800);
|
||||
const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true';
|
||||
|
||||
const EMAILS = {
|
||||
@ -18,31 +27,37 @@ const EMAILS = {
|
||||
DD_HEAD: 'ganesh@royalenfield.com',
|
||||
LEGAL: 'legal@royalenfield.com',
|
||||
NBH: 'yashwin@royalenfield.com',
|
||||
CCO: 'admin@royalenfield.com',
|
||||
CEO: 'admin@royalenfield.com',
|
||||
SALES: 'sales@royalenfield.com',
|
||||
SERVICE: 'service@royalenfield.com',
|
||||
SPARES: 'spares@royalenfield.com',
|
||||
ACCOUNTS: 'accounts@royalenfield.com',
|
||||
WARRANTY: 'warranty@royalenfield.com',
|
||||
MARKETING: 'marketing@royalenfield.com',
|
||||
HR: 'hr@royalenfield.com',
|
||||
IT: 'it@royalenfield.com',
|
||||
LOGISTICS: 'logistics@royalenfield.com',
|
||||
QUALITY: 'quality@royalenfield.com',
|
||||
APPAREL: 'apparel@royalenfield.com',
|
||||
DMS: 'dms@royalenfield.com'
|
||||
CCO: 'cco@royalenfield.com',
|
||||
CEO: 'ceo@royalenfield.com'
|
||||
};
|
||||
|
||||
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
/** Canonical stage labels (match TERMINATION_STAGES + UI timeline). */
|
||||
const STAGES = {
|
||||
RBM: 'RBM + DD-ZM Review',
|
||||
ZBH: 'ZBH Review',
|
||||
DD_LEAD: 'DD Lead Review',
|
||||
LEGAL: 'Legal Verification',
|
||||
DD_HEAD: 'DD Head Review',
|
||||
NBH: 'NBH Evaluation',
|
||||
SCN: 'Show Cause Notice (SCN)',
|
||||
SCN_EVAL: 'Evaluation of Dealer SCN Response',
|
||||
NBH_FINAL: 'NBH Final Approval',
|
||||
CCO: 'CCO Approval',
|
||||
CEO: 'CEO Final Approval',
|
||||
LEGAL_LETTER: 'Legal - Termination Letter',
|
||||
TERMINATED: 'Terminated'
|
||||
};
|
||||
|
||||
async function apiRequest(endpoint, method = 'GET', body = null, token = null, isFormData = false) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const config = { method, headers };
|
||||
if (body) config.body = JSON.stringify(body);
|
||||
if (body) config.body = isFormData ? body : JSON.stringify(body);
|
||||
|
||||
const response = await fetch(`${BASE_URL}${endpoint}`, config);
|
||||
const data = await response.json();
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`);
|
||||
@ -58,156 +73,317 @@ async function login(email) {
|
||||
return login.cache[email];
|
||||
}
|
||||
|
||||
const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms));
|
||||
const delay = (ms = STEP_DELAY_MS) => new Promise((res) => setTimeout(res, ms));
|
||||
const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`);
|
||||
|
||||
async function fetchTermination(terminationId, token) {
|
||||
const data = await apiRequest(`/termination/${terminationId}`, 'GET', null, token);
|
||||
return data.termination;
|
||||
}
|
||||
|
||||
async function waitUntilStage(terminationId, expectedStage, token, maxAttempts = 50) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const t = await fetchTermination(terminationId, token);
|
||||
if (t.currentStage === expectedStage) return t;
|
||||
await delay(250);
|
||||
}
|
||||
const last = await fetchTermination(terminationId, token);
|
||||
throw new Error(
|
||||
`Timed out waiting for stage "${expectedStage}" (still at "${last.currentStage}", status="${last.status}")`
|
||||
);
|
||||
}
|
||||
|
||||
async function approveTermination(terminationId, email, remarks) {
|
||||
const token = await login(email);
|
||||
return apiRequest(
|
||||
`/termination/${terminationId}/status`,
|
||||
'PUT',
|
||||
{ action: 'approve', remarks },
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
/** RBM+DD-ZM and SCN evaluation require two/four partial approvals before the stage advances. */
|
||||
async function runJointApprovals(terminationId, actors, nextStage, adminToken, stepLabel) {
|
||||
log(stepLabel, `Joint approvals at ${nextStage ? 'current' : '?'} → expect "${nextStage}"`);
|
||||
|
||||
for (const actor of actors) {
|
||||
const res = await approveTermination(terminationId, actor.email, actor.remarks);
|
||||
const message = res.message || res.data?.message || '';
|
||||
log(stepLabel, `${actor.email}: ${message}`);
|
||||
await delay(300);
|
||||
|
||||
const t = await fetchTermination(terminationId, adminToken);
|
||||
if (nextStage && t.currentStage === nextStage) {
|
||||
log(stepLabel, `Advanced to ${nextStage}`);
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
return waitUntilStage(terminationId, nextStage, adminToken);
|
||||
}
|
||||
|
||||
async function runSingleApproval(terminationId, email, remarks, nextStage, adminToken, stepLabel) {
|
||||
const res = await approveTermination(terminationId, email, remarks);
|
||||
log(stepLabel, `${email}: ${res.message || 'approved'}`);
|
||||
await delay(300);
|
||||
return waitUntilStage(terminationId, nextStage, adminToken);
|
||||
}
|
||||
|
||||
async function finalizeTermination(terminationId, email, nextStage, remarks, stepLabel, adminToken) {
|
||||
const token = await login(email);
|
||||
const res = await apiRequest(
|
||||
`/termination/${terminationId}/finalize`,
|
||||
'POST',
|
||||
{ decision: 'Approve', remarks },
|
||||
token
|
||||
);
|
||||
log(stepLabel, `${email} finalize → ${res.message || 'ok'}`);
|
||||
await delay(300);
|
||||
return waitUntilStage(terminationId, nextStage, adminToken);
|
||||
}
|
||||
|
||||
async function uploadScnResponsePlaceholder(terminationId) {
|
||||
const token = await login(EMAILS.DD_ADMIN);
|
||||
const form = new FormData();
|
||||
// Multer only allows PDF/images/office — use minimal PDF bytes
|
||||
const pdfBytes = Buffer.from(
|
||||
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>endobj\nxref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF'
|
||||
);
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
form.append('file', blob, 'scn-response-e2e.pdf');
|
||||
form.append('remarks', 'Dealer SCN response uploaded (E2E script).');
|
||||
|
||||
await apiRequest(`/termination/${terminationId}/scn-response`, 'POST', form, token, true);
|
||||
log('SCN', 'SCN response uploaded by DD Admin');
|
||||
}
|
||||
|
||||
const STAGE_ORDER = [
|
||||
STAGES.RBM,
|
||||
STAGES.ZBH,
|
||||
STAGES.DD_LEAD,
|
||||
STAGES.LEGAL,
|
||||
STAGES.DD_HEAD,
|
||||
STAGES.NBH,
|
||||
STAGES.SCN,
|
||||
STAGES.SCN_EVAL,
|
||||
STAGES.NBH_FINAL,
|
||||
STAGES.CCO,
|
||||
STAGES.CEO,
|
||||
STAGES.LEGAL_LETTER,
|
||||
STAGES.TERMINATED
|
||||
];
|
||||
|
||||
function stageIndex(stage) {
|
||||
const idx = STAGE_ORDER.indexOf(stage);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function shouldRun(resumeFromStage, targetStage) {
|
||||
return stageIndex(resumeFromStage) <= stageIndex(targetStage);
|
||||
}
|
||||
|
||||
async function runWorkflowFromStage(terminationId, resumeFromStage, adminToken, isUnethicalCategory) {
|
||||
let step = 2;
|
||||
|
||||
if (isUnethicalCategory) {
|
||||
log(step++, 'Unethical category — workflow starts at DD Lead (RBM/ZBH not used).');
|
||||
}
|
||||
|
||||
if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.RBM)) {
|
||||
await runJointApprovals(
|
||||
terminationId,
|
||||
[
|
||||
{ email: EMAILS.RBM, remarks: 'RBM validation complete.' },
|
||||
{ email: EMAILS.DD_ZM, remarks: 'DD-ZM confirmed escalation.' }
|
||||
],
|
||||
STAGES.ZBH,
|
||||
adminToken,
|
||||
step
|
||||
);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.ZBH)) {
|
||||
await runSingleApproval(terminationId, EMAILS.ZBH, 'Strategic decision aligned.', STAGES.DD_LEAD, adminToken, step++);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.DD_LEAD)) {
|
||||
await runSingleApproval(
|
||||
terminationId,
|
||||
EMAILS.DD_LEAD,
|
||||
isUnethicalCategory ? 'Immediate escalation — breaches documented.' : 'Breaches documented.',
|
||||
STAGES.LEGAL,
|
||||
adminToken,
|
||||
step++
|
||||
);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.LEGAL)) {
|
||||
await runSingleApproval(terminationId, EMAILS.LEGAL, 'Case is sound.', STAGES.DD_HEAD, adminToken, step++);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.DD_HEAD)) {
|
||||
await runSingleApproval(terminationId, EMAILS.DD_HEAD, 'Strategic impact assessed.', STAGES.NBH, adminToken, step++);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.NBH)) {
|
||||
await runSingleApproval(terminationId, EMAILS.NBH, 'Functional teams aligned.', STAGES.SCN, adminToken, step++);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.SCN)) {
|
||||
const atScn = await fetchTermination(terminationId, adminToken);
|
||||
if (atScn.currentStage === STAGES.SCN) {
|
||||
await uploadScnResponsePlaceholder(terminationId);
|
||||
await waitUntilStage(terminationId, STAGES.SCN_EVAL, adminToken);
|
||||
}
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.SCN_EVAL)) {
|
||||
await runJointApprovals(
|
||||
terminationId,
|
||||
[
|
||||
{ email: EMAILS.DD_LEAD, remarks: 'SCN response reviewed by DD Lead.' },
|
||||
{ email: EMAILS.ZBH, remarks: 'SCN response reviewed by ZBH.' },
|
||||
{ email: EMAILS.RBM, remarks: 'SCN response reviewed by RBM.' },
|
||||
{ email: EMAILS.DD_HEAD, remarks: 'SCN response reviewed by DD Head.' }
|
||||
],
|
||||
STAGES.NBH_FINAL,
|
||||
adminToken,
|
||||
step
|
||||
);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.NBH_FINAL)) {
|
||||
await finalizeTermination(terminationId, EMAILS.NBH, STAGES.CCO, 'NBH final authorization.', step++, adminToken);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.CCO)) {
|
||||
await finalizeTermination(terminationId, EMAILS.CCO, STAGES.CEO, 'CCO authorization.', step++, adminToken);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.CEO)) {
|
||||
await finalizeTermination(terminationId, EMAILS.CEO, STAGES.LEGAL_LETTER, 'CEO final authorization.', step++, adminToken);
|
||||
await delay();
|
||||
}
|
||||
|
||||
if (shouldRun(resumeFromStage, STAGES.LEGAL_LETTER)) {
|
||||
await runSingleApproval(
|
||||
terminationId,
|
||||
EMAILS.LEGAL,
|
||||
'Termination letter issued.',
|
||||
STAGES.TERMINATED,
|
||||
adminToken,
|
||||
step++
|
||||
);
|
||||
await delay();
|
||||
}
|
||||
|
||||
const afterLegal = await fetchTermination(terminationId, adminToken);
|
||||
if (afterLegal.currentStage === STAGES.TERMINATED) {
|
||||
log(step, `Terminated (status: ${afterLegal.status}). Use Push to F&F in UI when LWD is reached.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
console.log('--- STARTING DEALER TERMINATION E2E FLOW ---');
|
||||
console.log('--- STARTING DEALER TERMINATION E2E FLOW (UI-aligned) ---');
|
||||
|
||||
const adminToken = await login(EMAILS.DD_ADMIN);
|
||||
const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken);
|
||||
const targetDealer = dealersRes.data[0];
|
||||
|
||||
if (!targetDealer) throw new Error('No dealer profiles found for termination test. Run seed first.');
|
||||
|
||||
|
||||
if (!targetDealer) throw new Error('No dealer profiles found. Run seed first.');
|
||||
|
||||
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
||||
|
||||
let terminationId = args.terminationId;
|
||||
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
||||
|
||||
if (!terminationId) {
|
||||
console.log('[STEP 1] Initiating Termination...');
|
||||
log(1, 'Creating termination (ASM)...');
|
||||
const asmToken = await login(EMAILS.ASM);
|
||||
const createRes = await apiRequest('/termination', 'POST', {
|
||||
dealerId: targetDealer.id,
|
||||
category: args.category || 'Performance',
|
||||
reason: args.reason || 'Consistently failed to meet commitment targets.',
|
||||
proposedLwd: new Date().toISOString(),
|
||||
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
||||
}, asmToken);
|
||||
const createRes = await apiRequest(
|
||||
'/termination',
|
||||
'POST',
|
||||
{
|
||||
dealerId: targetDealer.id,
|
||||
category: args.category || 'Performance',
|
||||
reason: args.reason || 'Consistently failed to meet commitment targets.',
|
||||
proposedLwd: new Date().toISOString().split('T')[0],
|
||||
comments: 'E2E termination — follows UI stage order (no stacked partial approvals).'
|
||||
},
|
||||
asmToken
|
||||
);
|
||||
terminationId = createRes.termination.id;
|
||||
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}. Category: ${args.category || 'Performance'}`);
|
||||
log(1, `Created: ${terminationId} (${args.category || 'Performance'})`);
|
||||
} else {
|
||||
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
||||
log(1, `Resuming: ${terminationId}`);
|
||||
}
|
||||
|
||||
const currentTermination = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
||||
const currentStage = currentTermination?.termination?.currentStage;
|
||||
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
||||
await delay();
|
||||
|
||||
const approvals = [
|
||||
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
|
||||
{ stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] },
|
||||
{ stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] },
|
||||
{ stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] },
|
||||
{ stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] },
|
||||
{ stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] },
|
||||
{ stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] },
|
||||
{ stage: 'Personal Hearing', actors: [
|
||||
{ email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' },
|
||||
{ email: EMAILS.ZBH, remarks: 'Review recorded.' },
|
||||
{ email: EMAILS.RBM, remarks: 'Review recorded.' },
|
||||
{ email: EMAILS.DD_HEAD, remarks: 'Review recorded.' }
|
||||
] },
|
||||
{ stage: 'NBH Final Approval', actors: [{ email: EMAILS.NBH, remarks: 'Final recommendation.' }] },
|
||||
{ stage: 'CCO Approval', actors: [{ email: EMAILS.CCO, remarks: 'Approved.' }] },
|
||||
{ stage: 'CEO Final Approval', actors: [{ email: EMAILS.CEO, remarks: 'Final authorization.' }] },
|
||||
{ stage: 'Legal - Termination Letter', actors: [{ email: EMAILS.LEGAL, remarks: 'Termination letter shared.' }] }
|
||||
];
|
||||
let termination = await fetchTermination(terminationId, adminToken);
|
||||
log('INFO', `Starting stage: ${termination.currentStage} | status: ${termination.status}`);
|
||||
|
||||
const stageOrder = [
|
||||
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
|
||||
'NBH Evaluation', 'Show Cause Notice (SCN)', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
|
||||
'CEO Final Approval', 'Legal - Termination Letter', 'Terminated'
|
||||
];
|
||||
const resumeFrom = STAGE_ORDER.includes(termination.currentStage)
|
||||
? termination.currentStage
|
||||
: isUnethical
|
||||
? STAGES.DD_LEAD
|
||||
: STAGES.RBM;
|
||||
|
||||
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
|
||||
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
|
||||
let currentStep = 2;
|
||||
|
||||
for (let i = startIndex; i < approvals.length; i++) {
|
||||
const step = approvals[i];
|
||||
log(currentStep, `Stage: ${step.stage} - Processing approvals...`);
|
||||
|
||||
for (const actor of step.actors) {
|
||||
const token = await login(actor.email);
|
||||
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
||||
action: 'approve',
|
||||
remarks: actor.remarks
|
||||
}, token);
|
||||
log(currentStep, `Actor ${actor.email} Result: SUCCESS`);
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
currentStep++;
|
||||
await delay();
|
||||
if (termination.currentStage === STAGES.TERMINATED || termination.status?.includes('F&F')) {
|
||||
log('SKIP', 'Already terminated / in F&F — workflow steps skipped.');
|
||||
} else {
|
||||
await runWorkflowFromStage(terminationId, resumeFrom, adminToken, isUnethical);
|
||||
}
|
||||
|
||||
// --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) ---
|
||||
if (!SHOULD_SKIP_CLEARANCES) {
|
||||
log(13, 'Starting 16-Department F&F Clearance Flow for Termination...');
|
||||
}
|
||||
const terminationData = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
||||
const fnfId = terminationData.termination.fnfSettlement?.id;
|
||||
|
||||
if (!fnfId) {
|
||||
log('SKIP', 'FnF Settlement not initialized for this termination case.');
|
||||
} else if (!SHOULD_SKIP_CLEARANCES) {
|
||||
const departments = [
|
||||
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' },
|
||||
{ name: 'Accessories Department', status: 'Dues', amount: 15000, type: 'Receivable', remarks: 'Shortage in accessory stock.' },
|
||||
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' },
|
||||
{ name: 'RTO Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
|
||||
{ name: 'Service Department', status: 'Dues', amount: 8000, type: 'Receivable', remarks: 'Loaner vehicle damange charges.' },
|
||||
{ name: 'Parts Department', status: 'Dues', amount: 20000, type: 'Payable', remarks: 'Return parts credit.' },
|
||||
{ name: 'Finance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No interest dues.' },
|
||||
{ name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
|
||||
{ name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Inventory handed over.' },
|
||||
{ name: 'Marketing Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
|
||||
{ name: 'HR Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Staff settlement clear.' },
|
||||
{ name: 'IT Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Hardware recovered.' },
|
||||
{ name: 'Legal Department', status: 'Dues', amount: 50000, type: 'Receivable', remarks: 'Litigation cost recovery as per agreement.' },
|
||||
{ name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
|
||||
{ name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
|
||||
{ name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }
|
||||
];
|
||||
|
||||
for (const dept of departments) {
|
||||
log('13.1', `Clearing Dept: ${dept.name} [${dept.status}] - ${dept.remarks}`);
|
||||
await apiRequest(`/termination/${terminationId}/clearance`, 'PUT', {
|
||||
department: dept.name,
|
||||
status: dept.status,
|
||||
remarks: dept.remarks,
|
||||
amount: dept.amount,
|
||||
type: dept.type
|
||||
}, adminToken);
|
||||
await delay(100);
|
||||
termination = await fetchTermination(terminationId, adminToken);
|
||||
const fnfId = termination.fnfSettlement?.id;
|
||||
if (!fnfId) {
|
||||
log('F&F', 'No FnF record yet (expected until Push to F&F after LWD). Skipping clearances.');
|
||||
} else {
|
||||
log('F&F', 'Running department clearances...');
|
||||
const departments = [
|
||||
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' },
|
||||
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' }
|
||||
];
|
||||
for (const dept of departments) {
|
||||
await apiRequest(
|
||||
`/termination/${terminationId}/clearance`,
|
||||
'PUT',
|
||||
dept,
|
||||
adminToken
|
||||
);
|
||||
await delay(200);
|
||||
}
|
||||
}
|
||||
log(13, 'All 16 Departments Cleared for Termination.');
|
||||
await delay();
|
||||
}
|
||||
|
||||
const finalDetails = await fetchTermination(terminationId, adminToken);
|
||||
console.log(`[FINAL] stage=${finalDetails.currentStage} status=${finalDetails.status}`);
|
||||
|
||||
const userRes = await apiRequest('/admin/users', 'GET', null, adminToken);
|
||||
const dealerUser = userRes.data?.find((u) => u.dealerId === targetDealer.id);
|
||||
|
||||
console.log('[FINAL STEP] Verifying Terminated Status & Account Deactivation...');
|
||||
const finalDetails = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
||||
console.log(`Final Stage REACHED: ${finalDetails.termination.currentStage}`);
|
||||
|
||||
// Fetch user data to verify deactivation
|
||||
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||
const dealerUser = userRes.data.find(u => u.dealerId === targetDealer.id);
|
||||
|
||||
if (dealerUser && !dealerUser.isActive && dealerUser.status === 'inactive') {
|
||||
console.log(`[VERIFICATION] Account ${dealerUser.email} successfully DEACTIVATED.`);
|
||||
} else {
|
||||
console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`);
|
||||
throw new Error('Automated account deactivation check failed.');
|
||||
console.log(`[VERIFICATION] Dealer portal user ${dealerUser.email} deactivated.`);
|
||||
} else if (finalDetails.currentStage === STAGES.TERMINATED) {
|
||||
console.log('[VERIFICATION] Terminated — dealer deactivation may occur at Legal Letter stage.');
|
||||
}
|
||||
|
||||
console.log('\n--- VERIFICATION SUCCESSFUL ---');
|
||||
console.log('Outcome: DEALER TERMINATED & PORTAL ACCESS REVOKED');
|
||||
console.log('\n--- TERMINATION E2E COMPLETE ---');
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Workflow failed:', error.message);
|
||||
process.exit(1);
|
||||
|
||||
@ -296,280 +296,280 @@ async function triggerWorkflow() {
|
||||
remarks: 'Cleared Level 2'
|
||||
}, leadToken);
|
||||
log(5, 'Level 2 Complete.');
|
||||
await delay();
|
||||
// await delay();
|
||||
|
||||
// 6. LEVEL-3 INTERVIEW
|
||||
log(6, 'Scheduling Level 3 Interview...');
|
||||
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
||||
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
||||
// // 6. LEVEL-3 INTERVIEW
|
||||
// log(6, 'Scheduling Level 3 Interview...');
|
||||
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
||||
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
||||
|
||||
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
level: 3,
|
||||
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
||||
type: 'In-Person',
|
||||
location: 'HO',
|
||||
participants: [headUser.id, nbhUser.id]
|
||||
}, leadToken);
|
||||
const interviewId3 = intv3Response.data.id;
|
||||
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// level: 3,
|
||||
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
||||
// type: 'In-Person',
|
||||
// location: 'HO',
|
||||
// participants: [headUser.id, nbhUser.id]
|
||||
// }, leadToken);
|
||||
// const interviewId3 = intv3Response.data.id;
|
||||
|
||||
log(6.1, 'NBH Giving Feedback...');
|
||||
const nbhToken = await login(EMAILS.NBH);
|
||||
await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||
interviewId: interviewId3,
|
||||
overallScore: 10,
|
||||
feedbackItems: [
|
||||
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
|
||||
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
|
||||
],
|
||||
recommendation: 'Selected'
|
||||
}, nbhToken);
|
||||
// log(6.1, 'NBH Giving Feedback...');
|
||||
// const nbhToken = await login(EMAILS.NBH);
|
||||
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||
// interviewId: interviewId3,
|
||||
// overallScore: 10,
|
||||
// feedbackItems: [
|
||||
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
|
||||
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
|
||||
// ],
|
||||
// recommendation: 'Selected'
|
||||
// }, nbhToken);
|
||||
|
||||
log(6.15, 'DD-Head Giving Feedback...');
|
||||
const headToken = await login(EMAILS.DD_HEAD);
|
||||
await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||
interviewId: interviewId3,
|
||||
overallScore: 9.5,
|
||||
feedbackItems: [
|
||||
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
|
||||
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
|
||||
],
|
||||
recommendation: 'Selected'
|
||||
}, headToken);
|
||||
// log(6.15, 'DD-Head Giving Feedback...');
|
||||
// const headToken = await login(EMAILS.DD_HEAD);
|
||||
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||
// interviewId: interviewId3,
|
||||
// overallScore: 9.5,
|
||||
// feedbackItems: [
|
||||
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
|
||||
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
|
||||
// ],
|
||||
// recommendation: 'Selected'
|
||||
// }, headToken);
|
||||
|
||||
log(6.2, 'Head Finalizing Level 3 Decision...');
|
||||
await apiRequest('/assessment/decision', 'POST', {
|
||||
interviewId: interviewId3,
|
||||
decision: 'Approved',
|
||||
remarks: 'Cleared Level 3. Moving to FDD.'
|
||||
}, headToken);
|
||||
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
||||
await delay();
|
||||
// log(6.2, 'Head Finalizing Level 3 Decision...');
|
||||
// await apiRequest('/assessment/decision', 'POST', {
|
||||
// interviewId: interviewId3,
|
||||
// decision: 'Approved',
|
||||
// remarks: 'Cleared Level 3. Moving to FDD.'
|
||||
// }, headToken);
|
||||
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
||||
// await delay();
|
||||
|
||||
// 6.3 FDD ASSIGNMENT
|
||||
log(6.3, 'Admin Assigning Application to FDD Agency...');
|
||||
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
||||
await apiRequest('/fdd/assign', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
assignedToAgency: fddUser.id
|
||||
}, adminToken);
|
||||
log(6.3, 'FDD Agency assigned successfully.');
|
||||
await delay();
|
||||
// // 6.3 FDD ASSIGNMENT
|
||||
// log(6.3, 'Admin Assigning Application to FDD Agency...');
|
||||
// const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
||||
// await apiRequest('/fdd/assign', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// assignedToAgency: fddUser.id
|
||||
// }, adminToken);
|
||||
// log(6.3, 'FDD Agency assigned successfully.');
|
||||
// await delay();
|
||||
|
||||
// 7. FDD MILESTONE
|
||||
log(7, 'FDD Agency Discovery & Report Upload...');
|
||||
const fddToken = await login(EMAILS.FDD);
|
||||
// // 7. FDD MILESTONE
|
||||
// log(7, 'FDD Agency Discovery & Report Upload...');
|
||||
// const fddToken = await login(EMAILS.FDD);
|
||||
|
||||
// FETCH ASSIGNMENT ID
|
||||
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
||||
const assignmentId = assignmentRes.data.id;
|
||||
log(7, `Found Assignment ID: ${assignmentId}`);
|
||||
// // FETCH ASSIGNMENT ID
|
||||
// const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
||||
// const assignmentId = assignmentRes.data.id;
|
||||
// log(7, `Found Assignment ID: ${assignmentId}`);
|
||||
|
||||
await apiRequest('/fdd/report', 'POST', {
|
||||
assignmentId,
|
||||
findings: 'Finance records clean.',
|
||||
recommendation: 'Approved'
|
||||
}, fddToken);
|
||||
// await apiRequest('/fdd/report', 'POST', {
|
||||
// assignmentId,
|
||||
// findings: 'Finance records clean.',
|
||||
// recommendation: 'Approved'
|
||||
// }, fddToken);
|
||||
|
||||
log(7.1, 'Admin Approving FDD Final Stage...');
|
||||
await apiRequest('/assessment/stage-decision', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
stageCode: 'FDD_VERIFICATION',
|
||||
decision: 'Approved',
|
||||
remarks: 'FDD documents verified.'
|
||||
}, adminToken);
|
||||
log(7, 'FDD Milestone Complete.');
|
||||
await delay();
|
||||
// log(7.1, 'Admin Approving FDD Final Stage...');
|
||||
// await apiRequest('/assessment/stage-decision', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// stageCode: 'FDD_VERIFICATION',
|
||||
// decision: 'Approved',
|
||||
// remarks: 'FDD documents verified.'
|
||||
// }, adminToken);
|
||||
// log(7, 'FDD Milestone Complete.');
|
||||
// await delay();
|
||||
|
||||
log(7.4, 'Uploading mandatory documents prior to LOI generation...');
|
||||
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
|
||||
for (const doc of requiredDocs) {
|
||||
await mockUploadDocument(applicationUUID, adminToken, doc);
|
||||
}
|
||||
await delay(1000);
|
||||
// log(7.4, 'Uploading mandatory documents prior to LOI generation...');
|
||||
// const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
|
||||
// for (const doc of requiredDocs) {
|
||||
// await mockUploadDocument(applicationUUID, adminToken, doc);
|
||||
// }
|
||||
// await delay(1000);
|
||||
|
||||
// 7.5 LOI APPROVAL
|
||||
log(7.5, 'LOI Generation & Approval...');
|
||||
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
const loiRequestId = loiRes.data.id;
|
||||
// // 7.5 LOI APPROVAL
|
||||
// log(7.5, 'LOI Generation & Approval...');
|
||||
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
// const loiRequestId = loiRes.data.id;
|
||||
|
||||
// Head Approval
|
||||
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'Head Authorization for LOI'
|
||||
}, headToken);
|
||||
// // Head Approval
|
||||
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'Head Authorization for LOI'
|
||||
// }, headToken);
|
||||
|
||||
// NBH Approval
|
||||
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'NBH Authorization for LOI'
|
||||
}, nbhToken);
|
||||
// // NBH Approval
|
||||
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'NBH Authorization for LOI'
|
||||
// }, nbhToken);
|
||||
|
||||
log(7.5, 'LOI Milestone Complete.');
|
||||
await delay();
|
||||
// log(7.5, 'LOI Milestone Complete.');
|
||||
// await delay();
|
||||
|
||||
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
||||
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
||||
const financeToken = await login(EMAILS.FINANCE);
|
||||
await apiRequest('/loa/security-deposit', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
amount: 500000,
|
||||
paymentReference: 'PAY-888999',
|
||||
depositType: 'SECURITY_DEPOSIT',
|
||||
status: 'Verified'
|
||||
}, financeToken);
|
||||
log(8, 'Security Deposit Verified.')
|
||||
// 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
|
||||
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
||||
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
||||
await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
||||
await delay(300);
|
||||
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
||||
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
||||
// const financeToken = await login(EMAILS.FINANCE);
|
||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// amount: 500000,
|
||||
// paymentReference: 'PAY-888999',
|
||||
// depositType: 'SECURITY_DEPOSIT',
|
||||
// status: 'Verified'
|
||||
// }, financeToken);
|
||||
// log(8, 'Security Deposit Verified.')
|
||||
// // 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
|
||||
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
||||
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
||||
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
||||
// await delay(300);
|
||||
|
||||
if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
||||
log(9, 'Status is Security Deposit (or legacy Security Details); re-verifying Security Deposit to move to LOI Issued...');
|
||||
await apiRequest('/loa/security-deposit', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
amount: 500000,
|
||||
paymentReference: `PAY-RETRY-${Date.now()}`,
|
||||
depositType: 'SECURITY_DEPOSIT',
|
||||
status: 'Verified'
|
||||
}, financeToken);
|
||||
await delay();
|
||||
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
||||
}
|
||||
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
||||
// log(9, 'Status is Security Deposit (or legacy Security Details); re-verifying Security Deposit to move to LOI Issued...');
|
||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// amount: 500000,
|
||||
// paymentReference: `PAY-RETRY-${Date.now()}`,
|
||||
// depositType: 'SECURITY_DEPOSIT',
|
||||
// status: 'Verified'
|
||||
// }, financeToken);
|
||||
// await delay();
|
||||
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
||||
// }
|
||||
|
||||
// Current backend flow keeps app at Security Deposit until explicit admin transition.
|
||||
if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
||||
log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
|
||||
await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
||||
status: 'LOI Issued',
|
||||
stage: 'LOI',
|
||||
reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.'
|
||||
}, adminToken);
|
||||
await delay();
|
||||
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
|
||||
}
|
||||
// // Current backend flow keeps app at Security Deposit until explicit admin transition.
|
||||
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
||||
// log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
|
||||
// await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
||||
// status: 'LOI Issued',
|
||||
// stage: 'LOI',
|
||||
// reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.'
|
||||
// }, adminToken);
|
||||
// await delay();
|
||||
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||
// log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
|
||||
// }
|
||||
|
||||
if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
|
||||
throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
|
||||
}
|
||||
// if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
|
||||
// throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
|
||||
// }
|
||||
|
||||
log(9, 'Admin Generating SAP Dealer Codes...');
|
||||
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
||||
log(9, 'Dealer Codes Generated.');
|
||||
await delay();
|
||||
// log(9, 'Admin Generating SAP Dealer Codes...');
|
||||
// await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
||||
// log(9, 'Dealer Codes Generated.');
|
||||
// await delay();
|
||||
|
||||
// 10. FIRST FILL (POST CODE-GENERATION)
|
||||
log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
||||
await apiRequest('/loa/security-deposit', 'POST', {
|
||||
applicationId: applicationUUID,
|
||||
amount: 1500000,
|
||||
paymentReference: 'PAY-FIN-999',
|
||||
depositType: 'FIRST_FILL',
|
||||
status: 'Verified'
|
||||
}, financeToken);
|
||||
log(10, 'Final Security Deposit Verified.');
|
||||
await delay();
|
||||
// // 10. FIRST FILL (POST CODE-GENERATION)
|
||||
// log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
||||
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||
// applicationId: applicationUUID,
|
||||
// amount: 1500000,
|
||||
// paymentReference: 'PAY-FIN-999',
|
||||
// depositType: 'FIRST_FILL',
|
||||
// status: 'Verified'
|
||||
// }, financeToken);
|
||||
// log(10, 'Final Security Deposit Verified.');
|
||||
// await delay();
|
||||
|
||||
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||
accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||
panNumber: 'ABCDE1234F',
|
||||
gstNumber: '07ABCDE1234F1Z5',
|
||||
bankName: 'HDFC Bank',
|
||||
accountNumber: '50100223344556',
|
||||
ifscCode: 'HDFC0001234'
|
||||
}, adminToken);
|
||||
log(11, 'Statutory & Bank details updated.');
|
||||
await delay();
|
||||
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||
// log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||
// await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||
// accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||
// panNumber: 'ABCDE1234F',
|
||||
// gstNumber: '07ABCDE1234F1Z5',
|
||||
// bankName: 'HDFC Bank',
|
||||
// accountNumber: '50100223344556',
|
||||
// ifscCode: 'HDFC0001234'
|
||||
// }, adminToken);
|
||||
// log(11, 'Statutory & Bank details updated.');
|
||||
// await delay();
|
||||
|
||||
// 12. FINAL LOA APPROVAL
|
||||
log(12, 'NBH & Head Approving Final LOA...');
|
||||
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||
const finalLoaRequestId = loaRes.data.id;
|
||||
// // 12. FINAL LOA APPROVAL
|
||||
// log(12, 'NBH & Head Approving Final LOA...');
|
||||
// const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||
// const finalLoaRequestId = loaRes.data.id;
|
||||
|
||||
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'Head Authorization (Level 1)'
|
||||
}, headToken);
|
||||
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'Head Authorization (Level 1)'
|
||||
// }, headToken);
|
||||
|
||||
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
action: 'Approved',
|
||||
remarks: 'NBH Approval (Level 2)'
|
||||
}, nbhToken);
|
||||
log(12, 'LOA Fully Approved.');
|
||||
await delay();
|
||||
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||
// action: 'Approved',
|
||||
// remarks: 'NBH Approval (Level 2)'
|
||||
// }, nbhToken);
|
||||
// log(12, 'LOA Fully Approved.');
|
||||
// await delay();
|
||||
|
||||
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
const checklistId = eorInit.data.id;
|
||||
log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
||||
// // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||
// log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||
// const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
// const checklistId = eorInit.data.id;
|
||||
// log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
||||
|
||||
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||
const eorItems = [
|
||||
{ itemType: 'Sales', description: 'Sales Standards' },
|
||||
{ itemType: 'Service', description: 'Service & Spares' },
|
||||
{ itemType: 'IT', description: 'DMS infra' },
|
||||
{ itemType: 'Training', description: 'Manpower Training' },
|
||||
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||
{ itemType: 'Finance', description: 'Inventory Funding' },
|
||||
{ itemType: 'IT', description: 'Virtual code availability' },
|
||||
{ itemType: 'Finance', description: 'Vendor payments' },
|
||||
{ itemType: 'Marketing', description: 'Details for website submission' },
|
||||
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||
{ itemType: 'IT', description: 'Auto ordering' }
|
||||
];
|
||||
// log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||
// const eorItems = [
|
||||
// { itemType: 'Sales', description: 'Sales Standards' },
|
||||
// { itemType: 'Service', description: 'Service & Spares' },
|
||||
// { itemType: 'IT', description: 'DMS infra' },
|
||||
// { itemType: 'Training', description: 'Manpower Training' },
|
||||
// { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||
// { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||
// { itemType: 'Finance', description: 'Inventory Funding' },
|
||||
// { itemType: 'IT', description: 'Virtual code availability' },
|
||||
// { itemType: 'Finance', description: 'Vendor payments' },
|
||||
// { itemType: 'Marketing', description: 'Details for website submission' },
|
||||
// { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||
// { itemType: 'IT', description: 'Auto ordering' }
|
||||
// ];
|
||||
|
||||
for (const item of eorItems) {
|
||||
process.stdout.write(`.`); // Visual progress
|
||||
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||
...item,
|
||||
isCompliant: true,
|
||||
remarks: 'Verified by Auditor - Compliant'
|
||||
}, adminToken);
|
||||
}
|
||||
console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
||||
// for (const item of eorItems) {
|
||||
// process.stdout.write(`.`); // Visual progress
|
||||
// await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||
// ...item,
|
||||
// isCompliant: true,
|
||||
// remarks: 'Verified by Auditor - Compliant'
|
||||
// }, adminToken);
|
||||
// }
|
||||
// console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
||||
|
||||
log(13.2, 'Auditor Submitting Final EOR Audit...');
|
||||
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||
status: 'Completed',
|
||||
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||
}, adminToken);
|
||||
// log(13.2, 'Auditor Submitting Final EOR Audit...');
|
||||
// await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||
// status: 'Completed',
|
||||
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||
// }, adminToken);
|
||||
|
||||
// Status check
|
||||
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||
await delay();
|
||||
// // Status check
|
||||
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||
// await delay();
|
||||
|
||||
// 14. FINAL ONBOARDING
|
||||
log(14, 'Admin Finalizing Dealer Onboarding...');
|
||||
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
await delay();
|
||||
// // 14. FINAL ONBOARDING
|
||||
// log(14, 'Admin Finalizing Dealer Onboarding...');
|
||||
// await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||
// await delay();
|
||||
|
||||
// 15. VERIFICATION
|
||||
log(15, 'Verifying Dealer Record Creation...');
|
||||
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||
if (!dealerRes.success || !dealerRes.data) {
|
||||
throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||
}
|
||||
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||
// // 15. VERIFICATION
|
||||
// log(15, 'Verifying Dealer Record Creation...');
|
||||
// const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||
// if (!dealerRes.success || !dealerRes.data) {
|
||||
// throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||
// }
|
||||
// log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||
|
||||
log(15.1, 'Verifying User Account Role Update...');
|
||||
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
||||
}
|
||||
log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
|
||||
// log(15.1, 'Verifying User Account Role Update...');
|
||||
// const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||
// const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||
// if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||
// throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
||||
// }
|
||||
// log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
|
||||
|
||||
log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
||||
log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||
// log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
||||
// log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user