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
|
# File Uploads
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
MAX_FILE_SIZE=10485760
|
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 'dotenv/config';
|
||||||
import db from '../src/database/models/index.js';
|
import db from '../src/database/models/index.js';
|
||||||
|
import { SLA_STAGE_CATALOG } from '../src/common/config/slaStageCatalog.js';
|
||||||
type SlaDefault = {
|
import { seedSlaCatalogEntries } from '../src/common/utils/slaSeedUtils.js';
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function seedSlaConfigs() {
|
async function seedSlaConfigs() {
|
||||||
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any;
|
const { sequelize } = db as any;
|
||||||
await sequelize.authenticate();
|
await sequelize.authenticate();
|
||||||
console.log('Database connected.');
|
console.log('Database connected.');
|
||||||
|
|
||||||
const transaction = await sequelize.transaction();
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
try {
|
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
|
||||||
for (const item of defaults) {
|
await transaction.commit();
|
||||||
const [config, created] = await SLAConfiguration.findOrCreate({
|
console.log(
|
||||||
where: { activityName: item.stage },
|
`SLA configurations seeded successfully. Total activities: ${SLA_STAGE_CATALOG.length}`
|
||||||
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 }
|
|
||||||
);
|
);
|
||||||
}
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
|
console.error('SLA seed failed:', error);
|
||||||
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
|
throw error;
|
||||||
|
} finally {
|
||||||
await SLAReminder.bulkCreate(
|
await sequelize.close();
|
||||||
[
|
|
||||||
{ 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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
seedSlaConfigs().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
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',
|
'EOR_COMPLETED',
|
||||||
'FDD_DOCUMENT_REQUEST',
|
'FDD_DOCUMENT_REQUEST',
|
||||||
'FNF_INITIATED',
|
'FNF_INITIATED',
|
||||||
|
'FNF_LWD_READY',
|
||||||
'FNF_SUMMARY_PREPARED',
|
'FNF_SUMMARY_PREPARED',
|
||||||
'FNF_SETTLEMENT_APPROVED',
|
'FNF_SETTLEMENT_APPROVED',
|
||||||
'GENERIC_NOTIFICATION',
|
'GENERIC_NOTIFICATION',
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface SLATrackingAttributes {
|
|||||||
duration: number | null; // minutes or hours
|
duration: number | null; // minutes or hours
|
||||||
isBreached: boolean;
|
isBreached: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SLATrackingInstance extends Model<SLATrackingAttributes>, SLATrackingAttributes { }
|
export interface SLATrackingInstance extends Model<SLATrackingAttributes>, SLATrackingAttributes { }
|
||||||
@ -61,6 +62,11 @@ export default (sequelize: Sequelize) => {
|
|||||||
isActive: {
|
isActive: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: true
|
defaultValue: true
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: {}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'sla_tracking',
|
tableName: 'sla_tracking',
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import { Op } from 'sequelize';
|
|||||||
import * as EmailService from '../../common/utils/email.service.js';
|
import * as EmailService from '../../common/utils/email.service.js';
|
||||||
import { APPLICATION_STAGES, APPLICATION_STATUS, AUDIT_ACTIONS } from '../../common/config/constants.js';
|
import { APPLICATION_STAGES, APPLICATION_STATUS, AUDIT_ACTIONS } from '../../common/config/constants.js';
|
||||||
import { WorkflowService } from '../../services/WorkflowService.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';
|
import { NotificationService } from '../../services/NotificationService.js';
|
||||||
|
|
||||||
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
|
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
|
||||||
@ -677,6 +679,17 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
reason: `Interview Level ${levelNum} Scheduled`
|
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)
|
// 3. Panelist notifications (Applicant is already notified above with INTERVIEW_SCHEDULED_APPLICANT)
|
||||||
if (participantIds.length > 0) {
|
if (participantIds.length > 0) {
|
||||||
for (const userId of participantIds) {
|
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) => {
|
export const initializeDefaultSlas = async (req: Request, res: Response) => {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const defaults = [
|
const { SLA_STAGE_CATALOG } = await import('../../common/config/slaStageCatalog.js');
|
||||||
// --- ONBOARDING ---
|
const { seedSlaCatalogEntries } = await import('../../common/utils/slaSeedUtils.js');
|
||||||
{ 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' },
|
|
||||||
|
|
||||||
// --- RESIGNATION ---
|
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
|
||||||
{ 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 transaction.commit();
|
await transaction.commit();
|
||||||
|
|
||||||
@ -1818,13 +1711,16 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
|
|||||||
module: SYSTEM_AUDIT_MODULES.SLA_CONFIG,
|
module: SYSTEM_AUDIT_MODULES.SLA_CONFIG,
|
||||||
entityType: 'sla_defaults',
|
entityType: 'sla_defaults',
|
||||||
entityId: null,
|
entityId: null,
|
||||||
entityLabel: `Default SLA matrix (${defaults.length} stages)`,
|
entityLabel: `Default SLA matrix (${SLA_STAGE_CATALOG.length} stages)`,
|
||||||
action: SYSTEM_AUDIT_ACTIONS.INITIALIZED,
|
action: SYSTEM_AUDIT_ACTIONS.INITIALIZED,
|
||||||
description: `Initialized comprehensive SLA defaults across onboarding, resignation, termination, relocation, and constitutional flows (${defaults.length} stages)`,
|
description: `Initialized SLA catalog aligned to workflow stage names (${SLA_STAGE_CATALOG.length} activities)`,
|
||||||
metadata: { stageCount: defaults.length }
|
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) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
console.error('Init SLA error:', error);
|
console.error('Init SLA error:', error);
|
||||||
|
|||||||
@ -1718,29 +1718,9 @@ export const sendBulkReminders = async (req: AuthRequest, res: Response) => {
|
|||||||
where: { id: { [Op.in]: applicationIds } }
|
where: { id: { [Op.in]: applicationIds } }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { QuestionnaireReminderService } = await import('../../services/QuestionnaireReminderService.js');
|
||||||
for (const app of applications) {
|
for (const app of applications) {
|
||||||
await NotificationService.sendQuestionnaireReminder(
|
await QuestionnaireReminderService.sendReminderForApplication(app, 'manual');
|
||||||
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)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: `Reminders sent to ${applications.length} applicants` });
|
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).
|
// LWD gate applies to that manual push (SRS §4.2.2.8).
|
||||||
let shouldTriggerFnF = false;
|
let shouldTriggerFnF = false;
|
||||||
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED && targetOverride) {
|
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED && targetOverride) {
|
||||||
const today = new Date();
|
|
||||||
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||||
const { force } = req.body;
|
const { force } = req.body;
|
||||||
|
const { isLwdReached, formatLwdDisplay } = await import('../../common/utils/offboardingLwd.js');
|
||||||
|
|
||||||
const lwd = lwdString ? new Date(lwdString) : null;
|
if (!force && lwdString && !isLwdReached(lwdString)) {
|
||||||
if (lwd) {
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
lwd.setHours(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!force && lwd && today < lwd) {
|
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
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
|
canForce: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -540,6 +534,15 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
if (nextStage === RESIGNATION_STAGES.AWAITING_FNF) {
|
if (nextStage === RESIGNATION_STAGES.AWAITING_FNF) {
|
||||||
message =
|
message =
|
||||||
'DD Admin approval recorded. Use Push to F&F when ready to create the Full & Final settlement (Last Working Day rules apply).';
|
'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) {
|
} else if (sourceStage === RESIGNATION_STAGES.LEGAL && nextStage === RESIGNATION_STAGES.DD_ADMIN) {
|
||||||
message =
|
message =
|
||||||
'Legal stage approved successfully. After DD Admin review, use Push to F&F to start settlement when ready.';
|
'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();
|
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 });
|
res.json({ success: true, message: `Clearance updated for ${department}`, resignation });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (transaction) await transaction.rollback();
|
if (transaction) await transaction.rollback();
|
||||||
|
|||||||
@ -788,6 +788,9 @@ export const updateClearance = async (req: AuthRequest, res: Response) => {
|
|||||||
clearedAt: normalizedStatus !== 'Pending' ? new Date() : clearance.clearedAt
|
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
|
// Automatically update FnF progress
|
||||||
console.log(`[SettlementController] Updating clearance for F&F: ${id}`);
|
console.log(`[SettlementController] Updating clearance for F&F: ${id}`);
|
||||||
const fnfRecord = await calculateFnFLogic(id as string, req.user?.id);
|
const fnfRecord = await calculateFnFLogic(id as string, req.user?.id);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import type { AuthRequest } from '../../types/express.types.js';
|
||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { SLAConfiguration, SLATracking, SLABreach } = db;
|
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 ---
|
// --- 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) => {
|
export const getQueueStatus = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { slaQueue } = await import('../../common/queues/sla.queue.js');
|
const { slaQueue } = await import('../../common/queues/sla.queue.js');
|
||||||
|
|||||||
@ -7,6 +7,11 @@ router.use(authenticate as any);
|
|||||||
|
|
||||||
router.get('/configs', slaController.getConfigs);
|
router.get('/configs', slaController.getConfigs);
|
||||||
router.put('/configs/:id', slaController.updateConfig);
|
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('/tracking/:applicationId', slaController.getTracking);
|
||||||
router.get('/debug/queue', slaController.getQueueStatus);
|
router.get('/debug/queue', slaController.getQueueStatus);
|
||||||
|
|
||||||
|
|||||||
@ -364,6 +364,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
|
|
||||||
const fromStage = termination.currentStage;
|
const fromStage = termination.currentStage;
|
||||||
let approvedToStage: string | null = null;
|
let approvedToStage: string | null = null;
|
||||||
|
let scheduleLwdFnfReminder = false;
|
||||||
|
|
||||||
if (action === OFFBOARDING_ACTIONS.REJECT) {
|
if (action === OFFBOARDING_ACTIONS.REJECT) {
|
||||||
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
||||||
@ -391,6 +392,10 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
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.' });
|
return res.json({ success: true, message: 'Termination case placed on hold.' });
|
||||||
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
||||||
// Validation: Remarks mandatory for Revoke
|
// Validation: Remarks mandatory for Revoke
|
||||||
@ -424,24 +429,62 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
remarks
|
remarks
|
||||||
});
|
});
|
||||||
} else if (action === 'pushfnf') {
|
} 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();
|
await transaction.rollback();
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Cannot trigger F&F from ${termination.currentStage}. Complete the CEO and Legal approvals first.`
|
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}`);
|
const existingFnf = await db.FnF.findOne({
|
||||||
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction);
|
where: { terminationRequestId: termination.id },
|
||||||
|
transaction
|
||||||
// Maintain timeline visibility
|
});
|
||||||
|
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 || []), {
|
const timeline = [...(termination.timeline || []), {
|
||||||
stage: termination.currentStage,
|
stage: termination.currentStage,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
user: req.user.fullName,
|
user: req.user.fullName,
|
||||||
action: 'Forced F&F Initiation',
|
role: req.user.roleCode,
|
||||||
remarks: remarks || 'F&F settlement initiated manually via Push to F&F'
|
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({
|
await termination.update({
|
||||||
currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals
|
currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals
|
||||||
@ -630,10 +673,20 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
logger.info(
|
logger.info(
|
||||||
`[TerminationController] Termination reached TERMINATED. F&F must be started manually (Push to F&F). LWD=${termination.proposedLwd}, status=${statusAfterTerm}`
|
`[TerminationController] Termination reached TERMINATED. F&F must be started manually (Push to F&F). LWD=${termination.proposedLwd}, status=${statusAfterTerm}`
|
||||||
);
|
);
|
||||||
|
scheduleLwdFnfReminder = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
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 });
|
res.json({ success: true, message: 'Termination updated', termination });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (transaction) await transaction.rollback();
|
if (transaction) await transaction.rollback();
|
||||||
@ -913,10 +966,19 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
updatedBy: req.user.fullName
|
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 });
|
await termination.update({ departmentalClearances: clearances }, { transaction });
|
||||||
|
|
||||||
// Update individual clearance record for unified dashboard
|
// Update individual clearance record for unified dashboard
|
||||||
const fnf = await db.FnF.findOne({ where: { terminationRequestId: resolvedId } });
|
|
||||||
if (fnf) {
|
if (fnf) {
|
||||||
await db.FffClearance.update(
|
await db.FffClearance.update(
|
||||||
{ status: normalizedStatus, remarks, amount: Number(amount) || 0 },
|
{ status: normalizedStatus, remarks, amount: Number(amount) || 0 },
|
||||||
@ -943,6 +1005,12 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
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 });
|
res.json({ success: true, message: `Clearance updated for ${department}`, clearances });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (transaction) await transaction.rollback();
|
if (transaction) await transaction.rollback();
|
||||||
|
|||||||
@ -129,6 +129,13 @@ const seedTemplates = async () => {
|
|||||||
fileName: 'fnf_initiated.html',
|
fileName: 'fnf_initiated.html',
|
||||||
placeholders: ['recipientName', 'dealerName', 'requestId', 'initiatedBy', 'lwd', 'link', 'ctaLabel']
|
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',
|
templateCode: 'FNF_SUMMARY_PREPARED',
|
||||||
description: 'Notification to Finance team when F&F summary is ready for review',
|
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']
|
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 ───────────────────────────────────────────────────
|
// ── 4. EOR Completed ───────────────────────────────────────────────────
|
||||||
// Used in: eor.controller.ts (when all EOR checklist items are complete)
|
// Used in: eor.controller.ts (when all EOR checklist items are complete)
|
||||||
// Recipients: DD-Head, NBH
|
// Recipients: DD-Head, NBH
|
||||||
|
|||||||
@ -207,9 +207,19 @@ const startServer = async () => {
|
|||||||
if (process.env.ENABLE_REDIS === 'true') {
|
if (process.env.ENABLE_REDIS === 'true') {
|
||||||
const { notificationWorker } = await import('./common/queues/notification.worker.js');
|
const { notificationWorker } = await import('./common/queues/notification.worker.js');
|
||||||
const { slaWorker } = await import('./common/queues/sla.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 { 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 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');
|
logger.info('BullMQ Workers initialized and repeatable jobs scheduled');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { NotificationService } from './NotificationService.js';
|
|||||||
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
|
||||||
|
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||||
|
|
||||||
export class ConstitutionalWorkflowService {
|
export class ConstitutionalWorkflowService {
|
||||||
private static normalizeDocLabel(input: string): string {
|
private static normalizeDocLabel(input: string): string {
|
||||||
@ -120,6 +121,13 @@ export class ConstitutionalWorkflowService {
|
|||||||
|
|
||||||
await request.update(updateData);
|
await request.update(updateData);
|
||||||
|
|
||||||
|
await syncSlaOnStageTransition({
|
||||||
|
entityType: 'constitutional',
|
||||||
|
entityId: request.id,
|
||||||
|
fromStage: sourceStage,
|
||||||
|
toStage: targetStage
|
||||||
|
});
|
||||||
|
|
||||||
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) {
|
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) {
|
||||||
await ConstitutionalWorkflowService.syncDealerProfileConstitution(request, userId);
|
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 { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import logger from '../common/utils/logger.js';
|
import logger from '../common/utils/logger.js';
|
||||||
import { NotificationService } from './NotificationService.js';
|
import { NotificationService } from './NotificationService.js';
|
||||||
|
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||||
|
|
||||||
export class RelocationWorkflowService {
|
export class RelocationWorkflowService {
|
||||||
/**
|
/**
|
||||||
@ -48,6 +49,16 @@ export class RelocationWorkflowService {
|
|||||||
const updatedTimeline = [...(request.timeline || []), timelineEntry];
|
const updatedTimeline = [...(request.timeline || []), timelineEntry];
|
||||||
await request.update({ timeline: updatedTimeline });
|
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
|
// 3. Create Audit Log using standardized mapper
|
||||||
const { actionType } = metadata;
|
const { actionType } = metadata;
|
||||||
let resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RELOCATION);
|
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 { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||||
|
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||||
|
|
||||||
|
|
||||||
export class ResignationWorkflowService {
|
export class ResignationWorkflowService {
|
||||||
@ -45,6 +46,13 @@ export class ResignationWorkflowService {
|
|||||||
timeline: updatedTimeline
|
timeline: updatedTimeline
|
||||||
}, transaction ? { transaction } : undefined);
|
}, transaction ? { transaction } : undefined);
|
||||||
|
|
||||||
|
await syncSlaOnStageTransition({
|
||||||
|
entityType: 'resignation',
|
||||||
|
entityId: resignation.id,
|
||||||
|
fromStage: sourceStage,
|
||||||
|
toStage: targetStage
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Create Audit Log using standardized mapper
|
// 3. Create Audit Log using standardized mapper
|
||||||
const { actionType } = metadata;
|
const { actionType } = metadata;
|
||||||
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RESIGNATION);
|
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RESIGNATION);
|
||||||
@ -214,6 +222,9 @@ export class ResignationWorkflowService {
|
|||||||
);
|
);
|
||||||
await Promise.all(clearancePromises);
|
await Promise.all(clearancePromises);
|
||||||
|
|
||||||
|
const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js');
|
||||||
|
await startAllPendingFnfClearanceSlas(fnf.id);
|
||||||
|
|
||||||
// 3. Create Audit Trail
|
// 3. Create Audit Trail
|
||||||
await db.FnFAudit.create({
|
await db.FnFAudit.create({
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -1,324 +1,461 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db;
|
const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db;
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
|
import { slaConfigLookupNames } from '../common/config/slaStageCatalog.js';
|
||||||
|
import { effectiveElapsedMs } from '../common/utils/slaBusinessTime.js';
|
||||||
import { NotificationService } from './NotificationService.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 {
|
export class SLAService {
|
||||||
/**
|
private static async findConfigForStage(stageName: string) {
|
||||||
* Periodically check for SLA breaches, reminders and escalations
|
const names = slaConfigLookupNames(stageName);
|
||||||
*/
|
return SLAConfiguration.findOne({
|
||||||
static async checkBreaches() {
|
where: { activityName: { [Op.in]: names }, isActive: true }
|
||||||
console.log('[SLA Service] Starting SLA status check...');
|
});
|
||||||
const now = new Date();
|
}
|
||||||
|
|
||||||
// 1. Handle Active Tracks (Reminders and Initial Breach)
|
private static resolveTrackRef(
|
||||||
const activeTracking = await SLATracking.findAll({
|
refOrAppId: string | SlaTrackRef,
|
||||||
where: { isActive: true, endTime: null },
|
stageName?: string
|
||||||
include: [{ model: Application, as: 'application' }]
|
): 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) {
|
await this.logSlaActivity(
|
||||||
const config = await SLAConfiguration.findOne({
|
track,
|
||||||
where: { activityName: track.stageName, isActive: true },
|
`[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`
|
||||||
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 } }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const config = await SLAConfiguration.findOne({
|
metadata[reminderKey] = true;
|
||||||
where: { activityName: stageName, isActive: true }
|
await track.update({ metadata });
|
||||||
});
|
}
|
||||||
|
|
||||||
if (config) {
|
|
||||||
await SLATracking.create({
|
|
||||||
applicationId,
|
|
||||||
stageName,
|
|
||||||
startTime: new Date(),
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private static async triggerBreach(track: any, now: Date) {
|
||||||
* Stop tracking SLA for a stage
|
const caseLabel = await this.getCaseLabel(track);
|
||||||
*/
|
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${caseLabel}`);
|
||||||
static async stopTrack(applicationId: string, stageName: string) {
|
await track.update({ isBreached: true });
|
||||||
console.log(`[SLA Service] Stopping SLA track for App: ${applicationId}, Stage: ${stageName}`);
|
|
||||||
await SLATracking.update(
|
await SLABreach.create({
|
||||||
{ isActive: false, endTime: new Date() },
|
trackingId: track.id,
|
||||||
{ where: { applicationId, stageName, isActive: true } }
|
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 {
|
const recipientIds: string[] = [];
|
||||||
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
|
if (esc.notifyRole) {
|
||||||
|
recipientIds.push(
|
||||||
// Debug Mode: 1 hour = 1 minute (60x speedup)
|
...(await resolveRecipientsForRoles(
|
||||||
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
|
{
|
||||||
factor = factor / 60;
|
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) {
|
if (recipientIds.length === 0 && esc.notifyEmail) {
|
||||||
const msRemaining = deadline.getTime() - now.getTime();
|
await NotificationService.notify(null, esc.notifyEmail, {
|
||||||
|
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
|
||||||
for (const reminder of config.reminders || []) {
|
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
|
||||||
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
|
channels: ['email', 'system'],
|
||||||
|
templateCode: 'SLA_ESCALATION',
|
||||||
if (msRemaining <= reminderMs) {
|
placeholders: {
|
||||||
const metadata = track.metadata || {};
|
applicationId: caseLabel,
|
||||||
const reminderKey = `reminder_sent_${reminder.id}`;
|
stageName: track.stageName,
|
||||||
|
level: esc.level,
|
||||||
if (!metadata[reminderKey]) {
|
timeValue: esc.timeValue,
|
||||||
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
|
timeUnit: esc.timeUnit,
|
||||||
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
|
phone: ''
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static async triggerBreach(track: any, now: Date) {
|
for (const recipientId of recipientIds) {
|
||||||
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${track.application?.applicationId}`);
|
const user = await User.findByPk(recipientId);
|
||||||
await track.update({ isBreached: true });
|
if (!user?.email) continue;
|
||||||
|
const phone = user.mobileNumber || user.phone;
|
||||||
await SLABreach.create({
|
await NotificationService.notify(recipientId, user.email, {
|
||||||
trackingId: track.id,
|
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
|
||||||
applicationId: track.applicationId,
|
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
|
||||||
stageCode: track.stageName,
|
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
||||||
breachedAt: now,
|
templateCode: 'SLA_ESCALATION',
|
||||||
severity: 'High',
|
placeholders: {
|
||||||
status: 'Open'
|
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'
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
await this.notifyStakeholder(track, 'SLA_BREACH', {
|
console.error('[SLA Service] Failed to log application work note:', err);
|
||||||
title: `SLA BREACHED: ${track.stageName}`,
|
}
|
||||||
message: `The application ${track.application?.applicationId} has breached its SLA for ${track.stageName}.`
|
return;
|
||||||
});
|
|
||||||
|
|
||||||
// §9.4.1 — Auto-log in Work Notes
|
|
||||||
await this.logWorkNote(track.applicationId, `[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
|
const requestTypeMap: Record<string, WorkflowActivityRequestType | undefined> = {
|
||||||
const msSinceBreach = now.getTime() - deadline.getTime();
|
termination: 'termination',
|
||||||
|
resignation: 'resignation',
|
||||||
|
relocation: 'relocation',
|
||||||
|
constitutional: 'constitutional',
|
||||||
|
fnf: 'fnf'
|
||||||
|
};
|
||||||
|
const requestType = requestTypeMap[track.entityType];
|
||||||
|
if (!requestType) return;
|
||||||
|
|
||||||
for (const esc of config.escalationConfigs || []) {
|
try {
|
||||||
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
|
await writeWorkflowActivityWorknote({
|
||||||
|
requestId: track.entityId,
|
||||||
if (msSinceBreach >= escMs) {
|
requestType,
|
||||||
const metadata = track.metadata || {};
|
userId: admin.id,
|
||||||
const escKey = `esc_sent_L${esc.level}`;
|
noteText: text,
|
||||||
|
noteType: 'workflow'
|
||||||
if (!metadata[escKey]) {
|
});
|
||||||
console.log(`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'}, Email: ${esc.notifyEmail || 'N/A'})`);
|
} catch (err) {
|
||||||
|
console.error('[SLA Service] Failed to log offboarding work note:', err);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async logWorkNote(applicationId: string, text: string) {
|
private static linkForEntity(entityType: string, entityId: string): string {
|
||||||
try {
|
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
const { Worknote, User } = db;
|
switch (entityType) {
|
||||||
// Find a system user or admin to be the author
|
case 'application':
|
||||||
const admin = await User.findOne({ where: { role: 'Super Admin' } });
|
return `${base}/applications/${entityId}`;
|
||||||
await Worknote.create({
|
case 'termination':
|
||||||
applicationId,
|
return `${base}/termination/${entityId}`;
|
||||||
userId: admin?.id || (await User.findOne())?.id,
|
case 'resignation':
|
||||||
noteText: text,
|
return `${base}/resignation/${entityId}`;
|
||||||
noteType: 'system',
|
case 'relocation':
|
||||||
status: 'active'
|
return `${base}/relocation/${entityId}`;
|
||||||
});
|
case 'constitutional':
|
||||||
} catch (err) {
|
return `${base}/constitutional-change/${entityId}`;
|
||||||
console.error('[SLA Service] Failed to log work note:', err);
|
case 'fnf':
|
||||||
}
|
return `${base}/fnf/${entityId}`;
|
||||||
|
default:
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async notifyStakeholder(track: any, template: string, content: { title: string, message: string }) {
|
private static async notifyStakeholder(
|
||||||
const { Application, User, SLAConfiguration } = db;
|
track: any,
|
||||||
|
template: string,
|
||||||
// 1. Get the configuration for this stage to find the Owner Role(s)
|
content: { title: string; message: string }
|
||||||
const config = await SLAConfiguration.findOne({ where: { activityName: track.stageName, isActive: true } });
|
) {
|
||||||
if (!config || !config.ownerRole) return;
|
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 roles = config.ownerRole.split(',').map((r: string) => r.trim());
|
const recipientIds = await resolveRecipientsForRoles(
|
||||||
const application = await Application.findByPk(track.applicationId, {
|
{
|
||||||
include: [{ model: db.District, as: 'district', include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] }]
|
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>();
|
for (const userId of recipientIds) {
|
||||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
const user = await User.findByPk(userId);
|
||||||
|
if (!user) continue;
|
||||||
for (const role of roles) {
|
const phone = user.mobileNumber || user.phone || null;
|
||||||
let foundUserId = null;
|
await NotificationService.notify(userId, user.email, {
|
||||||
|
title: content.title,
|
||||||
// Resolve geography-bound roles
|
message: content.message,
|
||||||
if (application.district) {
|
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
||||||
const d = application.district;
|
templateCode: template,
|
||||||
const roleMap: Record<string, string | null> = {
|
placeholders: {
|
||||||
'ASM': d.asmId,
|
applicationId: caseLabel,
|
||||||
'DD-ZM': d.zmId,
|
stageName: track.stageName,
|
||||||
'RBM': d.region?.rbmId || null,
|
link,
|
||||||
'ZBH': d.zone?.zbhId || null
|
phone: phone || ''
|
||||||
};
|
},
|
||||||
if (roleMap[role]) foundUserId = roleMap[role];
|
metadata: {
|
||||||
}
|
entityType: track.entityType,
|
||||||
|
entityId: track.entityId,
|
||||||
if (foundUserId) {
|
applicationId: track.applicationId
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
|
||||||
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
|
||||||
import { ParticipantService } from './ParticipantService.js';
|
import { ParticipantService } from './ParticipantService.js';
|
||||||
|
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
|
||||||
|
|
||||||
export class TerminationWorkflowService {
|
export class TerminationWorkflowService {
|
||||||
/**
|
/**
|
||||||
@ -18,6 +19,7 @@ export class TerminationWorkflowService {
|
|||||||
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
|
||||||
const { action, remarks, status, transaction } = metadata;
|
const { action, remarks, status, transaction } = metadata;
|
||||||
const sourceStage = termination.currentStage;
|
const sourceStage = termination.currentStage;
|
||||||
|
const wasOnHold = String(termination.status || '').toLowerCase() === 'on hold';
|
||||||
|
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
currentStage: targetStage,
|
currentStage: targetStage,
|
||||||
@ -48,6 +50,18 @@ export class TerminationWorkflowService {
|
|||||||
timeline: updatedTimeline
|
timeline: updatedTimeline
|
||||||
}, transaction ? { transaction } : undefined);
|
}, 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
|
// 4. Create Audit Log using standardized mapper
|
||||||
const { actionType } = metadata;
|
const { actionType } = metadata;
|
||||||
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.TERMINATION);
|
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
|
// 1. Get Dealer User with associated Outlets
|
||||||
const dealerUser = await User.findOne({
|
const dealerUser = await User.findOne({
|
||||||
where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER },
|
where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER },
|
||||||
@ -180,25 +204,47 @@ export class TerminationWorkflowService {
|
|||||||
let fnfId = fnf?.id;
|
let fnfId = fnf?.id;
|
||||||
|
|
||||||
if (!fnf) {
|
if (!fnf) {
|
||||||
fnf = await db.FnF.create({
|
fnf = await db.FnF.create(
|
||||||
settlementId: await NomenclatureService.generateFnFId(),
|
{
|
||||||
terminationRequestId: termination.id,
|
settlementId: await NomenclatureService.generateFnFId(),
|
||||||
dealerId: termination.dealerId,
|
terminationRequestId: termination.id,
|
||||||
outletId: primaryOutlet?.id || null,
|
dealerId: termination.dealerId,
|
||||||
status: 'Initiated',
|
outletId: primaryOutlet?.id || null,
|
||||||
totalReceivables: 0,
|
status: 'Initiated',
|
||||||
totalPayables: 0,
|
totalReceivables: 0,
|
||||||
netAmount: 0
|
totalPayables: 0,
|
||||||
});
|
netAmount: 0
|
||||||
|
},
|
||||||
|
transaction ? { transaction } : undefined
|
||||||
|
);
|
||||||
|
|
||||||
await db.FffClearance.bulkCreate(
|
await db.FffClearance.bulkCreate(
|
||||||
FNF_DEPARTMENTS.map(dept => ({
|
FNF_DEPARTMENTS.map(dept => ({
|
||||||
fnfId: fnf.id,
|
fnfId: fnf.id,
|
||||||
department: dept,
|
department: dept,
|
||||||
status: 'Pending'
|
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;
|
fnfId = fnf.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
const { Application, ApplicationStatusHistory, User, Dealer } = db;
|
const { Application, ApplicationStatusHistory, User, Dealer } = db;
|
||||||
import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } 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 './SLAService.js';
|
||||||
import {
|
import {
|
||||||
AUDIT_ACTIONS,
|
AUDIT_ACTIONS,
|
||||||
APPLICATION_STAGES,
|
APPLICATION_STAGES,
|
||||||
OVERALL_STATUS_TO_DB_CURRENT_STAGE,
|
OVERALL_STATUS_TO_DB_CURRENT_STAGE,
|
||||||
} from '../common/config/constants.js';
|
} from '../common/config/constants.js';
|
||||||
import { NotificationService } from './NotificationService.js';
|
import { NotificationService } from './NotificationService.js';
|
||||||
import { SLAService } from './SLAService.js';
|
|
||||||
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
|
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
|
||||||
|
|
||||||
export class WorkflowService {
|
export class WorkflowService {
|
||||||
@ -106,9 +107,31 @@ export class WorkflowService {
|
|||||||
try {
|
try {
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
|
|
||||||
// SLA Tracking
|
// SLA Tracking — use pipeline milestone label when available (matches slaStageCatalog)
|
||||||
if (previousStage) tasks.push(SLAService.stopTrack(application.id, previousStage).catch(e => console.error('[WorkflowService] SLA stop failed:', e)));
|
const slaPrevStage =
|
||||||
if (stageForDbColumn) tasks.push(SLAService.startTrack(application.id, stageForDbColumn).catch(e => console.error('[WorkflowService] SLA start failed:', e)));
|
(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
|
// Progress Sync
|
||||||
tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e)));
|
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(
|
const args = Object.fromEntries(
|
||||||
process.argv.slice(2)
|
process.argv
|
||||||
.map(arg => arg.replace(/^--/, '').split('='))
|
.slice(2)
|
||||||
|
.map((arg) => arg.replace(/^--/, '').split('='))
|
||||||
.map(([k, v]) => [k, v ?? 'true'])
|
.map(([k, v]) => [k, v ?? 'true'])
|
||||||
);
|
);
|
||||||
const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api';
|
const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api';
|
||||||
const PASSWORD = 'Admin@123';
|
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 SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true';
|
||||||
|
|
||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
@ -18,31 +27,37 @@ const EMAILS = {
|
|||||||
DD_HEAD: 'ganesh@royalenfield.com',
|
DD_HEAD: 'ganesh@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
NBH: 'yashwin@royalenfield.com',
|
NBH: 'yashwin@royalenfield.com',
|
||||||
CCO: 'admin@royalenfield.com',
|
CCO: 'cco@royalenfield.com',
|
||||||
CEO: 'admin@royalenfield.com',
|
CEO: 'ceo@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'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
/** Canonical stage labels (match TERMINATION_STAGES + UI timeline). */
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
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 (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
const config = { method, headers };
|
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 response = await fetch(`${BASE_URL}${endpoint}`, config);
|
||||||
const data = await response.json();
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`);
|
throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`);
|
||||||
@ -58,156 +73,317 @@ async function login(email) {
|
|||||||
return login.cache[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}`);
|
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() {
|
async function run() {
|
||||||
try {
|
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 adminToken = await login(EMAILS.DD_ADMIN);
|
||||||
const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken);
|
const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken);
|
||||||
const targetDealer = dealersRes.data[0];
|
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})`);
|
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
|
||||||
|
|
||||||
let terminationId = args.terminationId;
|
let terminationId = args.terminationId;
|
||||||
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
|
||||||
|
|
||||||
if (!terminationId) {
|
if (!terminationId) {
|
||||||
console.log('[STEP 1] Initiating Termination...');
|
log(1, 'Creating termination (ASM)...');
|
||||||
const asmToken = await login(EMAILS.ASM);
|
const asmToken = await login(EMAILS.ASM);
|
||||||
const createRes = await apiRequest('/termination', 'POST', {
|
const createRes = await apiRequest(
|
||||||
dealerId: targetDealer.id,
|
'/termination',
|
||||||
category: args.category || 'Performance',
|
'POST',
|
||||||
reason: args.reason || 'Consistently failed to meet commitment targets.',
|
{
|
||||||
proposedLwd: new Date().toISOString(),
|
dealerId: targetDealer.id,
|
||||||
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
category: args.category || 'Performance',
|
||||||
}, asmToken);
|
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;
|
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 {
|
} else {
|
||||||
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
|
log(1, `Resuming: ${terminationId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTermination = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
await delay();
|
||||||
const currentStage = currentTermination?.termination?.currentStage;
|
|
||||||
console.log(`[INFO] Current stage before progression: ${currentStage}`);
|
|
||||||
|
|
||||||
const approvals = [
|
let termination = await fetchTermination(terminationId, adminToken);
|
||||||
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
|
log('INFO', `Starting stage: ${termination.currentStage} | status: ${termination.status}`);
|
||||||
{ 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.' }] }
|
|
||||||
];
|
|
||||||
|
|
||||||
const stageOrder = [
|
const resumeFrom = STAGE_ORDER.includes(termination.currentStage)
|
||||||
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
|
? termination.currentStage
|
||||||
'NBH Evaluation', 'Show Cause Notice (SCN)', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
|
: isUnethical
|
||||||
'CEO Final Approval', 'Legal - Termination Letter', 'Terminated'
|
? STAGES.DD_LEAD
|
||||||
];
|
: STAGES.RBM;
|
||||||
|
|
||||||
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
|
if (termination.currentStage === STAGES.TERMINATED || termination.status?.includes('F&F')) {
|
||||||
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
|
log('SKIP', 'Already terminated / in F&F — workflow steps skipped.');
|
||||||
let currentStep = 2;
|
} else {
|
||||||
|
await runWorkflowFromStage(terminationId, resumeFrom, adminToken, isUnethical);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) ---
|
|
||||||
if (!SHOULD_SKIP_CLEARANCES) {
|
if (!SHOULD_SKIP_CLEARANCES) {
|
||||||
log(13, 'Starting 16-Department F&F Clearance Flow for Termination...');
|
termination = await fetchTermination(terminationId, adminToken);
|
||||||
}
|
const fnfId = termination.fnfSettlement?.id;
|
||||||
const terminationData = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
if (!fnfId) {
|
||||||
const fnfId = terminationData.termination.fnfSettlement?.id;
|
log('F&F', 'No FnF record yet (expected until Push to F&F after LWD). Skipping clearances.');
|
||||||
|
} else {
|
||||||
if (!fnfId) {
|
log('F&F', 'Running department clearances...');
|
||||||
log('SKIP', 'FnF Settlement not initialized for this termination case.');
|
const departments = [
|
||||||
} else if (!SHOULD_SKIP_CLEARANCES) {
|
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' },
|
||||||
const departments = [
|
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' }
|
||||||
{ 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.' },
|
for (const dept of departments) {
|
||||||
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' },
|
await apiRequest(
|
||||||
{ name: 'RTO Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
|
`/termination/${terminationId}/clearance`,
|
||||||
{ name: 'Service Department', status: 'Dues', amount: 8000, type: 'Receivable', remarks: 'Loaner vehicle damange charges.' },
|
'PUT',
|
||||||
{ name: 'Parts Department', status: 'Dues', amount: 20000, type: 'Payable', remarks: 'Return parts credit.' },
|
dept,
|
||||||
{ name: 'Finance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No interest dues.' },
|
adminToken
|
||||||
{ 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.' },
|
await delay(200);
|
||||||
{ 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);
|
|
||||||
}
|
}
|
||||||
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') {
|
if (dealerUser && !dealerUser.isActive && dealerUser.status === 'inactive') {
|
||||||
console.log(`[VERIFICATION] Account ${dealerUser.email} successfully DEACTIVATED.`);
|
console.log(`[VERIFICATION] Dealer portal user ${dealerUser.email} deactivated.`);
|
||||||
} else {
|
} else if (finalDetails.currentStage === STAGES.TERMINATED) {
|
||||||
console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`);
|
console.log('[VERIFICATION] Terminated — dealer deactivation may occur at Legal Letter stage.');
|
||||||
throw new Error('Automated account deactivation check failed.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n--- VERIFICATION SUCCESSFUL ---');
|
console.log('\n--- TERMINATION E2E COMPLETE ---');
|
||||||
console.log('Outcome: DEALER TERMINATED & PORTAL ACCESS REVOKED');
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Workflow failed:', error.message);
|
console.error('Workflow failed:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@ -296,280 +296,280 @@ async function triggerWorkflow() {
|
|||||||
remarks: 'Cleared Level 2'
|
remarks: 'Cleared Level 2'
|
||||||
}, leadToken);
|
}, leadToken);
|
||||||
log(5, 'Level 2 Complete.');
|
log(5, 'Level 2 Complete.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 6. LEVEL-3 INTERVIEW
|
// // 6. LEVEL-3 INTERVIEW
|
||||||
log(6, 'Scheduling Level 3 Interview...');
|
// log(6, 'Scheduling Level 3 Interview...');
|
||||||
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
|
||||||
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
|
||||||
|
|
||||||
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
|
||||||
applicationId: applicationUUID,
|
// applicationId: applicationUUID,
|
||||||
level: 3,
|
// level: 3,
|
||||||
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
|
||||||
type: 'In-Person',
|
// type: 'In-Person',
|
||||||
location: 'HO',
|
// location: 'HO',
|
||||||
participants: [headUser.id, nbhUser.id]
|
// participants: [headUser.id, nbhUser.id]
|
||||||
}, leadToken);
|
// }, leadToken);
|
||||||
const interviewId3 = intv3Response.data.id;
|
// const interviewId3 = intv3Response.data.id;
|
||||||
|
|
||||||
log(6.1, 'NBH Giving Feedback...');
|
// log(6.1, 'NBH Giving Feedback...');
|
||||||
const nbhToken = await login(EMAILS.NBH);
|
// const nbhToken = await login(EMAILS.NBH);
|
||||||
await apiRequest('/assessment/level2-feedback', 'POST', {
|
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||||
interviewId: interviewId3,
|
// interviewId: interviewId3,
|
||||||
overallScore: 10,
|
// overallScore: 10,
|
||||||
feedbackItems: [
|
// feedbackItems: [
|
||||||
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
|
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
|
||||||
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
|
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
|
||||||
],
|
// ],
|
||||||
recommendation: 'Selected'
|
// recommendation: 'Selected'
|
||||||
}, nbhToken);
|
// }, nbhToken);
|
||||||
|
|
||||||
log(6.15, 'DD-Head Giving Feedback...');
|
// log(6.15, 'DD-Head Giving Feedback...');
|
||||||
const headToken = await login(EMAILS.DD_HEAD);
|
// const headToken = await login(EMAILS.DD_HEAD);
|
||||||
await apiRequest('/assessment/level2-feedback', 'POST', {
|
// await apiRequest('/assessment/level2-feedback', 'POST', {
|
||||||
interviewId: interviewId3,
|
// interviewId: interviewId3,
|
||||||
overallScore: 9.5,
|
// overallScore: 9.5,
|
||||||
feedbackItems: [
|
// feedbackItems: [
|
||||||
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
|
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
|
||||||
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
|
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
|
||||||
],
|
// ],
|
||||||
recommendation: 'Selected'
|
// recommendation: 'Selected'
|
||||||
}, headToken);
|
// }, headToken);
|
||||||
|
|
||||||
log(6.2, 'Head Finalizing Level 3 Decision...');
|
// log(6.2, 'Head Finalizing Level 3 Decision...');
|
||||||
await apiRequest('/assessment/decision', 'POST', {
|
// await apiRequest('/assessment/decision', 'POST', {
|
||||||
interviewId: interviewId3,
|
// interviewId: interviewId3,
|
||||||
decision: 'Approved',
|
// decision: 'Approved',
|
||||||
remarks: 'Cleared Level 3. Moving to FDD.'
|
// remarks: 'Cleared Level 3. Moving to FDD.'
|
||||||
}, headToken);
|
// }, headToken);
|
||||||
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 6.3 FDD ASSIGNMENT
|
// // 6.3 FDD ASSIGNMENT
|
||||||
log(6.3, 'Admin Assigning Application to FDD Agency...');
|
// log(6.3, 'Admin Assigning Application to FDD Agency...');
|
||||||
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
// const fddUser = users.data.find(u => u.email === EMAILS.FDD);
|
||||||
await apiRequest('/fdd/assign', 'POST', {
|
// await apiRequest('/fdd/assign', 'POST', {
|
||||||
applicationId: applicationUUID,
|
// applicationId: applicationUUID,
|
||||||
assignedToAgency: fddUser.id
|
// assignedToAgency: fddUser.id
|
||||||
}, adminToken);
|
// }, adminToken);
|
||||||
log(6.3, 'FDD Agency assigned successfully.');
|
// log(6.3, 'FDD Agency assigned successfully.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 7. FDD MILESTONE
|
// // 7. FDD MILESTONE
|
||||||
log(7, 'FDD Agency Discovery & Report Upload...');
|
// log(7, 'FDD Agency Discovery & Report Upload...');
|
||||||
const fddToken = await login(EMAILS.FDD);
|
// const fddToken = await login(EMAILS.FDD);
|
||||||
|
|
||||||
// FETCH ASSIGNMENT ID
|
// // FETCH ASSIGNMENT ID
|
||||||
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
// const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
|
||||||
const assignmentId = assignmentRes.data.id;
|
// const assignmentId = assignmentRes.data.id;
|
||||||
log(7, `Found Assignment ID: ${assignmentId}`);
|
// log(7, `Found Assignment ID: ${assignmentId}`);
|
||||||
|
|
||||||
await apiRequest('/fdd/report', 'POST', {
|
// await apiRequest('/fdd/report', 'POST', {
|
||||||
assignmentId,
|
// assignmentId,
|
||||||
findings: 'Finance records clean.',
|
// findings: 'Finance records clean.',
|
||||||
recommendation: 'Approved'
|
// recommendation: 'Approved'
|
||||||
}, fddToken);
|
// }, fddToken);
|
||||||
|
|
||||||
log(7.1, 'Admin Approving FDD Final Stage...');
|
// log(7.1, 'Admin Approving FDD Final Stage...');
|
||||||
await apiRequest('/assessment/stage-decision', 'POST', {
|
// await apiRequest('/assessment/stage-decision', 'POST', {
|
||||||
applicationId: applicationUUID,
|
// applicationId: applicationUUID,
|
||||||
stageCode: 'FDD_VERIFICATION',
|
// stageCode: 'FDD_VERIFICATION',
|
||||||
decision: 'Approved',
|
// decision: 'Approved',
|
||||||
remarks: 'FDD documents verified.'
|
// remarks: 'FDD documents verified.'
|
||||||
}, adminToken);
|
// }, adminToken);
|
||||||
log(7, 'FDD Milestone Complete.');
|
// log(7, 'FDD Milestone Complete.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
log(7.4, 'Uploading mandatory documents prior to LOI generation...');
|
// log(7.4, 'Uploading mandatory documents prior to LOI generation...');
|
||||||
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
|
// const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
|
||||||
for (const doc of requiredDocs) {
|
// for (const doc of requiredDocs) {
|
||||||
await mockUploadDocument(applicationUUID, adminToken, doc);
|
// await mockUploadDocument(applicationUUID, adminToken, doc);
|
||||||
}
|
// }
|
||||||
await delay(1000);
|
// await delay(1000);
|
||||||
|
|
||||||
// 7.5 LOI APPROVAL
|
// // 7.5 LOI APPROVAL
|
||||||
log(7.5, 'LOI Generation & Approval...');
|
// log(7.5, 'LOI Generation & Approval...');
|
||||||
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
const loiRequestId = loiRes.data.id;
|
// const loiRequestId = loiRes.data.id;
|
||||||
|
|
||||||
// Head Approval
|
// // Head Approval
|
||||||
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||||
action: 'Approved',
|
// action: 'Approved',
|
||||||
remarks: 'Head Authorization for LOI'
|
// remarks: 'Head Authorization for LOI'
|
||||||
}, headToken);
|
// }, headToken);
|
||||||
|
|
||||||
// NBH Approval
|
// // NBH Approval
|
||||||
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
|
||||||
action: 'Approved',
|
// action: 'Approved',
|
||||||
remarks: 'NBH Authorization for LOI'
|
// remarks: 'NBH Authorization for LOI'
|
||||||
}, nbhToken);
|
// }, nbhToken);
|
||||||
|
|
||||||
log(7.5, 'LOI Milestone Complete.');
|
// log(7.5, 'LOI Milestone Complete.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
|
||||||
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
|
||||||
const financeToken = await login(EMAILS.FINANCE);
|
// const financeToken = await login(EMAILS.FINANCE);
|
||||||
await apiRequest('/loa/security-deposit', 'POST', {
|
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
applicationId: applicationUUID,
|
// applicationId: applicationUUID,
|
||||||
amount: 500000,
|
// amount: 500000,
|
||||||
paymentReference: 'PAY-888999',
|
// paymentReference: 'PAY-888999',
|
||||||
depositType: 'SECURITY_DEPOSIT',
|
// depositType: 'SECURITY_DEPOSIT',
|
||||||
status: 'Verified'
|
// status: 'Verified'
|
||||||
}, financeToken);
|
// }, financeToken);
|
||||||
log(8, 'Security Deposit Verified.')
|
// log(8, 'Security Deposit Verified.')
|
||||||
// 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
|
// // 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
|
||||||
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||||
log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
|
||||||
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
|
||||||
await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
|
||||||
await delay(300);
|
// await delay(300);
|
||||||
|
|
||||||
if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
// 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...');
|
// 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', {
|
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
applicationId: applicationUUID,
|
// applicationId: applicationUUID,
|
||||||
amount: 500000,
|
// amount: 500000,
|
||||||
paymentReference: `PAY-RETRY-${Date.now()}`,
|
// paymentReference: `PAY-RETRY-${Date.now()}`,
|
||||||
depositType: 'SECURITY_DEPOSIT',
|
// depositType: 'SECURITY_DEPOSIT',
|
||||||
status: 'Verified'
|
// status: 'Verified'
|
||||||
}, financeToken);
|
// }, financeToken);
|
||||||
await delay();
|
// await delay();
|
||||||
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||||
log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Current backend flow keeps app at Security Deposit until explicit admin transition.
|
// // Current backend flow keeps app at Security Deposit until explicit admin transition.
|
||||||
if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
|
||||||
log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
|
// log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
|
||||||
await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
// await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
||||||
status: 'LOI Issued',
|
// status: 'LOI Issued',
|
||||||
stage: 'LOI',
|
// stage: 'LOI',
|
||||||
reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.'
|
// reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.'
|
||||||
}, adminToken);
|
// }, adminToken);
|
||||||
await delay();
|
// await delay();
|
||||||
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
|
||||||
log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
|
// log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
|
// if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
|
||||||
throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
|
// throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
log(9, 'Admin Generating SAP Dealer Codes...');
|
// log(9, 'Admin Generating SAP Dealer Codes...');
|
||||||
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
// await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
|
||||||
log(9, 'Dealer Codes Generated.');
|
// log(9, 'Dealer Codes Generated.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 10. FIRST FILL (POST CODE-GENERATION)
|
// // 10. FIRST FILL (POST CODE-GENERATION)
|
||||||
log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
// log(10, 'Finance Verifying FIRST FILL (₹15L)...');
|
||||||
await apiRequest('/loa/security-deposit', 'POST', {
|
// await apiRequest('/loa/security-deposit', 'POST', {
|
||||||
applicationId: applicationUUID,
|
// applicationId: applicationUUID,
|
||||||
amount: 1500000,
|
// amount: 1500000,
|
||||||
paymentReference: 'PAY-FIN-999',
|
// paymentReference: 'PAY-FIN-999',
|
||||||
depositType: 'FIRST_FILL',
|
// depositType: 'FIRST_FILL',
|
||||||
status: 'Verified'
|
// status: 'Verified'
|
||||||
}, financeToken);
|
// }, financeToken);
|
||||||
log(10, 'Final Security Deposit Verified.');
|
// log(10, 'Final Security Deposit Verified.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||||
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
// log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||||
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
// await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||||
accountHolderName: 'Ramesh Automobiles Private Limited',
|
// accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||||
panNumber: 'ABCDE1234F',
|
// panNumber: 'ABCDE1234F',
|
||||||
gstNumber: '07ABCDE1234F1Z5',
|
// gstNumber: '07ABCDE1234F1Z5',
|
||||||
bankName: 'HDFC Bank',
|
// bankName: 'HDFC Bank',
|
||||||
accountNumber: '50100223344556',
|
// accountNumber: '50100223344556',
|
||||||
ifscCode: 'HDFC0001234'
|
// ifscCode: 'HDFC0001234'
|
||||||
}, adminToken);
|
// }, adminToken);
|
||||||
log(11, 'Statutory & Bank details updated.');
|
// log(11, 'Statutory & Bank details updated.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 12. FINAL LOA APPROVAL
|
// // 12. FINAL LOA APPROVAL
|
||||||
log(12, 'NBH & Head Approving Final LOA...');
|
// log(12, 'NBH & Head Approving Final LOA...');
|
||||||
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
// const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
|
||||||
const finalLoaRequestId = loaRes.data.id;
|
// const finalLoaRequestId = loaRes.data.id;
|
||||||
|
|
||||||
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||||
action: 'Approved',
|
// action: 'Approved',
|
||||||
remarks: 'Head Authorization (Level 1)'
|
// remarks: 'Head Authorization (Level 1)'
|
||||||
}, headToken);
|
// }, headToken);
|
||||||
|
|
||||||
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
|
||||||
action: 'Approved',
|
// action: 'Approved',
|
||||||
remarks: 'NBH Approval (Level 2)'
|
// remarks: 'NBH Approval (Level 2)'
|
||||||
}, nbhToken);
|
// }, nbhToken);
|
||||||
log(12, 'LOA Fully Approved.');
|
// log(12, 'LOA Fully Approved.');
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
// // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||||
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
// log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||||
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
// const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
const checklistId = eorInit.data.id;
|
// const checklistId = eorInit.data.id;
|
||||||
log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
// log(13, `EOR Checklist Created (ID: ${checklistId})`);
|
||||||
|
|
||||||
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
// log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||||
const eorItems = [
|
// const eorItems = [
|
||||||
{ itemType: 'Sales', description: 'Sales Standards' },
|
// { itemType: 'Sales', description: 'Sales Standards' },
|
||||||
{ itemType: 'Service', description: 'Service & Spares' },
|
// { itemType: 'Service', description: 'Service & Spares' },
|
||||||
{ itemType: 'IT', description: 'DMS infra' },
|
// { itemType: 'IT', description: 'DMS infra' },
|
||||||
{ itemType: 'Training', description: 'Manpower Training' },
|
// { itemType: 'Training', description: 'Manpower Training' },
|
||||||
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
// { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||||
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
// { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||||
{ itemType: 'Finance', description: 'Inventory Funding' },
|
// { itemType: 'Finance', description: 'Inventory Funding' },
|
||||||
{ itemType: 'IT', description: 'Virtual code availability' },
|
// { itemType: 'IT', description: 'Virtual code availability' },
|
||||||
{ itemType: 'Finance', description: 'Vendor payments' },
|
// { itemType: 'Finance', description: 'Vendor payments' },
|
||||||
{ itemType: 'Marketing', description: 'Details for website submission' },
|
// { itemType: 'Marketing', description: 'Details for website submission' },
|
||||||
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
// { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||||
{ itemType: 'IT', description: 'Auto ordering' }
|
// { itemType: 'IT', description: 'Auto ordering' }
|
||||||
];
|
// ];
|
||||||
|
|
||||||
for (const item of eorItems) {
|
// for (const item of eorItems) {
|
||||||
process.stdout.write(`.`); // Visual progress
|
// process.stdout.write(`.`); // Visual progress
|
||||||
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
// await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||||
...item,
|
// ...item,
|
||||||
isCompliant: true,
|
// isCompliant: true,
|
||||||
remarks: 'Verified by Auditor - Compliant'
|
// remarks: 'Verified by Auditor - Compliant'
|
||||||
}, adminToken);
|
// }, adminToken);
|
||||||
}
|
// }
|
||||||
console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
// console.log('\n[STEP 13.1] All EOR items marked as compliant.');
|
||||||
|
|
||||||
log(13.2, 'Auditor Submitting Final EOR Audit...');
|
// log(13.2, 'Auditor Submitting Final EOR Audit...');
|
||||||
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
// await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||||
status: 'Completed',
|
// status: 'Completed',
|
||||||
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||||
}, adminToken);
|
// }, adminToken);
|
||||||
|
|
||||||
// Status check
|
// // Status check
|
||||||
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||||
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 14. FINAL ONBOARDING
|
// // 14. FINAL ONBOARDING
|
||||||
log(14, 'Admin Finalizing Dealer Onboarding...');
|
// log(14, 'Admin Finalizing Dealer Onboarding...');
|
||||||
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
// await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
await delay();
|
// await delay();
|
||||||
|
|
||||||
// 15. VERIFICATION
|
// // 15. VERIFICATION
|
||||||
log(15, 'Verifying Dealer Record Creation...');
|
// log(15, 'Verifying Dealer Record Creation...');
|
||||||
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
// const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||||
if (!dealerRes.success || !dealerRes.data) {
|
// if (!dealerRes.success || !dealerRes.data) {
|
||||||
throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
// throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||||
}
|
// }
|
||||||
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
// log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||||
|
|
||||||
log(15.1, 'Verifying User Account Role Update...');
|
// log(15.1, 'Verifying User Account Role Update...');
|
||||||
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
// const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||||
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
// const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||||
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
// if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||||
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
// 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, `User role confirmed: ${dealerUser.roleCode}`);
|
||||||
|
|
||||||
log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
// 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, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user