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 # File Uploads
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
MAX_FILE_SIZE=10485760 MAX_FILE_SIZE=10485760
# Redis / BullMQ (required for scheduled jobs: SLA, LWD, questionnaire reminders, notifications)
ENABLE_REDIS=false
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# DEBUG_SLA_FAST_MODE=true # Internal SLA checks every minute (dev only)
# SLA_BUSINESS_HOURS=false # Disable 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 'dotenv/config';
import db from '../src/database/models/index.js'; import db from '../src/database/models/index.js';
import { SLA_STAGE_CATALOG } from '../src/common/config/slaStageCatalog.js';
type SlaDefault = { import { seedSlaCatalogEntries } from '../src/common/utils/slaSeedUtils.js';
stage: string;
role: string;
tat: number;
unit: 'hours' | 'days';
};
const defaults: SlaDefault[] = [
// ONBOARDING
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' },
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
// RESIGNATION
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
// TERMINATION
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
// RELOCATION
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
// CONSTITUTIONAL CHANGE
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
];
async function seedSlaConfigs() { async function seedSlaConfigs() {
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any; const { sequelize } = db as any;
await sequelize.authenticate(); await sequelize.authenticate();
console.log('Database connected.'); console.log('Database connected.');
const transaction = await sequelize.transaction(); const transaction = await sequelize.transaction();
try {
try { await seedSlaCatalogEntries(db, SLA_STAGE_CATALOG, transaction);
for (const item of defaults) { await transaction.commit();
const [config, created] = await SLAConfiguration.findOrCreate({ console.log(
where: { activityName: item.stage }, `SLA configurations seeded successfully. Total activities: ${SLA_STAGE_CATALOG.length}`
defaults: {
activityName: item.stage,
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
isActive: true,
},
transaction,
});
if (!created) {
await config.update(
{
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
},
{ transaction }
); );
} } catch (error) {
await transaction.rollback();
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction }); console.error('SLA seed failed:', error);
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction }); throw error;
} finally {
await SLAReminder.bulkCreate( await sequelize.close();
[
{ slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true },
{ slaConfigId: config.id, timeValue: 4, timeUnit: 'hours', isEnabled: true },
],
{ transaction }
);
await SLAEscalationConfig.bulkCreate(
[
{ slaConfigId: config.id, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
{ slaConfigId: config.id, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
{ slaConfigId: config.id, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' },
],
{ transaction }
);
} }
await transaction.commit();
console.log(`SLA configurations seeded successfully. Total stages: ${defaults.length}`);
} catch (error) {
await transaction.rollback();
console.error('SLA seed failed:', error);
throw error;
} finally {
await sequelize.close();
}
} }
seedSlaConfigs().catch((err) => { seedSlaConfigs().catch((err) => {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
}); });

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', 'EOR_COMPLETED',
'FDD_DOCUMENT_REQUEST', 'FDD_DOCUMENT_REQUEST',
'FNF_INITIATED', 'FNF_INITIATED',
'FNF_LWD_READY',
'FNF_SUMMARY_PREPARED', 'FNF_SUMMARY_PREPARED',
'FNF_SETTLEMENT_APPROVED', 'FNF_SETTLEMENT_APPROVED',
'GENERIC_NOTIFICATION', 'GENERIC_NOTIFICATION',

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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