few de demo bugs and sla tracker implemeted alog with sla moitor screen

This commit is contained in:
Laxman 2026-05-18 21:08:49 +05:30
parent f5022b613d
commit f5d7ccc1ab
47 changed files with 3713 additions and 952 deletions

View File

@ -35,3 +35,18 @@ VAPID_EMAIL=admin@royalenfield.com
# File Uploads
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760
# Redis / BullMQ (required for scheduled jobs: SLA, LWD, questionnaire reminders, notifications)
ENABLE_REDIS=false
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# DEBUG_SLA_FAST_MODE=true # Internal SLA checks every minute (dev only)
# SLA_BUSINESS_HOURS=false # Disable MonFri 918h 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)

View 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, L1L3 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 MonFri 918 | **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.

View 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
View 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
View 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
```

View 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:** postF&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.

View 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);
});

View File

@ -1,120 +1,30 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
type SlaDefault = {
stage: string;
role: string;
tat: number;
unit: 'hours' | 'days';
};
const defaults: SlaDefault[] = [
// ONBOARDING
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' },
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
// RESIGNATION
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
// TERMINATION
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
// RELOCATION
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
// CONSTITUTIONAL CHANGE
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
];
import { SLA_STAGE_CATALOG } from '../src/common/config/slaStageCatalog.js';
import { seedSlaCatalogEntries } from '../src/common/utils/slaSeedUtils.js';
async function seedSlaConfigs() {
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any;
await sequelize.authenticate();
console.log('Database connected.');
const { sequelize } = db as any;
await sequelize.authenticate();
console.log('Database connected.');
const transaction = await sequelize.transaction();
try {
for (const item of defaults) {
const [config, created] = await SLAConfiguration.findOrCreate({
where: { activityName: item.stage },
defaults: {
activityName: item.stage,
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
isActive: true,
},
transaction,
});
if (!created) {
await config.update(
{
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
},
{ transaction }
const transaction = await sequelize.transaction();
try {
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
await transaction.commit();
console.log(
`SLA configurations seeded successfully. Total activities: ${SLA_STAGE_CATALOG.length}`
);
}
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
await SLAReminder.bulkCreate(
[
{ slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true },
{ slaConfigId: config.id, timeValue: 4, timeUnit: 'hours', isEnabled: true },
],
{ transaction }
);
await SLAEscalationConfig.bulkCreate(
[
{ slaConfigId: config.id, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
{ slaConfigId: config.id, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
{ slaConfigId: config.id, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' },
],
{ transaction }
);
} catch (error) {
await transaction.rollback();
console.error('SLA seed failed:', error);
throw error;
} finally {
await sequelize.close();
}
await transaction.commit();
console.log(`SLA configurations seeded successfully. Total stages: ${defaults.length}`);
} catch (error) {
await transaction.rollback();
console.error('SLA seed failed:', error);
throw error;
} finally {
await sequelize.close();
}
}
seedSlaConfigs().catch((err) => {
console.error(err);
process.exit(1);
console.error(err);
process.exit(1);
});

View 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);
});
});

View 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;

View 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'}`
);
};

View 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}`);
});

View 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'}`
);
};

View 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}`);
});

View 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;
}

View File

@ -0,0 +1,54 @@
/** SRS §9.4.5 — business hours 09:0018:00, MonFri (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;
}

View 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')));
}

View 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`
};
}

View 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
}
);
}
}

View 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);
}
}

View File

@ -16,6 +16,7 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'EOR_COMPLETED',
'FDD_DOCUMENT_REQUEST',
'FNF_INITIATED',
'FNF_LWD_READY',
'FNF_SUMMARY_PREPARED',
'FNF_SETTLEMENT_APPROVED',
'GENERIC_NOTIFICATION',

View File

@ -11,6 +11,7 @@ export interface SLATrackingAttributes {
duration: number | null; // minutes or hours
isBreached: boolean;
isActive: boolean;
metadata?: Record<string, unknown> | null;
}
export interface SLATrackingInstance extends Model<SLATrackingAttributes>, SLATrackingAttributes { }
@ -61,6 +62,11 @@ export default (sequelize: Sequelize) => {
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
metadata: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
}
}, {
tableName: 'sla_tracking',

View File

@ -9,7 +9,9 @@ import { Op } from 'sequelize';
import * as EmailService from '../../common/utils/email.service.js';
import { APPLICATION_STAGES, APPLICATION_STATUS, AUDIT_ACTIONS } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { syncApplicationProgress } from '../../common/utils/progress.js';
import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../../common/utils/progress.js';
import { shouldTrackOnboardingSla } from '../../common/config/slaStageCatalog.js';
import { SLAService } from '../../services/SLAService.js';
import { NotificationService } from '../../services/NotificationService.js';
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
@ -677,6 +679,17 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
reason: `Interview Level ${levelNum} Scheduled`
});
// Ensure internal SLA timer runs even when status was already "Level N Interview Pending"
const pipelineStage = PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[newStatus];
if (pipelineStage && shouldTrackOnboardingSla(pipelineStage)) {
await SLAService.startTrack({
entityType: 'application',
entityId: application.id,
applicationId: application.id,
stageName: pipelineStage
}).catch((e) => console.error('[scheduleInterview] SLA start failed:', e));
}
// 3. Panelist notifications (Applicant is already notified above with INTERVIEW_SCHEDULED_APPLICANT)
if (participantIds.length > 0) {
for (const userId of participantIds) {

View File

@ -1700,117 +1700,10 @@ export const saveSlaConfig = async (req: Request, res: Response) => {
export const initializeDefaultSlas = async (req: Request, res: Response) => {
const transaction = await db.sequelize.transaction();
try {
const defaults = [
// --- ONBOARDING ---
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' }, // Per Doc §9.4.5
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
const { SLA_STAGE_CATALOG } = await import('../../common/config/slaStageCatalog.js');
const { seedSlaCatalogEntries } = await import('../../common/utils/slaSeedUtils.js');
// --- RESIGNATION ---
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
// --- TERMINATION ---
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
// --- RELOCATION ---
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
// --- CONSTITUTIONAL CHANGE ---
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' }
];
for (const item of defaults) {
const [config, created] = await db.SLAConfiguration.findOrCreate({
where: { activityName: item.stage },
defaults: {
activityName: item.stage,
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit as any,
isActive: true
},
transaction
});
if (!created) {
// Update existing to match new standard defaults if they haven't been customized too much?
// Actually, let's just make sure they have the right roles as per new document alignment
await config.update({
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit as any
}, { transaction });
}
// Cleanup old reminders/escalations to avoid duplicates if re-running
await db.SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
await db.SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
// 1. Default Reminders (Per Doc §9.4.5: T-24h and T-4h)
await db.SLAReminder.bulkCreate([
{
slaConfigId: config.id,
timeValue: 1,
timeUnit: 'days',
isEnabled: true
},
{
slaConfigId: config.id,
timeValue: 4,
timeUnit: 'hours',
isEnabled: true
}
], { transaction });
// 2. Escalation Matrix (Per Doc §9.4.5)
// L1: +4h, L2: +12h, L3: +24h
await db.SLAEscalationConfig.bulkCreate([
{
slaConfigId: config.id,
level: 1,
timeValue: 4,
timeUnit: 'hours',
notifyRole: 'ZBH' // Example default escalation path
},
{
slaConfigId: config.id,
level: 2,
timeValue: 12,
timeUnit: 'hours',
notifyRole: 'DD Lead'
},
{
slaConfigId: config.id,
level: 3,
timeValue: 24,
timeUnit: 'hours',
notifyRole: 'NBH'
}
], { transaction });
}
await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
await transaction.commit();
@ -1818,13 +1711,16 @@ export const initializeDefaultSlas = async (req: Request, res: Response) => {
module: SYSTEM_AUDIT_MODULES.SLA_CONFIG,
entityType: 'sla_defaults',
entityId: null,
entityLabel: `Default SLA matrix (${defaults.length} stages)`,
entityLabel: `Default SLA matrix (${SLA_STAGE_CATALOG.length} stages)`,
action: SYSTEM_AUDIT_ACTIONS.INITIALIZED,
description: `Initialized comprehensive SLA defaults across onboarding, resignation, termination, relocation, and constitutional flows (${defaults.length} stages)`,
metadata: { stageCount: defaults.length }
description: `Initialized SLA catalog aligned to workflow stage names (${SLA_STAGE_CATALOG.length} activities)`,
metadata: { stageCount: SLA_STAGE_CATALOG.length }
});
res.json({ success: true, message: 'Comprehensive default SLA configurations initialized across all modules' });
res.json({
success: true,
message: `SLA configurations initialized (${SLA_STAGE_CATALOG.length} activities across all modules)`
});
} catch (error) {
await transaction.rollback();
console.error('Init SLA error:', error);

View File

@ -1718,29 +1718,9 @@ export const sendBulkReminders = async (req: AuthRequest, res: Response) => {
where: { id: { [Op.in]: applicationIds } }
});
const { QuestionnaireReminderService } = await import('../../services/QuestionnaireReminderService.js');
for (const app of applications) {
await NotificationService.sendQuestionnaireReminder(
app.email,
app.phone,
app.applicantName,
{
location: app.preferredLocation,
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/questionnaire/${app.applicationId}`
}
);
// Log Audit
await safeAuditLogCreate({
userId: req.user?.id || null,
action: 'REMINDER_SENT',
entityType: 'application',
entityId: app.id,
newData: {
template: 'QUESTIONNAIRE_REMINDER',
sentAt: new Date(),
context: pickApplicationAuditContext(app)
}
});
await QuestionnaireReminderService.sendReminderForApplication(app, 'manual');
}
res.json({ success: true, message: `Reminders sent to ${applications.length} applicants` });

View File

@ -412,21 +412,15 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
// LWD gate applies to that manual push (SRS §4.2.2.8).
let shouldTriggerFnF = false;
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED && targetOverride) {
const today = new Date();
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
const { force } = req.body;
const { isLwdReached, formatLwdDisplay } = await import('../../common/utils/offboardingLwd.js');
const lwd = lwdString ? new Date(lwdString) : null;
if (lwd) {
today.setHours(0, 0, 0, 0);
lwd.setHours(0, 0, 0, 0);
}
if (!force && lwd && today < lwd) {
if (!force && lwdString && !isLwdReached(lwdString)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `F&F settlement process is initiated only on the Last Working Day (${lwdString}) of the dealership.`,
message: `F&F settlement process is initiated only on the Last Working Day (${formatLwdDisplay(lwdString)}) of the dealership.`,
canForce: true
});
}
@ -540,6 +534,15 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
if (nextStage === RESIGNATION_STAGES.AWAITING_FNF) {
message =
'DD Admin approval recorded. Use Push to F&F when ready to create the Full & Final settlement (Last Working Day rules apply).';
const lwdForSchedule =
resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
if (lwdForSchedule) {
const { OffboardingLwdReminderService } = await import('../../services/OffboardingLwdReminderService.js');
await OffboardingLwdReminderService.scheduleDelayedReminderIfNeeded(
{ requestType: 'resignation', requestId: resignation.id },
lwdForSchedule
);
}
} else if (sourceStage === RESIGNATION_STAGES.LEGAL && nextStage === RESIGNATION_STAGES.DD_ADMIN) {
message =
'Legal stage approved successfully. After DD Admin review, use Push to F&F to start settlement when ready.';
@ -1013,6 +1016,12 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
}
await transaction.commit();
if (fnf) {
const { syncFnfClearanceSla } = await import('../../common/utils/slaFnfSync.js');
await syncFnfClearanceSla(fnf.id, department, normalizedDeptStatus);
}
res.json({ success: true, message: `Clearance updated for ${department}`, resignation });
} catch (error) {
if (transaction) await transaction.rollback();

View File

@ -788,6 +788,9 @@ export const updateClearance = async (req: AuthRequest, res: Response) => {
clearedAt: normalizedStatus !== 'Pending' ? new Date() : clearance.clearedAt
});
const { syncFnfClearanceSla } = await import('../../common/utils/slaFnfSync.js');
await syncFnfClearanceSla(String(id), clearance.department, normalizedStatus);
// Automatically update FnF progress
console.log(`[SettlementController] Updating clearance for F&F: ${id}`);
const fnfRecord = await calculateFnFLogic(id as string, req.user?.id);

View File

@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import type { AuthRequest } from '../../types/express.types.js';
import db from '../../database/models/index.js';
const { SLAConfiguration, SLATracking, SLABreach } = db;
@ -71,7 +72,93 @@ export const getTracking = async (req: Request, res: Response) => {
}
};
export const getOperationsDashboard = async (req: AuthRequest, res: Response) => {
try {
const module = typeof req.query.module === 'string' ? req.query.module : undefined;
const breachedOnly = String(req.query.breachedOnly || 'false') === 'true';
const mineOnly = String(req.query.mineOnly || 'false') === 'true';
const { SlaOperationsService } = await import('../../services/SlaOperationsService.js');
const data = await SlaOperationsService.getDashboard({
module,
breachedOnly,
mineOnly,
userRoleCode: req.user?.roleCode
});
res.json({ success: true, data });
} catch (error) {
console.error('SLA operations dashboard error:', error);
res.status(500).json({ success: false, message: 'Error loading SLA operations dashboard' });
}
};
export const getBatchStatus = async (req: AuthRequest, res: Response) => {
try {
const items = Array.isArray(req.body?.items) ? req.body.items : [];
const refs = items
.filter((i: any) => i?.entityType && i?.entityId)
.map((i: any) => ({ entityType: String(i.entityType), entityId: String(i.entityId) }));
const { SlaStatusService } = await import('../../services/SlaStatusService.js');
const data = await SlaStatusService.getBatchStatus(refs);
res.json({ success: true, data });
} catch (error) {
console.error('SLA batch status error:', error);
res.status(500).json({ success: false, message: 'Error fetching SLA status' });
}
};
export const exportOperationsCsv = async (req: AuthRequest, res: Response) => {
try {
const module = typeof req.query.module === 'string' ? req.query.module : undefined;
const mineOnly = String(req.query.mineOnly || 'false') === 'true';
const { SlaOperationsService } = await import('../../services/SlaOperationsService.js');
const data = await SlaOperationsService.getDashboard({
module,
mineOnly,
userRoleCode: req.user?.roleCode
});
const csv = SlaOperationsService.toCsv(data);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="sla-queue-${Date.now()}.csv"`);
res.send(csv);
} catch (error) {
console.error('SLA export error:', error);
res.status(500).json({ success: false, message: 'Error exporting SLA queue' });
}
};
// --- Debug Endpoint ---
export const getQuestionnaireReminderSettings = async (_req: Request, res: Response) => {
try {
const { getQuestionnaireReminderSettings } = await import(
'../../services/questionnaireReminderSettings.js'
);
const data = await getQuestionnaireReminderSettings();
res.json({ success: true, data });
} catch (error) {
console.error('Get questionnaire reminder settings error:', error);
res.status(500).json({ success: false, message: 'Error loading questionnaire reminder settings' });
}
};
export const updateQuestionnaireReminderSettings = async (req: Request, res: Response) => {
try {
const { saveQuestionnaireReminderSettings } = await import(
'../../services/questionnaireReminderSettings.js'
);
const { enabled, firstAfterDays, intervalDays, maxCount } = req.body || {};
const data = await saveQuestionnaireReminderSettings({
enabled: enabled !== undefined ? Boolean(enabled) : undefined,
firstAfterDays: firstAfterDays !== undefined ? Number(firstAfterDays) : undefined,
intervalDays: intervalDays !== undefined ? Number(intervalDays) : undefined,
maxCount: maxCount !== undefined ? Number(maxCount) : undefined
});
res.json({ success: true, data, message: 'Questionnaire reminder settings saved' });
} catch (error) {
console.error('Update questionnaire reminder settings error:', error);
res.status(500).json({ success: false, message: 'Error saving questionnaire reminder settings' });
}
};
export const getQueueStatus = async (req: Request, res: Response) => {
try {
const { slaQueue } = await import('../../common/queues/sla.queue.js');

View File

@ -7,6 +7,11 @@ router.use(authenticate as any);
router.get('/configs', slaController.getConfigs);
router.put('/configs/:id', slaController.updateConfig);
router.get('/operations/dashboard', slaController.getOperationsDashboard);
router.get('/operations/export', slaController.exportOperationsCsv);
router.get('/settings/questionnaire-reminder', slaController.getQuestionnaireReminderSettings);
router.put('/settings/questionnaire-reminder', slaController.updateQuestionnaireReminderSettings);
router.post('/status/batch', slaController.getBatchStatus);
router.get('/tracking/:applicationId', slaController.getTracking);
router.get('/debug/queue', slaController.getQueueStatus);

View File

@ -364,6 +364,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
const fromStage = termination.currentStage;
let approvedToStage: string | null = null;
let scheduleLwdFnfReminder = false;
if (action === OFFBOARDING_ACTIONS.REJECT) {
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
@ -391,6 +392,10 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
}, { transaction });
await transaction.commit();
const { SLAService } = await import('../../services/SLAService.js');
await SLAService.pauseEntityTracks('termination', termination.id);
return res.json({ success: true, message: 'Termination case placed on hold.' });
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
// Validation: Remarks mandatory for Revoke
@ -424,24 +429,62 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
remarks
});
} else if (action === 'pushfnf') {
if (termination.currentStage !== TERMINATION_STAGES.TERMINATED && termination.currentStage !== TERMINATION_STAGES.LEGAL_LETTER) {
// FnF row is created ONLY here — never on stage approval or LWD scheduler.
const awaitingFnfStatuses = ['Awaiting F&F', 'Awaiting F&F (LWD Pending)', 'Terminated'];
const atTerminatedStage = termination.currentStage === TERMINATION_STAGES.TERMINATED;
const awaitingFnfStatus = awaitingFnfStatuses.includes(String(termination.status || ''));
if (!atTerminatedStage && !awaitingFnfStatus) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Cannot trigger F&F from ${termination.currentStage}. Complete the CEO and Legal approvals first.`
success: false,
message: `Cannot trigger F&F from ${termination.currentStage}. Complete Legal approval so the case reaches Terminated / Awaiting F&F first.`
});
}
logger.info(`[TerminationController] Forcibly initiating F&F (pushfnf) for Termination ${termination.requestId}`);
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction);
// Maintain timeline visibility
const existingFnf = await db.FnF.findOne({
where: { terminationRequestId: termination.id },
transaction
});
if (existingFnf) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'F&F settlement already exists for this termination. Open the F&F module to continue clearance.'
});
}
const { force } = req.body;
const { isLwdReached, formatLwdDisplay } = await import('../../common/utils/offboardingLwd.js');
if (!force && !isLwdReached(termination.proposedLwd)) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `F&F settlement can be initiated only on or after the Last Working Day (${formatLwdDisplay(termination.proposedLwd)}).`,
canForce: true
});
}
const forceInitiated = Boolean(force);
logger.info(
`[TerminationController] Manual Push to F&F for Termination ${termination.requestId} (force=${forceInitiated})`
);
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction, {
manualTrigger: true
});
// Maintain timeline visibility — "Forced" only when LWD was bypassed
const timeline = [...(termination.timeline || []), {
stage: termination.currentStage,
timestamp: new Date(),
user: req.user.fullName,
action: 'Forced F&F Initiation',
remarks: remarks || 'F&F settlement initiated manually via Push to F&F'
role: req.user.roleCode,
action: forceInitiated ? 'Forced F&F Initiation' : 'F&F Initiated',
remarks:
remarks ||
(forceInitiated
? 'F&F settlement initiated before Last Working Day (exception / force).'
: 'F&F settlement initiated via Push to F&F after Last Working Day.')
}];
await termination.update({
currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals
@ -630,10 +673,20 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
logger.info(
`[TerminationController] Termination reached TERMINATED. F&F must be started manually (Push to F&F). LWD=${termination.proposedLwd}, status=${statusAfterTerm}`
);
scheduleLwdFnfReminder = true;
}
}
await transaction.commit();
if (scheduleLwdFnfReminder) {
const { OffboardingLwdReminderService } = await import('../../services/OffboardingLwdReminderService.js');
await OffboardingLwdReminderService.scheduleDelayedReminderIfNeeded(
{ requestType: 'termination', requestId: termination.id },
termination.proposedLwd
);
}
res.json({ success: true, message: 'Termination updated', termination });
} catch (error) {
if (transaction) await transaction.rollback();
@ -913,10 +966,19 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
updatedBy: req.user.fullName
};
const fnf = await db.FnF.findOne({ where: { terminationRequestId: resolvedId }, transaction });
if (!fnf) {
await transaction.rollback();
return res.status(400).json({
success: false,
message:
'F&F settlement has not been started for this termination. Use Push to F&F on the termination case first.'
});
}
await termination.update({ departmentalClearances: clearances }, { transaction });
// Update individual clearance record for unified dashboard
const fnf = await db.FnF.findOne({ where: { terminationRequestId: resolvedId } });
if (fnf) {
await db.FffClearance.update(
{ status: normalizedStatus, remarks, amount: Number(amount) || 0 },
@ -943,6 +1005,12 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
}
await transaction.commit();
if (fnf) {
const { syncFnfClearanceSla } = await import('../../common/utils/slaFnfSync.js');
await syncFnfClearanceSla(fnf.id, department, normalizedStatus);
}
res.json({ success: true, message: `Clearance updated for ${department}`, clearances });
} catch (error) {
if (transaction) await transaction.rollback();

View File

@ -129,6 +129,13 @@ const seedTemplates = async () => {
fileName: 'fnf_initiated.html',
placeholders: ['recipientName', 'dealerName', 'requestId', 'initiatedBy', 'lwd', 'link', 'ctaLabel']
},
{
templateCode: 'FNF_LWD_READY',
description: 'Notifies DD Admin / DD Lead when Last Working Day is reached and Push to F&F can be used',
subject: 'LWD Reached — Initiate F&F for {{requestId}}',
fileName: 'fnf_lwd_ready.html',
placeholders: ['recipientName', 'dealerName', 'requestId', 'requestType', 'lwd', 'link', 'ctaLabel']
},
{
templateCode: 'FNF_SUMMARY_PREPARED',
description: 'Notification to Finance team when F&F summary is ready for review',

View File

@ -141,6 +141,32 @@ const seedMissingTemplates = async () => {
placeholders: ['recipientName', 'dealerName', 'requestId', 'lwd', 'initiatedBy', 'link']
},
{
templateCode: 'FNF_LWD_READY',
description: 'Scheduled reminder to authorized admins when LWD is reached — use Push to F&F',
subject: 'Last Working Day Reached — Push to F&F: {{requestId}}',
body: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<div style="background:#1e3a5f;padding:20px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:20px;">Last Working Day Reached</h2>
</div>
<div style="background:#f9f9f9;padding:24px;border:1px solid #eee;border-radius:0 0 8px 8px;">
<p>Dear {{recipientName}},</p>
<p>
The Last Working Day (<strong>{{lwd}}</strong>) has been reached for
<strong>{{requestType}}</strong> case <strong>{{requestId}}</strong> (dealer: <strong>{{dealerName}}</strong>).
</p>
<p>You may now initiate Full &amp; Final settlement using <strong>Push to F&amp;F</strong> on the case details page.</p>
<div style="text-align:center;margin:24px 0;">
<a href="{{link}}" style="background:#c8102e;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
{{ctaLabel}}
</a>
</div>
</div>
</div>`,
placeholders: ['recipientName', 'dealerName', 'requestId', 'requestType', 'lwd', 'link', 'ctaLabel']
},
// ── 4. EOR Completed ───────────────────────────────────────────────────
// Used in: eor.controller.ts (when all EOR checklist items are complete)
// Recipients: DD-Head, NBH

View File

@ -207,9 +207,19 @@ const startServer = async () => {
if (process.env.ENABLE_REDIS === 'true') {
const { notificationWorker } = await import('./common/queues/notification.worker.js');
const { slaWorker } = await import('./common/queues/sla.worker.js');
const { offboardingLwdWorker } = await import('./common/queues/offboarding-lwd.worker.js');
const { questionnaireReminderWorker } = await import('./common/queues/questionnaire-reminder.worker.js');
const { scheduleSLACheck } = await import('./common/queues/sla.queue.js');
const { scheduleOffboardingLwdReminders } = await import('./common/queues/offboarding-lwd.queue.js');
const { scheduleQuestionnaireReminders } = await import('./common/queues/questionnaire-reminder.queue.js');
await scheduleSLACheck(); // Register repeatable job
await scheduleOffboardingLwdReminders();
await scheduleQuestionnaireReminders();
void notificationWorker;
void slaWorker;
void offboardingLwdWorker;
void questionnaireReminderWorker;
logger.info('BullMQ Workers initialized and repeatable jobs scheduled');
} else {

View File

@ -4,6 +4,7 @@ import { NotificationService } from './NotificationService.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js';
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
export class ConstitutionalWorkflowService {
private static normalizeDocLabel(input: string): string {
@ -120,6 +121,13 @@ export class ConstitutionalWorkflowService {
await request.update(updateData);
await syncSlaOnStageTransition({
entityType: 'constitutional',
entityId: request.id,
fromStage: sourceStage,
toStage: targetStage
});
if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) {
await ConstitutionalWorkflowService.syncDealerProfileConstitution(request, userId);
}

View 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);
}
}
}

View 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 };
}
}

View File

@ -5,6 +5,7 @@ import { getOffboardingAuditAction, formatOffboardingAction } from '../common/ut
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import logger from '../common/utils/logger.js';
import { NotificationService } from './NotificationService.js';
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
export class RelocationWorkflowService {
/**
@ -48,6 +49,16 @@ export class RelocationWorkflowService {
const updatedTimeline = [...(request.timeline || []), timelineEntry];
await request.update({ timeline: updatedTimeline });
const toStage = updateData.currentStage;
if (toStage && toStage !== sourceStage) {
await syncSlaOnStageTransition({
entityType: 'relocation',
entityId: request.id,
fromStage: sourceStage,
toStage
});
}
// 3. Create Audit Log using standardized mapper
const { actionType } = metadata;
let resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RELOCATION);

View File

@ -8,6 +8,7 @@ import logger from '../common/utils/logger.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { NomenclatureService } from '../common/utils/nomenclature.js';
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
export class ResignationWorkflowService {
@ -45,6 +46,13 @@ export class ResignationWorkflowService {
timeline: updatedTimeline
}, transaction ? { transaction } : undefined);
await syncSlaOnStageTransition({
entityType: 'resignation',
entityId: resignation.id,
fromStage: sourceStage,
toStage: targetStage
});
// 3. Create Audit Log using standardized mapper
const { actionType } = metadata;
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RESIGNATION);
@ -214,6 +222,9 @@ export class ResignationWorkflowService {
);
await Promise.all(clearancePromises);
const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js');
await startAllPendingFnfClearanceSlas(fnf.id);
// 3. Create Audit Trail
await db.FnFAudit.create({
userId,

View File

@ -1,324 +1,461 @@
import db from '../database/models/index.js';
const { SLATracking, SLAConfiguration, SLABreach, Application, User, SLAReminder, SLAEscalationConfig } = db;
import { Op } from 'sequelize';
import { slaConfigLookupNames } from '../common/config/slaStageCatalog.js';
import { effectiveElapsedMs } from '../common/utils/slaBusinessTime.js';
import { NotificationService } from './NotificationService.js';
import { resolveRecipientsForRoles } from './slaGeographyResolver.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import type { WorkflowActivityRequestType } from '../common/utils/workflowWorknote.js';
export type SlaTrackRef = {
entityType: string;
entityId: string;
applicationId?: string | null;
stageName: string;
};
export class SLAService {
/**
* Periodically check for SLA breaches, reminders and escalations
*/
static async checkBreaches() {
console.log('[SLA Service] Starting SLA status check...');
const now = new Date();
// 1. Handle Active Tracks (Reminders and Initial Breach)
const activeTracking = await SLATracking.findAll({
where: { isActive: true, endTime: null },
include: [{ model: Application, as: 'application' }]
private static async findConfigForStage(stageName: string) {
const names = slaConfigLookupNames(stageName);
return SLAConfiguration.findOne({
where: { activityName: { [Op.in]: names }, isActive: true }
});
}
private static resolveTrackRef(
refOrAppId: string | SlaTrackRef,
stageName?: string
): SlaTrackRef {
if (typeof refOrAppId === 'string') {
return {
entityType: 'application',
entityId: refOrAppId,
applicationId: refOrAppId,
stageName: stageName!
};
}
return refOrAppId;
}
static async checkBreaches() {
console.log('[SLA Service] Starting SLA status check...');
const now = new Date();
const activeTracking = await SLATracking.findAll({
where: { isActive: true, endTime: null },
include: [{ model: Application, as: 'application', required: false }]
});
for (const track of activeTracking) {
const config = await this.findConfigForStage(track.stageName);
const configWithChildren = config
? await SLAConfiguration.findByPk(config.id, {
include: [
{ model: SLAReminder, as: 'reminders' },
{ model: SLAEscalationConfig, as: 'escalationConfigs' }
]
})
: null;
if (!configWithChildren) continue;
const configResolved = configWithChildren;
const meta = track.metadata || {};
if (meta.pausedAt) continue;
const tatMs = this.getTatInMs(configResolved.tatHours, configResolved.tatUnit);
const elapsedMs = effectiveElapsedMs(track, now.getTime());
const deadline = new Date(new Date(track.startTime).getTime() + tatMs);
if (!track.isBreached && elapsedMs < tatMs) {
await this.processReminders(track, configResolved, now, deadline, tatMs, elapsedMs);
} else if (!track.isBreached && elapsedMs >= tatMs) {
await this.triggerBreach(track, now);
} else if (track.isBreached) {
await this.processEscalations(track, configResolved, now, deadline);
await this.processRepeatOverdueReminder(track, now);
}
}
}
static async startTrack(refOrAppId: string | SlaTrackRef, stageName?: string) {
const ref = this.resolveTrackRef(refOrAppId, stageName);
console.log(
`[SLA Service] Starting SLA track for ${ref.entityType}:${ref.entityId}, Stage: ${ref.stageName}`
);
const deactivateWhere: Record<string, unknown> = {
entityId: ref.entityId,
isActive: true,
endTime: null
};
// F&F: multiple department timers run in parallel on the same settlement
if (ref.entityType === 'fnf') {
deactivateWhere.stageName = ref.stageName;
}
await SLATracking.update({ isActive: false, endTime: new Date() }, { where: deactivateWhere });
const config = await this.findConfigForStage(ref.stageName);
if (config) {
await SLATracking.create({
applicationId: ref.applicationId ?? null,
entityType: ref.entityType,
entityId: ref.entityId,
stageName: ref.stageName,
startTime: new Date(),
isActive: true
});
}
}
/** Pause all active SLA timers for an entity (e.g. case On Hold). */
static async pauseEntityTracks(entityType: string, entityId: string) {
const tracks = await SLATracking.findAll({
where: { entityType, entityId, isActive: true, endTime: null }
});
const pausedAt = new Date().toISOString();
for (const track of tracks) {
const meta = { ...(track.metadata || {}), pausedAt };
await track.update({ metadata: meta });
}
}
/** Resume paused SLA timers after hold / waiting period ends. */
static async resumeEntityTracks(entityType: string, entityId: string) {
const tracks = await SLATracking.findAll({
where: { entityType, entityId, isActive: true, endTime: null }
});
const now = Date.now();
for (const track of tracks) {
const meta = { ...(track.metadata || {}) };
if (!meta.pausedAt) continue;
const pauseStart = new Date(String(meta.pausedAt)).getTime();
meta.accumulatedPauseMs = Number(meta.accumulatedPauseMs || 0) + (now - pauseStart);
delete meta.pausedAt;
await track.update({ metadata: meta });
}
}
static async stopTrack(refOrAppId: string | SlaTrackRef, stageName?: string) {
const ref = this.resolveTrackRef(refOrAppId, stageName);
console.log(
`[SLA Service] Stopping SLA track for ${ref.entityType}:${ref.entityId}, Stage: ${ref.stageName}`
);
const stageNames = slaConfigLookupNames(ref.stageName);
await SLATracking.update(
{ isActive: false, endTime: new Date() },
{
where: {
entityId: ref.entityId,
stageName: { [Op.in]: stageNames },
isActive: true
}
}
);
}
private static getTatInMs(value: number, unit: string): number {
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
factor = factor / 60;
}
return value * factor;
}
private static async getCaseLabel(track: any): Promise<string> {
if (track.application?.applicationId) return track.application.applicationId;
const type = track.entityType;
const id = track.entityId;
if (type === 'termination') {
const row = await db.TerminationRequest.findByPk(id, { attributes: ['requestId'] });
return row?.requestId || id;
}
if (type === 'resignation') {
const row = await db.Resignation.findByPk(id, { attributes: ['resignationId'] });
return row?.resignationId || id;
}
if (type === 'relocation') {
const row = await db.RelocationRequest.findByPk(id, { attributes: ['requestId'] });
return row?.requestId || id;
}
if (type === 'constitutional') {
const row = await db.ConstitutionalChange.findByPk(id, { attributes: ['requestId'] });
return row?.requestId || id;
}
if (type === 'fnf') {
const row = await db.FnF.findByPk(id, { attributes: ['settlementId'] });
return row?.settlementId || id;
}
return id;
}
private static async processRepeatOverdueReminder(track: any, now: Date) {
const meta = { ...(track.metadata || {}) };
let repeatMs = 24 * 60 * 60 * 1000;
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
repeatMs = 60 * 1000;
}
const last = meta.lastOverdueReminderAt ? new Date(String(meta.lastOverdueReminderAt)).getTime() : 0;
if (now.getTime() - last < repeatMs) return;
const caseLabel = await this.getCaseLabel(track);
await this.notifyStakeholder(track, 'SLA_REMINDER', {
title: `SLA Still Overdue: ${track.stageName}`,
message: `Case ${caseLabel} remains overdue for ${track.stageName}. Please close or escalate.`
});
await this.logSlaActivity(track, `[SLA] Repeat overdue reminder: ${track.stageName} still open.`);
meta.lastOverdueReminderAt = now.toISOString();
await track.update({ metadata: meta });
}
private static async processReminders(
track: any,
config: any,
now: Date,
deadline: Date,
tatMs: number,
elapsedMs: number
) {
const msRemaining = Math.max(0, tatMs - elapsedMs);
const caseLabel = await this.getCaseLabel(track);
for (const reminder of config.reminders || []) {
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
if (msRemaining <= reminderMs) {
const metadata = track.metadata || {};
const reminderKey = `reminder_sent_${reminder.id}`;
if (metadata[reminderKey]) continue;
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
await this.notifyStakeholder(track, 'SLA_REMINDER', {
title: `SLA Reminder: ${track.stageName}`,
message: `Case ${caseLabel} is approaching its SLA deadline for ${track.stageName}.`
});
for (const track of activeTracking) {
const config = await SLAConfiguration.findOne({
where: { activityName: track.stageName, isActive: true },
include: [
{ model: SLAReminder, as: 'reminders' },
{ model: SLAEscalationConfig, as: 'escalationConfigs' }
]
});
if (!config) continue;
const startTime = new Date(track.startTime);
const tatMs = this.getTatInMs(config.tatHours, config.tatUnit);
const deadline = new Date(startTime.getTime() + tatMs);
// CASE A: Not Breached Yet - Check Reminders
if (!track.isBreached && now < deadline) {
await this.processReminders(track, config, now, deadline);
}
// CASE B: Just Breached - Mark it and trigger Level 1 Escalation
else if (!track.isBreached && now >= deadline) {
await this.triggerBreach(track, now);
}
// CASE C: Already Breached - Check for Escalations
else if (track.isBreached) {
await this.processEscalations(track, config, now, deadline);
}
}
}
/**
* Start tracking SLA for a new stage
*/
static async startTrack(applicationId: string, stageName: string) {
console.log(`[SLA Service] Starting SLA track for App: ${applicationId}, Stage: ${stageName}`);
// Ensure NO other active tracks for this application exist
await SLATracking.update(
{ isActive: false, endTime: new Date() },
{ where: { applicationId, isActive: true, endTime: null } }
await this.logSlaActivity(
track,
`[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`
);
const config = await SLAConfiguration.findOne({
where: { activityName: stageName, isActive: true }
});
if (config) {
await SLATracking.create({
applicationId,
stageName,
startTime: new Date(),
isActive: true
});
}
metadata[reminderKey] = true;
await track.update({ metadata });
}
}
}
/**
* Stop tracking SLA for a stage
*/
static async stopTrack(applicationId: string, stageName: string) {
console.log(`[SLA Service] Stopping SLA track for App: ${applicationId}, Stage: ${stageName}`);
await SLATracking.update(
{ isActive: false, endTime: new Date() },
{ where: { applicationId, stageName, isActive: true } }
private static async triggerBreach(track: any, now: Date) {
const caseLabel = await this.getCaseLabel(track);
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${caseLabel}`);
await track.update({ isBreached: true });
await SLABreach.create({
trackingId: track.id,
breachedAt: now,
status: 'Open'
});
await this.notifyStakeholder(track, 'SLA_BREACH', {
title: `SLA BREACHED: ${track.stageName}`,
message: `Case ${caseLabel} has breached its SLA for ${track.stageName}.`
});
await this.logSlaActivity(
track,
`[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`
);
}
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
const msSinceBreach = now.getTime() - deadline.getTime();
const caseLabel = await this.getCaseLabel(track);
for (const esc of config.escalationConfigs || []) {
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
if (msSinceBreach >= escMs) {
const metadata = track.metadata || {};
const escKey = `esc_sent_L${esc.level}`;
if (metadata[escKey]) continue;
console.log(
`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'})`
);
}
private static getTatInMs(value: number, unit: string): number {
let factor = unit === 'days' ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
// Debug Mode: 1 hour = 1 minute (60x speedup)
if (process.env.DEBUG_SLA_FAST_MODE === 'true') {
factor = factor / 60;
const recipientIds: string[] = [];
if (esc.notifyRole) {
recipientIds.push(
...(await resolveRecipientsForRoles(
{
entityType: track.entityType,
entityId: track.entityId,
applicationId: track.applicationId
},
[esc.notifyRole]
))
);
}
return value * factor;
}
private static async processReminders(track: any, config: any, now: Date, deadline: Date) {
const msRemaining = deadline.getTime() - now.getTime();
for (const reminder of config.reminders || []) {
const reminderMs = this.getTatInMs(reminder.timeValue, reminder.timeUnit);
if (msRemaining <= reminderMs) {
const metadata = track.metadata || {};
const reminderKey = `reminder_sent_${reminder.id}`;
if (!metadata[reminderKey]) {
const timeStr = `${reminder.timeValue} ${reminder.timeUnit}`;
console.log(`[SLA Service] Sending reminder for ${track.stageName} (${timeStr} remaining)`);
await this.notifyStakeholder(track, 'SLA_REMINDER', {
title: `SLA Reminder: ${track.stageName}`,
message: `The application ${track.application?.applicationId} is approaching its SLA deadline for ${track.stageName}.`
});
// §9.4.1 — Auto-log in Work Notes
await this.logWorkNote(track.applicationId, `[SLA] Reminder sent: ${track.stageName} has ${timeStr} remaining.`);
metadata[reminderKey] = true;
await track.update({ metadata });
}
if (recipientIds.length === 0 && esc.notifyEmail) {
await NotificationService.notify(null, esc.notifyEmail, {
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
channels: ['email', 'system'],
templateCode: 'SLA_ESCALATION',
placeholders: {
applicationId: caseLabel,
stageName: track.stageName,
level: esc.level,
timeValue: esc.timeValue,
timeUnit: esc.timeUnit,
phone: ''
}
});
}
}
private static async triggerBreach(track: any, now: Date) {
console.log(`[SLA Service] Breach detected for ${track.stageName}: ${track.application?.applicationId}`);
await track.update({ isBreached: true });
await SLABreach.create({
trackingId: track.id,
applicationId: track.applicationId,
stageCode: track.stageName,
breachedAt: now,
severity: 'High',
status: 'Open'
for (const recipientId of recipientIds) {
const user = await User.findByPk(recipientId);
if (!user?.email) continue;
const phone = user.mobileNumber || user.phone;
await NotificationService.notify(recipientId, user.email, {
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
message: `Case ${caseLabel} remains incomplete after SLA breach for ${track.stageName}.`,
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'SLA_ESCALATION',
placeholders: {
applicationId: caseLabel,
stageName: track.stageName,
level: esc.level,
timeValue: esc.timeValue,
timeUnit: esc.timeUnit,
phone: phone || ''
}
});
}
await this.logSlaActivity(
track,
`[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyRole || esc.notifyEmail || 'supervisor'} for stage ${track.stageName}.`
);
metadata[escKey] = true;
await track.update({ metadata });
}
}
}
private static async logSlaActivity(track: any, text: string) {
const admin =
(await User.findOne({ where: { roleCode: 'Super Admin', status: 'active' } })) ||
(await User.findOne());
if (!admin) return;
if (track.entityType === 'application' && track.applicationId) {
try {
await db.Worknote.create({
applicationId: track.applicationId,
userId: admin.id,
noteText: text,
noteType: 'system',
status: 'active'
});
await this.notifyStakeholder(track, 'SLA_BREACH', {
title: `SLA BREACHED: ${track.stageName}`,
message: `The application ${track.application?.applicationId} has breached its SLA for ${track.stageName}.`
});
// §9.4.1 — Auto-log in Work Notes
await this.logWorkNote(track.applicationId, `[SLA] BREACH: ${track.stageName} has exceeded its Turnaround Time (TAT).`);
} catch (err) {
console.error('[SLA Service] Failed to log application work note:', err);
}
return;
}
private static async processEscalations(track: any, config: any, now: Date, deadline: Date) {
const msSinceBreach = now.getTime() - deadline.getTime();
const requestTypeMap: Record<string, WorkflowActivityRequestType | undefined> = {
termination: 'termination',
resignation: 'resignation',
relocation: 'relocation',
constitutional: 'constitutional',
fnf: 'fnf'
};
const requestType = requestTypeMap[track.entityType];
if (!requestType) return;
for (const esc of config.escalationConfigs || []) {
const escMs = this.getTatInMs(esc.timeValue, esc.timeUnit);
if (msSinceBreach >= escMs) {
const metadata = track.metadata || {};
const escKey = `esc_sent_L${esc.level}`;
if (!metadata[escKey]) {
console.log(`[SLA Service] Escalating ${track.stageName} to Level ${esc.level} (Role: ${esc.notifyRole || 'N/A'}, Email: ${esc.notifyEmail || 'N/A'})`);
const { User, Application, District, Region, Zone } = db;
let targetEmail = esc.notifyEmail;
let recipientId = null;
// Runtime Resolution: Resolve role to a specific user/email if role is provided
if (esc.notifyRole) {
const app = await Application.findByPk(track.applicationId, {
include: [{
model: District,
as: 'district',
include: [
{ model: Region, as: 'region' },
{ model: Zone, as: 'zone' }
]
}]
});
if (app?.district) {
const d = app.district;
const r = d.region;
const z = d.zone;
// Map geography-bound roles
const roleMap: Record<string, string | null> = {
'ASM': d.asmId,
'DD-ZM': d.zmId,
'RBM': r?.rbmId || null,
'ZBH': z?.zbhId || null
};
if (roleMap[esc.notifyRole]) {
recipientId = roleMap[esc.notifyRole];
}
}
// Fallback/National roles: Resolve by roleCode singleton
if (!recipientId) {
const user = await User.findOne({
where: { roleCode: esc.notifyRole, status: 'active' },
order: [['createdAt', 'DESC']]
});
if (user) recipientId = user.id;
}
}
// Resolve final email and phone if we have a recipientId
let phone = null;
if (recipientId) {
const user = await User.findByPk(recipientId);
if (user) {
targetEmail = user.email;
phone = user.mobileNumber || user.phone || null;
}
}
if (targetEmail) {
await NotificationService.notify(recipientId, targetEmail, {
title: `SLA ESCALATION [L${esc.level}]: ${track.stageName}`,
message: `The application ${track.application?.applicationId} remains incomplete after SLA breach for ${track.stageName}.`,
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: 'SLA_ESCALATION',
placeholders: {
applicationId: track.application?.applicationId || '',
stageName: track.stageName,
level: esc.level,
timeValue: esc.timeValue,
timeUnit: esc.timeUnit,
phone: phone || ''
}
});
}
// §9.4.1 — Auto-log in Work Notes
await this.logWorkNote(track.applicationId, `[SLA] ESCALATION Level ${esc.level}: Escalated to ${esc.notifyEmail} for stage ${track.stageName}.`);
metadata[escKey] = true;
await track.update({ metadata });
}
}
}
try {
await writeWorkflowActivityWorknote({
requestId: track.entityId,
requestType,
userId: admin.id,
noteText: text,
noteType: 'workflow'
});
} catch (err) {
console.error('[SLA Service] Failed to log offboarding work note:', err);
}
}
private static async logWorkNote(applicationId: string, text: string) {
try {
const { Worknote, User } = db;
// Find a system user or admin to be the author
const admin = await User.findOne({ where: { role: 'Super Admin' } });
await Worknote.create({
applicationId,
userId: admin?.id || (await User.findOne())?.id,
noteText: text,
noteType: 'system',
status: 'active'
});
} catch (err) {
console.error('[SLA Service] Failed to log work note:', err);
}
private static linkForEntity(entityType: string, entityId: string): string {
const base = process.env.FRONTEND_URL || 'http://localhost:5173';
switch (entityType) {
case 'application':
return `${base}/applications/${entityId}`;
case 'termination':
return `${base}/termination/${entityId}`;
case 'resignation':
return `${base}/resignation/${entityId}`;
case 'relocation':
return `${base}/relocation/${entityId}`;
case 'constitutional':
return `${base}/constitutional-change/${entityId}`;
case 'fnf':
return `${base}/fnf/${entityId}`;
default:
return base;
}
}
private static async notifyStakeholder(track: any, template: string, content: { title: string, message: string }) {
const { Application, User, SLAConfiguration } = db;
// 1. Get the configuration for this stage to find the Owner Role(s)
const config = await SLAConfiguration.findOne({ where: { activityName: track.stageName, isActive: true } });
if (!config || !config.ownerRole) return;
private static async notifyStakeholder(
track: any,
template: string,
content: { title: string; message: string }
) {
const config = await this.findConfigForStage(track.stageName);
if (!config?.ownerRole) return;
// 2. Resolve multiple roles (comma-separated)
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
const application = await Application.findByPk(track.applicationId, {
include: [{ model: db.District, as: 'district', include: [{ model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' }] }]
});
const roles = config.ownerRole.split(',').map((r: string) => r.trim());
const recipientIds = await resolveRecipientsForRoles(
{
entityType: track.entityType,
entityId: track.entityId,
applicationId: track.applicationId
},
roles
);
if (recipientIds.length === 0) return;
if (!application) return;
const caseLabel = await this.getCaseLabel(track);
const link = this.linkForEntity(track.entityType, track.entityId);
const recipientIds = new Set<string>();
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
for (const role of roles) {
let foundUserId = null;
// Resolve geography-bound roles
if (application.district) {
const d = application.district;
const roleMap: Record<string, string | null> = {
'ASM': d.asmId,
'DD-ZM': d.zmId,
'RBM': d.region?.rbmId || null,
'ZBH': d.zone?.zbhId || null
};
if (roleMap[role]) foundUserId = roleMap[role];
}
if (foundUserId) {
recipientIds.add(foundUserId);
} else {
// Fallback: Resolve all active users with this role
const users = await User.findAll({ where: { roleCode: role, status: 'active' } });
users.forEach((u: any) => recipientIds.add(u.id));
}
}
// 3. Send notifications to all resolved recipients
for (const userId of recipientIds) {
const user = await User.findByPk(userId);
if (!user) continue;
const phone = user.mobileNumber || user.phone || null;
await NotificationService.notify(userId, user.email, {
title: content.title,
message: content.message,
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: template,
placeholders: {
applicationId: application.applicationId || String(application.id),
stageName: track.stageName,
link: `${portalBase}/applications/${application.id}`,
phone: phone || ''
},
metadata: { applicationId: application.id }
});
for (const userId of recipientIds) {
const user = await User.findByPk(userId);
if (!user) continue;
const phone = user.mobileNumber || user.phone || null;
await NotificationService.notify(userId, user.email, {
title: content.title,
message: content.message,
channels: phone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
templateCode: template,
placeholders: {
applicationId: caseLabel,
stageName: track.stageName,
link,
phone: phone || ''
},
metadata: {
entityType: track.entityType,
entityId: track.entityId,
applicationId: track.applicationId
}
});
}
}
}

View 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');
}
}

View 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));
}
}

View File

@ -10,6 +10,7 @@ import { NomenclatureService } from '../common/utils/nomenclature.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js';
import { ParticipantService } from './ParticipantService.js';
import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js';
export class TerminationWorkflowService {
/**
@ -18,6 +19,7 @@ export class TerminationWorkflowService {
static async transitionTermination(termination: any, targetStage: string, userId: string | null = null, metadata: any = {}) {
const { action, remarks, status, transaction } = metadata;
const sourceStage = termination.currentStage;
const wasOnHold = String(termination.status || '').toLowerCase() === 'on hold';
const updateData: any = {
currentStage: targetStage,
@ -48,6 +50,18 @@ export class TerminationWorkflowService {
timeline: updatedTimeline
}, transaction ? { transaction } : undefined);
await syncSlaOnStageTransition({
entityType: 'termination',
entityId: termination.id,
fromStage: sourceStage,
toStage: targetStage
});
if (wasOnHold) {
const { SLAService } = await import('./SLAService.js');
await SLAService.resumeEntityTracks('termination', termination.id);
}
// 4. Create Audit Log using standardized mapper
const { actionType } = metadata;
const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.TERMINATION);
@ -161,9 +175,19 @@ export class TerminationWorkflowService {
}
/**
* Initiates Full & Final Settlement for a terminated dealer
* Creates the FnF settlement record ONLY call from explicit Push to F&F (`manualTrigger: true`).
*/
static async initiateFnF(termination: any, userId: string, transaction: any) {
static async initiateFnF(
termination: any,
userId: string,
transaction: any,
options: { manualTrigger?: boolean } = {}
) {
if (!options.manualTrigger) {
throw new Error(
'F&F settlement for termination must be started via Push to F&F (manual trigger only).'
);
}
// 1. Get Dealer User with associated Outlets
const dealerUser = await User.findOne({
where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER },
@ -180,25 +204,47 @@ export class TerminationWorkflowService {
let fnfId = fnf?.id;
if (!fnf) {
fnf = await db.FnF.create({
settlementId: await NomenclatureService.generateFnFId(),
terminationRequestId: termination.id,
dealerId: termination.dealerId,
outletId: primaryOutlet?.id || null,
status: 'Initiated',
totalReceivables: 0,
totalPayables: 0,
netAmount: 0
});
fnf = await db.FnF.create(
{
settlementId: await NomenclatureService.generateFnFId(),
terminationRequestId: termination.id,
dealerId: termination.dealerId,
outletId: primaryOutlet?.id || null,
status: 'Initiated',
totalReceivables: 0,
totalPayables: 0,
netAmount: 0
},
transaction ? { transaction } : undefined
);
await db.FffClearance.bulkCreate(
FNF_DEPARTMENTS.map(dept => ({
fnfId: fnf.id,
department: dept,
status: 'Pending'
}))
})),
transaction ? { transaction } : undefined
);
const { startAllPendingFnfClearanceSlas } = await import('../common/utils/slaFnfSync.js');
await startAllPendingFnfClearanceSlas(fnf.id);
await db.FnFAudit.create(
{
userId,
fnfId: fnf.id,
action: 'INITIATED',
remarks: 'F&F settlement created via manual Push to F&F (termination)',
details: {
source: 'Termination Workflow',
terminationRequestId: termination.requestId,
manualTrigger: true
}
},
transaction ? { transaction } : undefined
);
fnfId = fnf.id;
}

View File

@ -1,13 +1,14 @@
import db from '../database/models/index.js';
const { Application, ApplicationStatusHistory, User, Dealer } = db;
import { syncApplicationProgress, PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS } from '../common/utils/progress.js';
import { shouldTrackOnboardingSla } from '../common/config/slaStageCatalog.js';
import { SLAService } from './SLAService.js';
import {
AUDIT_ACTIONS,
APPLICATION_STAGES,
OVERALL_STATUS_TO_DB_CURRENT_STAGE,
} from '../common/config/constants.js';
import { NotificationService } from './NotificationService.js';
import { SLAService } from './SLAService.js';
import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js';
export class WorkflowService {
@ -106,9 +107,31 @@ export class WorkflowService {
try {
const tasks = [];
// SLA Tracking
if (previousStage) tasks.push(SLAService.stopTrack(application.id, previousStage).catch(e => console.error('[WorkflowService] SLA stop failed:', e)));
if (stageForDbColumn) tasks.push(SLAService.startTrack(application.id, stageForDbColumn).catch(e => console.error('[WorkflowService] SLA start failed:', e)));
// SLA Tracking — use pipeline milestone label when available (matches slaStageCatalog)
const slaPrevStage =
(previousStatus && PIPELINE_STAGE_LABEL_BY_OVERALL_STATUS[previousStatus]) ||
previousStage;
const slaNextStage = pipelineStageLabel || stageForDbColumn;
if (slaPrevStage && shouldTrackOnboardingSla(slaPrevStage)) {
tasks.push(
SLAService.stopTrack({
entityType: 'application',
entityId: application.id,
applicationId: application.id,
stageName: slaPrevStage
}).catch((e) => console.error('[WorkflowService] SLA stop failed:', e))
);
}
if (slaNextStage && shouldTrackOnboardingSla(slaNextStage)) {
tasks.push(
SLAService.startTrack({
entityType: 'application',
entityId: application.id,
applicationId: application.id,
stageName: slaNextStage
}).catch((e) => console.error('[WorkflowService] SLA start failed:', e))
);
}
// Progress Sync
tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e)));

View 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();
}

View 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];
}

View File

@ -1,11 +1,20 @@
/**
* Dealer termination E2E follows the same stage order and APIs as the UI.
*
* Usage:
* node trigger-termination.js
* node trigger-termination.js --terminationId=<uuid> --delayMs=800
* node trigger-termination.js --category=Unethical --skipClearances=true
*/
const args = Object.fromEntries(
process.argv.slice(2)
.map(arg => arg.replace(/^--/, '').split('='))
process.argv
.slice(2)
.map((arg) => arg.replace(/^--/, '').split('='))
.map(([k, v]) => [k, v ?? 'true'])
);
const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api';
const PASSWORD = 'Admin@123';
const STEP_DELAY_MS = Number(args.delayMs || 500);
const STEP_DELAY_MS = Number(args.delayMs || 800);
const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true';
const EMAILS = {
@ -18,31 +27,37 @@ const EMAILS = {
DD_HEAD: 'ganesh@royalenfield.com',
LEGAL: 'legal@royalenfield.com',
NBH: 'yashwin@royalenfield.com',
CCO: 'admin@royalenfield.com',
CEO: 'admin@royalenfield.com',
SALES: 'sales@royalenfield.com',
SERVICE: 'service@royalenfield.com',
SPARES: 'spares@royalenfield.com',
ACCOUNTS: 'accounts@royalenfield.com',
WARRANTY: 'warranty@royalenfield.com',
MARKETING: 'marketing@royalenfield.com',
HR: 'hr@royalenfield.com',
IT: 'it@royalenfield.com',
LOGISTICS: 'logistics@royalenfield.com',
QUALITY: 'quality@royalenfield.com',
APPAREL: 'apparel@royalenfield.com',
DMS: 'dms@royalenfield.com'
CCO: 'cco@royalenfield.com',
CEO: 'ceo@royalenfield.com'
};
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
const headers = { 'Content-Type': 'application/json' };
/** Canonical stage labels (match TERMINATION_STAGES + UI timeline). */
const STAGES = {
RBM: 'RBM + DD-ZM Review',
ZBH: 'ZBH Review',
DD_LEAD: 'DD Lead Review',
LEGAL: 'Legal Verification',
DD_HEAD: 'DD Head Review',
NBH: 'NBH Evaluation',
SCN: 'Show Cause Notice (SCN)',
SCN_EVAL: 'Evaluation of Dealer SCN Response',
NBH_FINAL: 'NBH Final Approval',
CCO: 'CCO Approval',
CEO: 'CEO Final Approval',
LEGAL_LETTER: 'Legal - Termination Letter',
TERMINATED: 'Terminated'
};
async function apiRequest(endpoint, method = 'GET', body = null, token = null, isFormData = false) {
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isFormData) headers['Content-Type'] = 'application/json';
const config = { method, headers };
if (body) config.body = JSON.stringify(body);
if (body) config.body = isFormData ? body : JSON.stringify(body);
const response = await fetch(`${BASE_URL}${endpoint}`, config);
const data = await response.json();
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`);
@ -58,156 +73,317 @@ async function login(email) {
return login.cache[email];
}
const delay = (ms = STEP_DELAY_MS) => new Promise(res => setTimeout(res, ms));
const delay = (ms = STEP_DELAY_MS) => new Promise((res) => setTimeout(res, ms));
const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`);
async function fetchTermination(terminationId, token) {
const data = await apiRequest(`/termination/${terminationId}`, 'GET', null, token);
return data.termination;
}
async function waitUntilStage(terminationId, expectedStage, token, maxAttempts = 50) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const t = await fetchTermination(terminationId, token);
if (t.currentStage === expectedStage) return t;
await delay(250);
}
const last = await fetchTermination(terminationId, token);
throw new Error(
`Timed out waiting for stage "${expectedStage}" (still at "${last.currentStage}", status="${last.status}")`
);
}
async function approveTermination(terminationId, email, remarks) {
const token = await login(email);
return apiRequest(
`/termination/${terminationId}/status`,
'PUT',
{ action: 'approve', remarks },
token
);
}
/** RBM+DD-ZM and SCN evaluation require two/four partial approvals before the stage advances. */
async function runJointApprovals(terminationId, actors, nextStage, adminToken, stepLabel) {
log(stepLabel, `Joint approvals at ${nextStage ? 'current' : '?'} → expect "${nextStage}"`);
for (const actor of actors) {
const res = await approveTermination(terminationId, actor.email, actor.remarks);
const message = res.message || res.data?.message || '';
log(stepLabel, `${actor.email}: ${message}`);
await delay(300);
const t = await fetchTermination(terminationId, adminToken);
if (nextStage && t.currentStage === nextStage) {
log(stepLabel, `Advanced to ${nextStage}`);
return t;
}
}
return waitUntilStage(terminationId, nextStage, adminToken);
}
async function runSingleApproval(terminationId, email, remarks, nextStage, adminToken, stepLabel) {
const res = await approveTermination(terminationId, email, remarks);
log(stepLabel, `${email}: ${res.message || 'approved'}`);
await delay(300);
return waitUntilStage(terminationId, nextStage, adminToken);
}
async function finalizeTermination(terminationId, email, nextStage, remarks, stepLabel, adminToken) {
const token = await login(email);
const res = await apiRequest(
`/termination/${terminationId}/finalize`,
'POST',
{ decision: 'Approve', remarks },
token
);
log(stepLabel, `${email} finalize → ${res.message || 'ok'}`);
await delay(300);
return waitUntilStage(terminationId, nextStage, adminToken);
}
async function uploadScnResponsePlaceholder(terminationId) {
const token = await login(EMAILS.DD_ADMIN);
const form = new FormData();
// Multer only allows PDF/images/office — use minimal PDF bytes
const pdfBytes = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>endobj\nxref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF'
);
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
form.append('file', blob, 'scn-response-e2e.pdf');
form.append('remarks', 'Dealer SCN response uploaded (E2E script).');
await apiRequest(`/termination/${terminationId}/scn-response`, 'POST', form, token, true);
log('SCN', 'SCN response uploaded by DD Admin');
}
const STAGE_ORDER = [
STAGES.RBM,
STAGES.ZBH,
STAGES.DD_LEAD,
STAGES.LEGAL,
STAGES.DD_HEAD,
STAGES.NBH,
STAGES.SCN,
STAGES.SCN_EVAL,
STAGES.NBH_FINAL,
STAGES.CCO,
STAGES.CEO,
STAGES.LEGAL_LETTER,
STAGES.TERMINATED
];
function stageIndex(stage) {
const idx = STAGE_ORDER.indexOf(stage);
return idx >= 0 ? idx : 0;
}
function shouldRun(resumeFromStage, targetStage) {
return stageIndex(resumeFromStage) <= stageIndex(targetStage);
}
async function runWorkflowFromStage(terminationId, resumeFromStage, adminToken, isUnethicalCategory) {
let step = 2;
if (isUnethicalCategory) {
log(step++, 'Unethical category — workflow starts at DD Lead (RBM/ZBH not used).');
}
if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.RBM)) {
await runJointApprovals(
terminationId,
[
{ email: EMAILS.RBM, remarks: 'RBM validation complete.' },
{ email: EMAILS.DD_ZM, remarks: 'DD-ZM confirmed escalation.' }
],
STAGES.ZBH,
adminToken,
step
);
await delay();
}
if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.ZBH)) {
await runSingleApproval(terminationId, EMAILS.ZBH, 'Strategic decision aligned.', STAGES.DD_LEAD, adminToken, step++);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.DD_LEAD)) {
await runSingleApproval(
terminationId,
EMAILS.DD_LEAD,
isUnethicalCategory ? 'Immediate escalation — breaches documented.' : 'Breaches documented.',
STAGES.LEGAL,
adminToken,
step++
);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.LEGAL)) {
await runSingleApproval(terminationId, EMAILS.LEGAL, 'Case is sound.', STAGES.DD_HEAD, adminToken, step++);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.DD_HEAD)) {
await runSingleApproval(terminationId, EMAILS.DD_HEAD, 'Strategic impact assessed.', STAGES.NBH, adminToken, step++);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.NBH)) {
await runSingleApproval(terminationId, EMAILS.NBH, 'Functional teams aligned.', STAGES.SCN, adminToken, step++);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.SCN)) {
const atScn = await fetchTermination(terminationId, adminToken);
if (atScn.currentStage === STAGES.SCN) {
await uploadScnResponsePlaceholder(terminationId);
await waitUntilStage(terminationId, STAGES.SCN_EVAL, adminToken);
}
await delay();
}
if (shouldRun(resumeFromStage, STAGES.SCN_EVAL)) {
await runJointApprovals(
terminationId,
[
{ email: EMAILS.DD_LEAD, remarks: 'SCN response reviewed by DD Lead.' },
{ email: EMAILS.ZBH, remarks: 'SCN response reviewed by ZBH.' },
{ email: EMAILS.RBM, remarks: 'SCN response reviewed by RBM.' },
{ email: EMAILS.DD_HEAD, remarks: 'SCN response reviewed by DD Head.' }
],
STAGES.NBH_FINAL,
adminToken,
step
);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.NBH_FINAL)) {
await finalizeTermination(terminationId, EMAILS.NBH, STAGES.CCO, 'NBH final authorization.', step++, adminToken);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.CCO)) {
await finalizeTermination(terminationId, EMAILS.CCO, STAGES.CEO, 'CCO authorization.', step++, adminToken);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.CEO)) {
await finalizeTermination(terminationId, EMAILS.CEO, STAGES.LEGAL_LETTER, 'CEO final authorization.', step++, adminToken);
await delay();
}
if (shouldRun(resumeFromStage, STAGES.LEGAL_LETTER)) {
await runSingleApproval(
terminationId,
EMAILS.LEGAL,
'Termination letter issued.',
STAGES.TERMINATED,
adminToken,
step++
);
await delay();
}
const afterLegal = await fetchTermination(terminationId, adminToken);
if (afterLegal.currentStage === STAGES.TERMINATED) {
log(step, `Terminated (status: ${afterLegal.status}). Use Push to F&F in UI when LWD is reached.`);
}
}
async function run() {
try {
console.log('--- STARTING DEALER TERMINATION E2E FLOW ---');
console.log('--- STARTING DEALER TERMINATION E2E FLOW (UI-aligned) ---');
const adminToken = await login(EMAILS.DD_ADMIN);
const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken);
const targetDealer = dealersRes.data[0];
if (!targetDealer) throw new Error('No dealer profiles found for termination test. Run seed first.');
if (!targetDealer) throw new Error('No dealer profiles found. Run seed first.');
console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`);
let terminationId = args.terminationId;
const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical');
if (!terminationId) {
console.log('[STEP 1] Initiating Termination...');
log(1, 'Creating termination (ASM)...');
const asmToken = await login(EMAILS.ASM);
const createRes = await apiRequest('/termination', 'POST', {
dealerId: targetDealer.id,
category: args.category || 'Performance',
reason: args.reason || 'Consistently failed to meet commitment targets.',
proposedLwd: new Date().toISOString(),
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
}, asmToken);
const createRes = await apiRequest(
'/termination',
'POST',
{
dealerId: targetDealer.id,
category: args.category || 'Performance',
reason: args.reason || 'Consistently failed to meet commitment targets.',
proposedLwd: new Date().toISOString().split('T')[0],
comments: 'E2E termination — follows UI stage order (no stacked partial approvals).'
},
asmToken
);
terminationId = createRes.termination.id;
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}. Category: ${args.category || 'Performance'}`);
log(1, `Created: ${terminationId} (${args.category || 'Performance'})`);
} else {
console.log(`[STEP 1] Resuming existing termination: ${terminationId}`);
log(1, `Resuming: ${terminationId}`);
}
const currentTermination = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
const currentStage = currentTermination?.termination?.currentStage;
console.log(`[INFO] Current stage before progression: ${currentStage}`);
await delay();
const approvals = [
{ stage: 'RBM + DD-ZM Review', actors: [{ email: EMAILS.RBM, remarks: 'Validated.' }, { email: EMAILS.DD_ZM, remarks: 'Confirmed.' }] },
{ stage: 'ZBH Review', actors: [{ email: EMAILS.ZBH, remarks: 'Strategic decision aligned.' }] },
{ stage: 'DD Lead Review', actors: [{ email: EMAILS.DD_LEAD, remarks: 'Breaches documented.' }] },
{ stage: 'Legal Verification', actors: [{ email: EMAILS.LEGAL, remarks: 'Case is sound.' }] },
{ stage: 'DD Head Review', actors: [{ email: EMAILS.DD_HEAD, remarks: 'Strategic impact assessed.' }] },
{ stage: 'NBH Evaluation', actors: [{ email: EMAILS.NBH, remarks: 'Functional teams aligned.' }] },
{ stage: 'Show Cause Notice (SCN)', actors: [{ email: EMAILS.DD_ADMIN, remarks: 'SCN Issued.' }] },
{ stage: 'Personal Hearing', actors: [
{ email: EMAILS.DD_LEAD, remarks: 'Hearing completed.' },
{ email: EMAILS.ZBH, remarks: 'Review recorded.' },
{ email: EMAILS.RBM, remarks: 'Review recorded.' },
{ email: EMAILS.DD_HEAD, remarks: 'Review recorded.' }
] },
{ stage: 'NBH Final Approval', actors: [{ email: EMAILS.NBH, remarks: 'Final recommendation.' }] },
{ stage: 'CCO Approval', actors: [{ email: EMAILS.CCO, remarks: 'Approved.' }] },
{ stage: 'CEO Final Approval', actors: [{ email: EMAILS.CEO, remarks: 'Final authorization.' }] },
{ stage: 'Legal - Termination Letter', actors: [{ email: EMAILS.LEGAL, remarks: 'Termination letter shared.' }] }
];
let termination = await fetchTermination(terminationId, adminToken);
log('INFO', `Starting stage: ${termination.currentStage} | status: ${termination.status}`);
const stageOrder = [
'Submitted', 'RBM + DD-ZM Review', 'ZBH Review', 'DD Lead Review', 'Legal Verification', 'DD Head Review',
'NBH Evaluation', 'Show Cause Notice (SCN)', 'Personal Hearing', 'NBH Final Approval', 'CCO Approval',
'CEO Final Approval', 'Legal - Termination Letter', 'Terminated'
];
const resumeFrom = STAGE_ORDER.includes(termination.currentStage)
? termination.currentStage
: isUnethical
? STAGES.DD_LEAD
: STAGES.RBM;
// If Unethical, the skip-routing skips to DD Lead Review (index 3 in stageOrder)
const startIndex = isUnethical ? 2 : Math.max(0, stageOrder.indexOf(currentStage));
let currentStep = 2;
for (let i = startIndex; i < approvals.length; i++) {
const step = approvals[i];
log(currentStep, `Stage: ${step.stage} - Processing approvals...`);
for (const actor of step.actors) {
const token = await login(actor.email);
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
action: 'approve',
remarks: actor.remarks
}, token);
log(currentStep, `Actor ${actor.email} Result: SUCCESS`);
await delay(100);
}
currentStep++;
await delay();
if (termination.currentStage === STAGES.TERMINATED || termination.status?.includes('F&F')) {
log('SKIP', 'Already terminated / in F&F — workflow steps skipped.');
} else {
await runWorkflowFromStage(terminationId, resumeFrom, adminToken, isUnethical);
}
// --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) ---
if (!SHOULD_SKIP_CLEARANCES) {
log(13, 'Starting 16-Department F&F Clearance Flow for Termination...');
}
const terminationData = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
const fnfId = terminationData.termination.fnfSettlement?.id;
if (!fnfId) {
log('SKIP', 'FnF Settlement not initialized for this termination case.');
} else if (!SHOULD_SKIP_CLEARANCES) {
const departments = [
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' },
{ name: 'Accessories Department', status: 'Dues', amount: 15000, type: 'Receivable', remarks: 'Shortage in accessory stock.' },
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' },
{ name: 'RTO Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
{ name: 'Service Department', status: 'Dues', amount: 8000, type: 'Receivable', remarks: 'Loaner vehicle damange charges.' },
{ name: 'Parts Department', status: 'Dues', amount: 20000, type: 'Payable', remarks: 'Return parts credit.' },
{ name: 'Finance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No interest dues.' },
{ name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
{ name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Inventory handed over.' },
{ name: 'Marketing Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
{ name: 'HR Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Staff settlement clear.' },
{ name: 'IT Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Hardware recovered.' },
{ name: 'Legal Department', status: 'Dues', amount: 50000, type: 'Receivable', remarks: 'Litigation cost recovery as per agreement.' },
{ name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
{ name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' },
{ name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }
];
for (const dept of departments) {
log('13.1', `Clearing Dept: ${dept.name} [${dept.status}] - ${dept.remarks}`);
await apiRequest(`/termination/${terminationId}/clearance`, 'PUT', {
department: dept.name,
status: dept.status,
remarks: dept.remarks,
amount: dept.amount,
type: dept.type
}, adminToken);
await delay(100);
termination = await fetchTermination(terminationId, adminToken);
const fnfId = termination.fnfSettlement?.id;
if (!fnfId) {
log('F&F', 'No FnF record yet (expected until Push to F&F after LWD). Skipping clearances.');
} else {
log('F&F', 'Running department clearances...');
const departments = [
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' },
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' }
];
for (const dept of departments) {
await apiRequest(
`/termination/${terminationId}/clearance`,
'PUT',
dept,
adminToken
);
await delay(200);
}
}
log(13, 'All 16 Departments Cleared for Termination.');
await delay();
}
const finalDetails = await fetchTermination(terminationId, adminToken);
console.log(`[FINAL] stage=${finalDetails.currentStage} status=${finalDetails.status}`);
const userRes = await apiRequest('/admin/users', 'GET', null, adminToken);
const dealerUser = userRes.data?.find((u) => u.dealerId === targetDealer.id);
console.log('[FINAL STEP] Verifying Terminated Status & Account Deactivation...');
const finalDetails = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
console.log(`Final Stage REACHED: ${finalDetails.termination.currentStage}`);
// Fetch user data to verify deactivation
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
const dealerUser = userRes.data.find(u => u.dealerId === targetDealer.id);
if (dealerUser && !dealerUser.isActive && dealerUser.status === 'inactive') {
console.log(`[VERIFICATION] Account ${dealerUser.email} successfully DEACTIVATED.`);
} else {
console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`);
throw new Error('Automated account deactivation check failed.');
console.log(`[VERIFICATION] Dealer portal user ${dealerUser.email} deactivated.`);
} else if (finalDetails.currentStage === STAGES.TERMINATED) {
console.log('[VERIFICATION] Terminated — dealer deactivation may occur at Legal Letter stage.');
}
console.log('\n--- VERIFICATION SUCCESSFUL ---');
console.log('Outcome: DEALER TERMINATED & PORTAL ACCESS REVOKED');
console.log('\n--- TERMINATION E2E COMPLETE ---');
process.exit(0);
} catch (error) {
console.error('Workflow failed:', error.message);
process.exit(1);

View File

@ -296,280 +296,280 @@ async function triggerWorkflow() {
remarks: 'Cleared Level 2'
}, leadToken);
log(5, 'Level 2 Complete.');
await delay();
// await delay();
// 6. LEVEL-3 INTERVIEW
log(6, 'Scheduling Level 3 Interview...');
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
// // 6. LEVEL-3 INTERVIEW
// log(6, 'Scheduling Level 3 Interview...');
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
applicationId: applicationUUID,
level: 3,
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
type: 'In-Person',
location: 'HO',
participants: [headUser.id, nbhUser.id]
}, leadToken);
const interviewId3 = intv3Response.data.id;
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
// applicationId: applicationUUID,
// level: 3,
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
// type: 'In-Person',
// location: 'HO',
// participants: [headUser.id, nbhUser.id]
// }, leadToken);
// const interviewId3 = intv3Response.data.id;
log(6.1, 'NBH Giving Feedback...');
const nbhToken = await login(EMAILS.NBH);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 10,
feedbackItems: [
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
],
recommendation: 'Selected'
}, nbhToken);
// log(6.1, 'NBH Giving Feedback...');
// const nbhToken = await login(EMAILS.NBH);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 10,
// feedbackItems: [
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
// ],
// recommendation: 'Selected'
// }, nbhToken);
log(6.15, 'DD-Head Giving Feedback...');
const headToken = await login(EMAILS.DD_HEAD);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 9.5,
feedbackItems: [
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
],
recommendation: 'Selected'
}, headToken);
// log(6.15, 'DD-Head Giving Feedback...');
// const headToken = await login(EMAILS.DD_HEAD);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
// ],
// recommendation: 'Selected'
// }, headToken);
log(6.2, 'Head Finalizing Level 3 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId3,
decision: 'Approved',
remarks: 'Cleared Level 3. Moving to FDD.'
}, headToken);
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
await delay();
// log(6.2, 'Head Finalizing Level 3 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId3,
// decision: 'Approved',
// remarks: 'Cleared Level 3. Moving to FDD.'
// }, headToken);
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
// await delay();
// 6.3 FDD ASSIGNMENT
log(6.3, 'Admin Assigning Application to FDD Agency...');
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
await apiRequest('/fdd/assign', 'POST', {
applicationId: applicationUUID,
assignedToAgency: fddUser.id
}, adminToken);
log(6.3, 'FDD Agency assigned successfully.');
await delay();
// // 6.3 FDD ASSIGNMENT
// log(6.3, 'Admin Assigning Application to FDD Agency...');
// const fddUser = users.data.find(u => u.email === EMAILS.FDD);
// await apiRequest('/fdd/assign', 'POST', {
// applicationId: applicationUUID,
// assignedToAgency: fddUser.id
// }, adminToken);
// log(6.3, 'FDD Agency assigned successfully.');
// await delay();
// 7. FDD MILESTONE
log(7, 'FDD Agency Discovery & Report Upload...');
const fddToken = await login(EMAILS.FDD);
// // 7. FDD MILESTONE
// log(7, 'FDD Agency Discovery & Report Upload...');
// const fddToken = await login(EMAILS.FDD);
// FETCH ASSIGNMENT ID
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
const assignmentId = assignmentRes.data.id;
log(7, `Found Assignment ID: ${assignmentId}`);
// // FETCH ASSIGNMENT ID
// const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
// const assignmentId = assignmentRes.data.id;
// log(7, `Found Assignment ID: ${assignmentId}`);
await apiRequest('/fdd/report', 'POST', {
assignmentId,
findings: 'Finance records clean.',
recommendation: 'Approved'
}, fddToken);
// await apiRequest('/fdd/report', 'POST', {
// assignmentId,
// findings: 'Finance records clean.',
// recommendation: 'Approved'
// }, fddToken);
log(7.1, 'Admin Approving FDD Final Stage...');
await apiRequest('/assessment/stage-decision', 'POST', {
applicationId: applicationUUID,
stageCode: 'FDD_VERIFICATION',
decision: 'Approved',
remarks: 'FDD documents verified.'
}, adminToken);
log(7, 'FDD Milestone Complete.');
await delay();
// log(7.1, 'Admin Approving FDD Final Stage...');
// await apiRequest('/assessment/stage-decision', 'POST', {
// applicationId: applicationUUID,
// stageCode: 'FDD_VERIFICATION',
// decision: 'Approved',
// remarks: 'FDD documents verified.'
// }, adminToken);
// log(7, 'FDD Milestone Complete.');
// await delay();
log(7.4, 'Uploading mandatory documents prior to LOI generation...');
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
for (const doc of requiredDocs) {
await mockUploadDocument(applicationUUID, adminToken, doc);
}
await delay(1000);
// log(7.4, 'Uploading mandatory documents prior to LOI generation...');
// const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
// for (const doc of requiredDocs) {
// await mockUploadDocument(applicationUUID, adminToken, doc);
// }
// await delay(1000);
// 7.5 LOI APPROVAL
log(7.5, 'LOI Generation & Approval...');
const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
const loiRequestId = loiRes.data.id;
// // 7.5 LOI APPROVAL
// log(7.5, 'LOI Generation & Approval...');
// const loiRes = await apiRequest('/loi/request', 'POST', { applicationId: applicationUUID }, adminToken);
// const loiRequestId = loiRes.data.id;
// Head Approval
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'Head Authorization for LOI'
}, headToken);
// // Head Approval
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'Head Authorization for LOI'
// }, headToken);
// NBH Approval
await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'NBH Authorization for LOI'
}, nbhToken);
// // NBH Approval
// await apiRequest(`/loi/request/${loiRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'NBH Authorization for LOI'
// }, nbhToken);
log(7.5, 'LOI Milestone Complete.');
await delay();
// log(7.5, 'LOI Milestone Complete.');
// await delay();
// 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
const financeToken = await login(EMAILS.FINANCE);
await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID,
amount: 500000,
paymentReference: 'PAY-888999',
depositType: 'SECURITY_DEPOSIT',
status: 'Verified'
}, financeToken);
log(8, 'Security Deposit Verified.')
// 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
await delay(300);
// // 8. PAYMENT GATE (SECURITY DEPOSIT FIRST AS PER CURRENT FLOW)
// log(8, 'Finance Verifying SECURITY_DEPOSIT to unlock LOI Issued...');
// const financeToken = await login(EMAILS.FINANCE);
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 500000,
// paymentReference: 'PAY-888999',
// depositType: 'SECURITY_DEPOSIT',
// status: 'Verified'
// }, financeToken);
// log(8, 'Security Deposit Verified.')
// // 9. GENERATE DEALER CODES (align with backend gate: LOI Issued required)
// let statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Current status before code generation: ${statusBeforeCodeGen}`);
// log(9, 'Ensuring mandatory PAN/GST/Bank fields before code generation...');
// await ensureMandatoryCodeGenFields(applicationUUID, adminToken);
// await delay(300);
if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
log(9, 'Status is Security Deposit (or legacy Security Details); re-verifying Security Deposit to move to LOI Issued...');
await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID,
amount: 500000,
paymentReference: `PAY-RETRY-${Date.now()}`,
depositType: 'SECURITY_DEPOSIT',
status: 'Verified'
}, financeToken);
await delay();
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
}
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
// log(9, 'Status is Security Deposit (or legacy Security Details); re-verifying Security Deposit to move to LOI Issued...');
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 500000,
// paymentReference: `PAY-RETRY-${Date.now()}`,
// depositType: 'SECURITY_DEPOSIT',
// status: 'Verified'
// }, financeToken);
// await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after re-verify: ${statusBeforeCodeGen}`);
// }
// Current backend flow keeps app at Security Deposit until explicit admin transition.
if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
status: 'LOI Issued',
stage: 'LOI',
reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.'
}, adminToken);
await delay();
statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
}
// // Current backend flow keeps app at Security Deposit until explicit admin transition.
// if (statusBeforeCodeGen === 'Security Deposit' || statusBeforeCodeGen === 'Security Details') {
// log(9, 'Applying admin transition from Security Deposit -> LOI Issued...');
// await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
// status: 'LOI Issued',
// stage: 'LOI',
// reason: 'E2E script alignment: unlock dealer code generation after Security Deposit checks.'
// }, adminToken);
// await delay();
// statusBeforeCodeGen = await getApplicationStatus(applicationUUID, adminToken);
// log(9, `Status after admin transition: ${statusBeforeCodeGen}`);
// }
if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
}
// if (statusBeforeCodeGen !== 'LOI Issued' && statusBeforeCodeGen !== 'Dealer Code Generation') {
// throw new Error(`Cannot generate codes: expected LOI Issued/Dealer Code Generation, got ${statusBeforeCodeGen}`);
// }
log(9, 'Admin Generating SAP Dealer Codes...');
await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
log(9, 'Dealer Codes Generated.');
await delay();
// log(9, 'Admin Generating SAP Dealer Codes...');
// await apiRequest(`/onboarding/applications/${applicationUUID}/generate-codes`, 'POST', {}, adminToken);
// log(9, 'Dealer Codes Generated.');
// await delay();
// 10. FIRST FILL (POST CODE-GENERATION)
log(10, 'Finance Verifying FIRST FILL (₹15L)...');
await apiRequest('/loa/security-deposit', 'POST', {
applicationId: applicationUUID,
amount: 1500000,
paymentReference: 'PAY-FIN-999',
depositType: 'FIRST_FILL',
status: 'Verified'
}, financeToken);
log(10, 'Final Security Deposit Verified.');
await delay();
// // 10. FIRST FILL (POST CODE-GENERATION)
// log(10, 'Finance Verifying FIRST FILL (₹15L)...');
// await apiRequest('/loa/security-deposit', 'POST', {
// applicationId: applicationUUID,
// amount: 1500000,
// paymentReference: 'PAY-FIN-999',
// depositType: 'FIRST_FILL',
// status: 'Verified'
// }, financeToken);
// log(10, 'Final Security Deposit Verified.');
// await delay();
// 11. ADMIN UPDATING STATUTORY & BANK DETAILS
log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
accountHolderName: 'Ramesh Automobiles Private Limited',
panNumber: 'ABCDE1234F',
gstNumber: '07ABCDE1234F1Z5',
bankName: 'HDFC Bank',
accountNumber: '50100223344556',
ifscCode: 'HDFC0001234'
}, adminToken);
log(11, 'Statutory & Bank details updated.');
await delay();
// // 11. ADMIN UPDATING STATUTORY & BANK DETAILS
// log(11, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
// await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
// accountHolderName: 'Ramesh Automobiles Private Limited',
// panNumber: 'ABCDE1234F',
// gstNumber: '07ABCDE1234F1Z5',
// bankName: 'HDFC Bank',
// accountNumber: '50100223344556',
// ifscCode: 'HDFC0001234'
// }, adminToken);
// log(11, 'Statutory & Bank details updated.');
// await delay();
// 12. FINAL LOA APPROVAL
log(12, 'NBH & Head Approving Final LOA...');
const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
const finalLoaRequestId = loaRes.data.id;
// // 12. FINAL LOA APPROVAL
// log(12, 'NBH & Head Approving Final LOA...');
// const loaRes = await apiRequest('/loa/request', 'POST', { applicationId: applicationUUID }, headToken);
// const finalLoaRequestId = loaRes.data.id;
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'Head Authorization (Level 1)'
}, headToken);
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'Head Authorization (Level 1)'
// }, headToken);
await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
action: 'Approved',
remarks: 'NBH Approval (Level 2)'
}, nbhToken);
log(12, 'LOA Fully Approved.');
await delay();
// await apiRequest(`/loa/request/${finalLoaRequestId}/approve`, 'POST', {
// action: 'Approved',
// remarks: 'NBH Approval (Level 2)'
// }, nbhToken);
// log(12, 'LOA Fully Approved.');
// await delay();
// 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
const checklistId = eorInit.data.id;
log(13, `EOR Checklist Created (ID: ${checklistId})`);
// // 13. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
// log(13, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
// const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
// const checklistId = eorInit.data.id;
// log(13, `EOR Checklist Created (ID: ${checklistId})`);
log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
const eorItems = [
{ itemType: 'Sales', description: 'Sales Standards' },
{ itemType: 'Service', description: 'Service & Spares' },
{ itemType: 'IT', description: 'DMS infra' },
{ itemType: 'Training', description: 'Manpower Training' },
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
{ itemType: 'Finance', description: 'Inventory Funding' },
{ itemType: 'IT', description: 'Virtual code availability' },
{ itemType: 'Finance', description: 'Vendor payments' },
{ itemType: 'Marketing', description: 'Details for website submission' },
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
{ itemType: 'IT', description: 'Auto ordering' }
];
// log(13.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
// const eorItems = [
// { itemType: 'Sales', description: 'Sales Standards' },
// { itemType: 'Service', description: 'Service & Spares' },
// { itemType: 'IT', description: 'DMS infra' },
// { itemType: 'Training', description: 'Manpower Training' },
// { itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
// { itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
// { itemType: 'Finance', description: 'Inventory Funding' },
// { itemType: 'IT', description: 'Virtual code availability' },
// { itemType: 'Finance', description: 'Vendor payments' },
// { itemType: 'Marketing', description: 'Details for website submission' },
// { itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
// { itemType: 'IT', description: 'Auto ordering' }
// ];
for (const item of eorItems) {
process.stdout.write(`.`); // Visual progress
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
...item,
isCompliant: true,
remarks: 'Verified by Auditor - Compliant'
}, adminToken);
}
console.log('\n[STEP 13.1] All EOR items marked as compliant.');
// for (const item of eorItems) {
// process.stdout.write(`.`); // Visual progress
// await apiRequest(`/eor/item/${checklistId}`, 'POST', {
// ...item,
// isCompliant: true,
// remarks: 'Verified by Auditor - Compliant'
// }, adminToken);
// }
// console.log('\n[STEP 13.1] All EOR items marked as compliant.');
log(13.2, 'Auditor Submitting Final EOR Audit...');
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
status: 'Completed',
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
}, adminToken);
// log(13.2, 'Auditor Submitting Final EOR Audit...');
// await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
// status: 'Completed',
// overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
// }, adminToken);
// Status check
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
await delay();
// // Status check
// const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
// log(13.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
// await delay();
// 14. FINAL ONBOARDING
log(14, 'Admin Finalizing Dealer Onboarding...');
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
await delay();
// // 14. FINAL ONBOARDING
// log(14, 'Admin Finalizing Dealer Onboarding...');
// await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
// await delay();
// 15. VERIFICATION
log(15, 'Verifying Dealer Record Creation...');
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
if (!dealerRes.success || !dealerRes.data) {
throw new Error('Verification Failed: Dealer record not found after onboarding.');
}
log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
// // 15. VERIFICATION
// log(15, 'Verifying Dealer Record Creation...');
// const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
// if (!dealerRes.success || !dealerRes.data) {
// throw new Error('Verification Failed: Dealer record not found after onboarding.');
// }
// log(15, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
log(15.1, 'Verifying User Account Role Update...');
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
}
log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
// log(15.1, 'Verifying User Account Role Update...');
// const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
// const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
// if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
// throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
// }
// log(15.1, `User role confirmed: ${dealerUser.roleCode}`);
log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
// log(15.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
// log(15.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
}
/**