new mail templates added dealer table enhanced add bank details added
This commit is contained in:
parent
19c766c999
commit
6373372eb9
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Redis - Required for BullMQ background jobs (notifications, SLAs)
|
||||||
|
redis:
|
||||||
|
image: redis:7.2-alpine
|
||||||
|
container_name: re-onboarding-redis
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --save 60 1 --loglevel warning
|
||||||
|
|
||||||
|
# RedisInsight - GUI for monitoring Redis/BullMQ (Optional)
|
||||||
|
redis-insight:
|
||||||
|
image: redislabs/redisinsight:latest
|
||||||
|
container_name: re-onboarding-redis-insight
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
# PostgreSQL - Application Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: re-onboarding-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: Admin@123
|
||||||
|
POSTGRES_DB: royal_enfield_onboarding
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
postgres_data:
|
||||||
314
package-lock.json
generated
314
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"license": "PROPRIETARY",
|
"license": "PROPRIETARY",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"bullmq": "^5.73.4",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@ -18,6 +19,7 @@
|
|||||||
"express-validator": "^7.3.1",
|
"express-validator": "^7.3.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
@ -1867,6 +1869,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@ -2304,6 +2312,84 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@ -4212,6 +4298,46 @@
|
|||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "5.73.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.73.4.tgz",
|
||||||
|
"integrity": "sha512-Q+NeFLtdKSD3GDPYSX4pH+Mc9E4OZVKimXwrnZ5WmndNy31COMy4vQV9zfhgfHGSUFrlpsBicfKYbSjx9FbO+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "4.9.0",
|
||||||
|
"ioredis": "5.10.1",
|
||||||
|
"msgpackr": "1.11.5",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.7.4",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"uuid": "11.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@ -4455,6 +4581,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@ -4697,6 +4832,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -4756,6 +4903,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@ -4765,6 +4921,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-newline": {
|
"node_modules/detect-newline": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||||
@ -5949,6 +6115,53 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
|
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.5.1",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.0.1",
|
"version": "10.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||||
@ -6953,12 +7166,24 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isboolean": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
@ -7028,6 +7253,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-dir": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
@ -7265,6 +7499,37 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "1.11.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||||
|
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||||
@ -7321,6 +7586,27 @@
|
|||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@ -8008,6 +8294,27 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@ -8680,6 +8987,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@ -9134,7 +9447,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
"license": "PROPRIETARY",
|
"license": "PROPRIETARY",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"bullmq": "^5.73.4",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@ -44,6 +45,7 @@
|
|||||||
"express-validator": "^7.3.1",
|
"express-validator": "^7.3.1",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
@ -81,4 +83,4 @@
|
|||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
"npm": ">=9.0.0"
|
"npm": ">=9.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
scratch/check_fnf.sql
Normal file
1
scratch/check_fnf.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
SELECT id, "settlementId", "outletId", "dealerId", status FROM fnf_settlements ORDER BY "createdAt" DESC LIMIT 1;
|
||||||
3
scratch/fix_schema.sql
Normal file
3
scratch/fix_schema.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE fnf_settlements DROP COLUMN IF EXISTS settlementid;
|
||||||
|
ALTER TABLE fnf_settlements DROP COLUMN IF EXISTS "settlementId";
|
||||||
|
ALTER TABLE fnf_settlements ADD COLUMN "settlementId" VARCHAR(255);
|
||||||
27
scripts/add-recovery-enum.ts
Normal file
27
scripts/add-recovery-enum.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import db from '../src/database/models/index.js';
|
||||||
|
|
||||||
|
const addRecoveryEnum = async () => {
|
||||||
|
try {
|
||||||
|
console.log('>>> STARTING ENUM UPDATE: FnFLineItem itemType <<<');
|
||||||
|
await db.sequelize.authenticate();
|
||||||
|
console.log('Database connection established.');
|
||||||
|
|
||||||
|
// Raw query to add 'Recovery' to the itemType enum
|
||||||
|
try {
|
||||||
|
await db.sequelize.query(`ALTER TYPE "enum_fnf_line_items_itemType" ADD VALUE IF NOT EXISTS 'Recovery';`);
|
||||||
|
console.log('SUCCESS: Added "Recovery" to "enum_fnf_line_items_itemType"');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('NOTICE: "Recovery" likely already exists or another error occurred:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.sequelize.close();
|
||||||
|
console.log('>>> ENUM UPDATE COMPLETED <<<');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('>>> ERROR: Failed to update Enum:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addRecoveryEnum();
|
||||||
102
simulate-clearances.js
Normal file
102
simulate-clearances.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
const BASE_URL = 'http://localhost:5000/api';
|
||||||
|
const FINANCE_EMAIL = 'finance@royalenfield.com';
|
||||||
|
const PASSWORD = 'Admin@123';
|
||||||
|
|
||||||
|
async function simulateFullClearance() {
|
||||||
|
try {
|
||||||
|
console.log('--- STARTING COMPREHENSIVE F&F SIMULATION (16 DEPARTMENTS) ---');
|
||||||
|
|
||||||
|
// 1. Login
|
||||||
|
const loginRes = await fetch(`${BASE_URL}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: FINANCE_EMAIL, password: PASSWORD })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
const token = loginData.token;
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` };
|
||||||
|
|
||||||
|
// 2. Resolve target F&F
|
||||||
|
const res = await fetch(`${BASE_URL}/settlement/fnf`, { headers });
|
||||||
|
const data = await res.json();
|
||||||
|
const latestFnF = data.settlements[0];
|
||||||
|
|
||||||
|
if (!latestFnF) {
|
||||||
|
console.error('No F&F record found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fnfId = latestFnF.id;
|
||||||
|
console.log(`Targeting F&F: ${fnfId} for ${latestFnF.outlet?.name}`);
|
||||||
|
|
||||||
|
// 3. Define departmental scenarios
|
||||||
|
const scenarios = [
|
||||||
|
{ dept: 'Sales', type: 'Receivable', amount: 45000, desc: 'Pending vehicle stock dues' },
|
||||||
|
{ dept: 'Service', type: 'Payable', amount: 12000, desc: 'Unprocessed warranty claims' },
|
||||||
|
{ dept: 'Spares / Parts', type: 'Receivable', amount: 8500, desc: 'Missing spare parts inventory' },
|
||||||
|
{ dept: 'Warranty', type: 'Payable', amount: 3400, desc: 'Settled warranty credit' },
|
||||||
|
{ dept: 'Accounts', type: 'Receivable', amount: 1500, desc: 'Audit penalty' },
|
||||||
|
{ dept: 'Marketing', type: 'Payable', amount: 20000, desc: 'Signage reimbursement' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Add Line Items
|
||||||
|
console.log('\nAdding financial line items...');
|
||||||
|
for (const item of scenarios) {
|
||||||
|
await fetch(`${BASE_URL}/settlement/fnf/${fnfId}/line-items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
itemType: item.type,
|
||||||
|
description: item.desc,
|
||||||
|
department: item.dept,
|
||||||
|
amount: item.amount
|
||||||
|
})
|
||||||
|
});
|
||||||
|
console.log(`[+] Added ${item.type} for ${item.dept}: ₹${item.amount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Submit Clearances for ALL 16 Departments
|
||||||
|
console.log('\nSubmitting clearances for all 16 departments...');
|
||||||
|
const allDepts = latestFnF.clearances;
|
||||||
|
|
||||||
|
for (const clearance of allDepts) {
|
||||||
|
const hasDues = scenarios.some(s => s.dept === clearance.department);
|
||||||
|
|
||||||
|
await fetch(`${BASE_URL}/settlement/fnf/${fnfId}/clearances/${clearance.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'Cleared', // Backend will auto-convert to 'NOC Submitted' or 'Dues Pending'
|
||||||
|
remarks: hasDues
|
||||||
|
? `Clearance submitted with outstanding dues as listed in calculations.`
|
||||||
|
: `No dues found. Department NOC issued successfully.`,
|
||||||
|
supportingDocument: `https://re-dealer-onboard.s3.amazonaws.com/noc/${clearance.department.replace(/\s/g, '_')}_NOC.pdf`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
console.log(`[OK] Cleared ${clearance.department} (${hasDues ? 'With Dues' : 'Full NOC'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify Final State
|
||||||
|
const finalRes = await fetch(`${BASE_URL}/settlement/fnf/${fnfId}`, { headers });
|
||||||
|
const finalData = await finalRes.json();
|
||||||
|
const f = finalData.fnf;
|
||||||
|
|
||||||
|
console.log('\n--- FINAL VERIFICATION ---');
|
||||||
|
console.log(`Case Number: ${f.resignation?.resignationId || f.id}`);
|
||||||
|
console.log(`Overall Status: ${f.status}`);
|
||||||
|
console.log(`Overall Progress: ${f.progressPercentage}%`);
|
||||||
|
console.log(`Net Settlement Amount: ₹${f.netAmount}`);
|
||||||
|
console.log(`Departments with Dues: ${f.clearances.filter(c => c.status === 'Dues Pending').length}`);
|
||||||
|
console.log(`Departments with NOC: ${f.clearances.filter(c => c.status === 'NOC Submitted').length}`);
|
||||||
|
|
||||||
|
console.log('\nUI CHECKLIST:');
|
||||||
|
console.log('1. Verify Step 2 of progress bar is Green (Completed).');
|
||||||
|
console.log('2. Verify Step 3 (Finance Summary) is Blue (In Progress).');
|
||||||
|
console.log('3. Verify Status Badges in table: 6 Red (Dues Pending), 10 Green (NOC Submitted).');
|
||||||
|
console.log('4. Verify Net Amount matches the sum of added items.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Simulation Failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateFullClearance();
|
||||||
@ -20,7 +20,16 @@ export const ROLES = {
|
|||||||
CEO: 'CEO',
|
CEO: 'CEO',
|
||||||
SPARES_MANAGER: 'Spares Manager',
|
SPARES_MANAGER: 'Spares Manager',
|
||||||
SERVICE_MANAGER: 'Service Manager',
|
SERVICE_MANAGER: 'Service Manager',
|
||||||
ACCOUNTS_MANAGER: 'Accounts Manager'
|
ACCOUNTS_MANAGER: 'Accounts Manager',
|
||||||
|
SALES_MANAGER: 'Sales Manager',
|
||||||
|
WARRANTY_MANAGER: 'Warranty Manager',
|
||||||
|
MARKETING_MANAGER: 'Marketing Manager',
|
||||||
|
HR_MANAGER: 'HR Manager',
|
||||||
|
IT_MANAGER: 'IT Manager',
|
||||||
|
LOGISTICS_MANAGER: 'Logistics Manager',
|
||||||
|
QUALITY_MANAGER: 'Quality Manager',
|
||||||
|
APPAREL_MANAGER: 'Apparel Manager',
|
||||||
|
DMS_MANAGER: 'DMS Manager'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Regions
|
// Regions
|
||||||
@ -115,6 +124,7 @@ export const TERMINATION_STAGES = {
|
|||||||
ZBH_REVIEW: 'ZBH Review',
|
ZBH_REVIEW: 'ZBH Review',
|
||||||
DD_LEAD_REVIEW: 'DD Lead Review',
|
DD_LEAD_REVIEW: 'DD Lead Review',
|
||||||
LEGAL_VERIFICATION: 'Legal Verification',
|
LEGAL_VERIFICATION: 'Legal Verification',
|
||||||
|
DD_HEAD_REVIEW: 'DD Head Review',
|
||||||
NBH_EVALUATION: 'NBH Evaluation',
|
NBH_EVALUATION: 'NBH Evaluation',
|
||||||
SCN_ISSUED: 'Show Cause Notice',
|
SCN_ISSUED: 'Show Cause Notice',
|
||||||
PERSONAL_HEARING: 'Personal Hearing',
|
PERSONAL_HEARING: 'Personal Hearing',
|
||||||
@ -246,24 +256,24 @@ export const FNF_STATUS = {
|
|||||||
COMPLETED: 'Completed'
|
COMPLETED: 'Completed'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// F&F Departments (Full list of 16 functional units)
|
// F&F Departments (Full list of 16 functional units as per Finance Dashboard)
|
||||||
export const FNF_DEPARTMENTS = [
|
export const FNF_DEPARTMENTS = [
|
||||||
'Sales',
|
'Warranty Department',
|
||||||
'Service',
|
'Accessories Department',
|
||||||
'Spares / Parts',
|
'Sales Department',
|
||||||
'Finance',
|
'RTO Department',
|
||||||
'Accounts',
|
'Service Department',
|
||||||
'Warranty',
|
'Parts Department',
|
||||||
'Marketing',
|
'Finance Department',
|
||||||
'HR',
|
'Insurance Department',
|
||||||
'IT',
|
'Inventory Department',
|
||||||
'Legal',
|
'Marketing Department',
|
||||||
'Logistics',
|
'HR Department',
|
||||||
'Quality',
|
'IT Department',
|
||||||
'FDD',
|
'Legal Department',
|
||||||
'Apparel',
|
'Quality Department',
|
||||||
'DMS',
|
'Logistics Department',
|
||||||
'Admin / DD-Admin'
|
'Customer Relations Department'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Audit Actions
|
// Audit Actions
|
||||||
|
|||||||
9
src/common/queues/config.ts
Normal file
9
src/common/queues/config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { ConnectionOptions } from 'bullmq';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const redisConfig: ConnectionOptions = {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
};
|
||||||
16
src/common/queues/notification.queue.ts
Normal file
16
src/common/queues/notification.queue.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { redisConfig } from './config.js';
|
||||||
|
|
||||||
|
export const notificationQueue = new Queue('notificationQueue', {
|
||||||
|
connection: redisConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addNotificationJob = async (data: any) => {
|
||||||
|
await notificationQueue.add('sendNotification', data, {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 5000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
22
src/common/queues/notification.worker.ts
Normal file
22
src/common/queues/notification.worker.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import { redisConfig } from './config.js';
|
||||||
|
import { NotificationService } from '../../services/NotificationService.js';
|
||||||
|
|
||||||
|
export const notificationWorker = new Worker('notificationQueue', async (job) => {
|
||||||
|
console.log(`[Worker] Processing job ${job.id} of type ${job.name}`);
|
||||||
|
|
||||||
|
if (job.name === 'sendNotification') {
|
||||||
|
await NotificationService.processJob(job.data);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
connection: redisConfig,
|
||||||
|
concurrency: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationWorker.on('completed', (job) => {
|
||||||
|
console.log(`[Worker] Job ${job.id} completed successfully`);
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationWorker.on('failed', (job, err) => {
|
||||||
|
console.error(`[Worker] Job ${job?.id} failed with error: ${err.message}`);
|
||||||
|
});
|
||||||
20
src/common/queues/sla.queue.ts
Normal file
20
src/common/queues/sla.queue.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { redisConfig } from './config.js';
|
||||||
|
|
||||||
|
export const slaQueue = new Queue('slaQueue', {
|
||||||
|
connection: redisConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the recurring SLA check (Every Hour)
|
||||||
|
*/
|
||||||
|
export const scheduleSLACheck = async () => {
|
||||||
|
// We use a unique job ID to ensure only one instance of the repeatable job exists
|
||||||
|
await slaQueue.add('checkSLABreaches', {}, {
|
||||||
|
repeat: {
|
||||||
|
pattern: '0 * * * *', // Every hour at :00
|
||||||
|
},
|
||||||
|
jobId: 'hourly-sla-check'
|
||||||
|
});
|
||||||
|
console.log('[SLA Queue] Repeatable job scheduled: Hourly check');
|
||||||
|
};
|
||||||
22
src/common/queues/sla.worker.ts
Normal file
22
src/common/queues/sla.worker.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import { redisConfig } from './config.js';
|
||||||
|
import { SLAService } from '../../services/SLAService.js';
|
||||||
|
|
||||||
|
export const slaWorker = new Worker('slaQueue', async (job) => {
|
||||||
|
console.log(`[SLA Worker] Processing job ${job.id} of type ${job.name}`);
|
||||||
|
|
||||||
|
if (job.name === 'checkSLABreaches') {
|
||||||
|
await SLAService.checkBreaches();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
connection: redisConfig,
|
||||||
|
concurrency: 1 // Single concurrency for SLA checks to avoid race conditions
|
||||||
|
});
|
||||||
|
|
||||||
|
slaWorker.on('completed', (job) => {
|
||||||
|
console.log(`[SLA Worker] Job ${job.id} completed successfully`);
|
||||||
|
});
|
||||||
|
|
||||||
|
slaWorker.on('failed', (job, err) => {
|
||||||
|
console.error(`[SLA Worker] Job ${job?.id} failed with error: ${err.message}`);
|
||||||
|
});
|
||||||
@ -40,6 +40,15 @@ export interface ApplicationAttributes {
|
|||||||
architectureDocumentDate: Date | null;
|
architectureDocumentDate: Date | null;
|
||||||
architectureCompletionDate: Date | null;
|
architectureCompletionDate: Date | null;
|
||||||
score: number;
|
score: number;
|
||||||
|
// Statutory & Bank Details
|
||||||
|
panNumber: string | null;
|
||||||
|
gstNumber: string | null;
|
||||||
|
bankName: string | null;
|
||||||
|
accountNumber: string | null;
|
||||||
|
ifscCode: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
accountHolderName: string | null;
|
||||||
|
registeredAddress: string | null;
|
||||||
documents: any[];
|
documents: any[];
|
||||||
timeline: any[];
|
timeline: any[];
|
||||||
}
|
}
|
||||||
@ -198,6 +207,10 @@ export default (sequelize: Sequelize) => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: 'Pending'
|
defaultValue: 'Pending'
|
||||||
},
|
},
|
||||||
|
registeredAddress: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
submittedBy: {
|
submittedBy: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -226,6 +239,34 @@ export default (sequelize: Sequelize) => {
|
|||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
|
panNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
gstNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
bankName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
accountNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
ifscCode: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
branchName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
accountHolderName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
documents: {
|
documents: {
|
||||||
type: DataTypes.JSON,
|
type: DataTypes.JSON,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
|
|||||||
@ -126,7 +126,8 @@ export default (sequelize: Sequelize) => {
|
|||||||
ConstitutionalChange.hasMany(models.RequestParticipant, {
|
ConstitutionalChange.hasMany(models.RequestParticipant, {
|
||||||
foreignKey: 'requestId',
|
foreignKey: 'requestId',
|
||||||
as: 'participants',
|
as: 'participants',
|
||||||
scope: { requestType: 'constitutional' }
|
scope: { requestType: 'constitutional' },
|
||||||
|
constraints: false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -132,6 +132,8 @@ export default (sequelize: Sequelize) => {
|
|||||||
if (User) {
|
if (User) {
|
||||||
Dealer.hasOne(User, { foreignKey: 'dealerId', as: 'user' });
|
Dealer.hasOne(User, { foreignKey: 'dealerId', as: 'user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Dealer.hasMany(models.DealerBankDetail, { foreignKey: 'dealerId', as: 'bankDetails' });
|
||||||
};
|
};
|
||||||
|
|
||||||
return Dealer;
|
return Dealer;
|
||||||
|
|||||||
74
src/database/models/DealerBankDetail.ts
Normal file
74
src/database/models/DealerBankDetail.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||||
|
|
||||||
|
export interface DealerBankDetailAttributes {
|
||||||
|
id: string;
|
||||||
|
dealerId: string;
|
||||||
|
accountHolderName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
ifscCode: string;
|
||||||
|
bankName: string;
|
||||||
|
branchName: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DealerBankDetailInstance extends Model<DealerBankDetailAttributes>, DealerBankDetailAttributes { }
|
||||||
|
|
||||||
|
export default (sequelize: Sequelize) => {
|
||||||
|
const DealerBankDetail = sequelize.define<DealerBankDetailInstance>('DealerBankDetail', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
dealerId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'dealers',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accountHolderName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
accountNumber: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
ifscCode: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
bankName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
branchName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
isPrimary: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: 'active'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'dealer_bank_details',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['dealerId'] },
|
||||||
|
{ fields: ['status'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
(DealerBankDetail as any).associate = (models: any) => {
|
||||||
|
DealerBankDetail.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealer' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return DealerBankDetail;
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ import { FNF_STATUS } from '../../common/config/constants.js';
|
|||||||
|
|
||||||
export interface FnFAttributes {
|
export interface FnFAttributes {
|
||||||
id: string;
|
id: string;
|
||||||
|
settlementId: string | null;
|
||||||
resignationId: string | null;
|
resignationId: string | null;
|
||||||
terminationRequestId: string | null;
|
terminationRequestId: string | null;
|
||||||
outletId: string | null;
|
outletId: string | null;
|
||||||
@ -10,8 +11,13 @@ export interface FnFAttributes {
|
|||||||
status: string;
|
status: string;
|
||||||
totalReceivables: number;
|
totalReceivables: number;
|
||||||
totalPayables: number;
|
totalPayables: number;
|
||||||
|
totalDeductions: number;
|
||||||
netAmount: number;
|
netAmount: number;
|
||||||
|
settlementAmount: number | null;
|
||||||
settlementDate: Date | null;
|
settlementDate: Date | null;
|
||||||
|
paymentMode: string | null;
|
||||||
|
transactionReference: string | null;
|
||||||
|
remarks: string | null;
|
||||||
clearanceDocuments: any[];
|
clearanceDocuments: any[];
|
||||||
progressPercentage: number;
|
progressPercentage: number;
|
||||||
}
|
}
|
||||||
@ -25,6 +31,11 @@ export default (sequelize: Sequelize) => {
|
|||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true
|
primaryKey: true
|
||||||
},
|
},
|
||||||
|
settlementId: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
resignationId: {
|
resignationId: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -69,14 +80,34 @@ export default (sequelize: Sequelize) => {
|
|||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
|
totalDeductions: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
netAmount: {
|
netAmount: {
|
||||||
type: DataTypes.DECIMAL(15, 2),
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
|
settlementAmount: {
|
||||||
|
type: DataTypes.DECIMAL(15, 2),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
settlementDate: {
|
settlementDate: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
},
|
},
|
||||||
|
paymentMode: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
transactionReference: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
remarks: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
clearanceDocuments: {
|
clearanceDocuments: {
|
||||||
type: DataTypes.JSON,
|
type: DataTypes.JSON,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Model, DataTypes, Sequelize } from 'sequelize';
|
|||||||
export interface FnFLineItemAttributes {
|
export interface FnFLineItemAttributes {
|
||||||
id: string;
|
id: string;
|
||||||
fnfId: string;
|
fnfId: string;
|
||||||
itemType: 'Payable' | 'Receivable' | 'Deduction';
|
itemType: 'Payable' | 'Receivable' | 'Deduction' | 'Recovery';
|
||||||
description: string;
|
description: string;
|
||||||
department: string;
|
department: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
@ -28,7 +28,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemType: {
|
itemType: {
|
||||||
type: DataTypes.ENUM('Payable', 'Receivable', 'Deduction'),
|
type: DataTypes.ENUM('Payable', 'Receivable', 'Deduction', 'Recovery'),
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
|
|||||||
@ -148,6 +148,12 @@ export default (sequelize: Sequelize) => {
|
|||||||
foreignKey: 'relocationId',
|
foreignKey: 'relocationId',
|
||||||
as: 'eorChecklist'
|
as: 'eorChecklist'
|
||||||
});
|
});
|
||||||
|
RelocationRequest.hasMany(models.RequestParticipant, {
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
as: 'participants',
|
||||||
|
scope: { requestType: 'relocation' },
|
||||||
|
constraints: false
|
||||||
|
});
|
||||||
// Note: Participants are computed dynamically based on outlet location hierarchy
|
// Note: Participants are computed dynamically based on outlet location hierarchy
|
||||||
// See getRequestById in relocation.controller.ts
|
// See getRequestById in relocation.controller.ts
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,12 +13,14 @@ export interface TerminationRequestAttributes {
|
|||||||
comments: string | null;
|
comments: string | null;
|
||||||
timeline: any[];
|
timeline: any[];
|
||||||
documents: any[];
|
documents: any[];
|
||||||
departmentalClearances: {
|
departmentalClearances: Record<string, {
|
||||||
spares: boolean;
|
status: 'Pending' | 'Cleared' | 'Dues';
|
||||||
service: boolean;
|
amount?: number;
|
||||||
accounts: boolean;
|
type?: 'Payable' | 'Recovery';
|
||||||
logistics: boolean;
|
remarks?: string;
|
||||||
} | null;
|
updatedAt?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminationRequestInstance extends Model<TerminationRequestAttributes>, TerminationRequestAttributes { }
|
export interface TerminationRequestInstance extends Model<TerminationRequestAttributes>, TerminationRequestAttributes { }
|
||||||
@ -85,12 +87,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
},
|
},
|
||||||
departmentalClearances: {
|
departmentalClearances: {
|
||||||
type: DataTypes.JSON,
|
type: DataTypes.JSON,
|
||||||
defaultValue: {
|
defaultValue: {}
|
||||||
spares: false,
|
|
||||||
service: false,
|
|
||||||
accounts: false,
|
|
||||||
logistics: false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'termination_requests',
|
tableName: 'termination_requests',
|
||||||
|
|||||||
@ -146,6 +146,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
|
|
||||||
User.hasMany(models.AuditLog, { foreignKey: 'userId', as: 'auditLogs' });
|
User.hasMany(models.AuditLog, { foreignKey: 'userId', as: 'auditLogs' });
|
||||||
User.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealerProfile' });
|
User.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealerProfile' });
|
||||||
|
User.hasMany(models.Outlet, { foreignKey: 'dealerId', as: 'outlets' });
|
||||||
};
|
};
|
||||||
|
|
||||||
return User;
|
return User;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import createState from './State.js';
|
|||||||
import createTerminationScnResponse from './TerminationScnResponse.js';
|
import createTerminationScnResponse from './TerminationScnResponse.js';
|
||||||
import createTerminationHearingRecord from './TerminationHearingRecord.js';
|
import createTerminationHearingRecord from './TerminationHearingRecord.js';
|
||||||
import createFffClearance from './FffClearance.js';
|
import createFffClearance from './FffClearance.js';
|
||||||
|
import createDealerBankDetail from './DealerBankDetail.js';
|
||||||
|
|
||||||
// Batch 1: Organizational Hierarchy & User Management
|
// Batch 1: Organizational Hierarchy & User Management
|
||||||
import createRole from './Role.js';
|
import createRole from './Role.js';
|
||||||
@ -146,6 +147,7 @@ db.State = createState(sequelize);
|
|||||||
db.TerminationScnResponse = createTerminationScnResponse(sequelize);
|
db.TerminationScnResponse = createTerminationScnResponse(sequelize);
|
||||||
db.TerminationHearingRecord = createTerminationHearingRecord(sequelize);
|
db.TerminationHearingRecord = createTerminationHearingRecord(sequelize);
|
||||||
db.FffClearance = createFffClearance(sequelize);
|
db.FffClearance = createFffClearance(sequelize);
|
||||||
|
db.DealerBankDetail = createDealerBankDetail(sequelize);
|
||||||
|
|
||||||
// Batch 1: Organizational Hierarchy & User Management
|
// Batch 1: Organizational Hierarchy & User Management
|
||||||
db.Role = createRole(sequelize);
|
db.Role = createRole(sequelize);
|
||||||
|
|||||||
@ -1,47 +1,34 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Great News: You Are Shortlisted!</title>
|
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
.container { max-width: 600px; margin: 20px auto; border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
.header { background: #1B1B1B; color: #fff; padding: 30px; text-align: center; border-bottom: 5px solid #A11B1E; }
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
|
.header img { height: 40px; }
|
||||||
.content { padding: 30px; }
|
.content { padding: 30px; }
|
||||||
.footer { background: #f9f9f9; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
.button { display: inline-block; padding: 12px 24px; background: #A11B1E; color: #fff; text-decoration: none; border-radius: 4px; margin-top: 20px; font-weight: bold; }
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
.highlight-box { background: #fdf2f2; border: 1px dashed #A11B1E; padding: 20px; margin: 20px 0; border-radius: 8px; text-align: center; }
|
.highlight { color: #e31837; font-weight: bold; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1 style="margin: 0; font-size: 24px; color: #A11B1E;">Royal Enfield</h1>
|
<h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1>
|
||||||
<p style="margin: 5px 0 0; opacity: 0.8; font-weight: bold; font-size: 18px;">Congratulations!</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Dear <strong>{{applicantName}}</strong>,</p>
|
<h2>Hi {{applicantName}},</h2>
|
||||||
<p>We are excited to inform you that your application (<strong>{{applicationId}}</strong>) for the Royal Enfield dealership in <strong>{{location}}</strong> has been <strong style="color: #A11B1E;">SHORTLISTED</strong> by our Dealer Development team.</p>
|
<p>We are pleased to inform you that your dealership application for <span class="highlight text-uppercase">{{location}}</span> has been shortlisted for further evaluation.</p>
|
||||||
|
<p>Our team will contact you shortly to schedule the next level of interviews. In the meantime, you can track your application status on our dealer portal.</p>
|
||||||
<div class="highlight-box">
|
<div style="text-align: center;">
|
||||||
<p style="margin-top: 0;">You have successfully cleared the initial assessment phase. The next step will be an <strong>Initial Evaluation Interview</strong> with our Zonal and Regional managers.</p>
|
<a href="{{portalLink}}" class="button">Visit Dealer Portal</a>
|
||||||
<a href="{{portalLink}}" class="button">Log In to Onboarding Portal</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p>Regards,<br>Royal Enfield Dealer Development Team</p>
|
||||||
<p><strong>What happens next?</strong></p>
|
|
||||||
<ul>
|
|
||||||
<li>Our team will schedule an interview slot for you soon.</li>
|
|
||||||
<li>You will receive a separate notification with the meeting details (Online/Offline).</li>
|
|
||||||
<li>Please ensure all your business documents are ready for the evaluation.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>We look forward to meeting you and discussing this opportunity further.</p>
|
|
||||||
|
|
||||||
<p>Best regards,<br><strong>Dealer Development Team</strong><br>Royal Enfield</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© {{year}} Royal Enfield. All rights reserved.
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
<p>This is an automated message, please do not reply.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
30
src/emailtemplates/constitutional_change_submitted.html
Normal file
30
src/emailtemplates/constitutional_change_submitted.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi Team,</h2>
|
||||||
|
<p>A new Constitutional Change request has been submitted by <span style="font-weight: bold;">{{dealerName}}</span>.</p>
|
||||||
|
<p><strong>Request Type:</strong> {{changeType}}<br><strong>Request ID:</strong> {{requestId}}</p>
|
||||||
|
<p>Please log in to the Dealer development portal to review the request and documents.</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{link}}" class="button">Review Request</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
src/emailtemplates/constitutional_change_update.html
Normal file
30
src/emailtemplates/constitutional_change_update.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.status-badge { display: inline-block; padding: 6px 12px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; font-weight: bold; margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi {{dealerName}},</h2>
|
||||||
|
<p>This is to inform you that your Constitutional Change request has been updated.</p>
|
||||||
|
<p><strong>Current Stage:</strong> <span class="status-badge">{{status}}</span></p>
|
||||||
|
<p>{{remarks}}</p>
|
||||||
|
<div style="text-align: center; margin-top: 20px;">
|
||||||
|
<a href="{{link}}" style="display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">View Request</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/emailtemplates/dealer_code_ready.html
Normal file
31
src/emailtemplates/dealer_code_ready.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #000; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.code-box { background-color: #f4f4f4; padding: 15px; border: 1px dashed #e31837; text-align: center; font-family: monospace; font-size: 18px; margin: 20px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>SAP Dealer Codes Generated</h2>
|
||||||
|
<p>Hi {{applicantName}},</p>
|
||||||
|
<p>We are pleased to inform you that your SAP Dealer Codes for application **{{applicationId}}** are now ready and active in our system.</p>
|
||||||
|
<div class="code-box">
|
||||||
|
Sales Code: {{salesCode}}<br>
|
||||||
|
Service Code: {{serviceCode}}
|
||||||
|
</div>
|
||||||
|
<p>You can now proceed with system onboarding and initial orders.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
src/emailtemplates/generic_notification.html
Normal file
25
src/emailtemplates/generic_notification.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>{{title}}</h2>
|
||||||
|
<p>{{message}}</p>
|
||||||
|
<p>Please log in to the portal for more details.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -2,27 +2,30 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: sans-serif; line-height: 1.6; color: #333; }
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
.container { padding: 20px; border: 1px solid #ddd; border-radius: 8px; max-width: 600px; }
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
.header { font-size: 20px; font-weight: bold; color: #c2410c; margin-bottom: 20px; }
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
.details { background: #fef3c7; padding: 15px; border-radius: 4px; margin: 20px 0; }
|
.content { padding: 30px; }
|
||||||
.footer { font-size: 12px; color: #666; margin-top: 30px; }
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.details { background-color: #f9f9f9; padding: 15px; border-radius: 4px; border-left: 4px solid #e31837; margin: 20px 0; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">Interview Scheduled: {{applicationId}}</div>
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
<p>Dear {{name}},</p>
|
<div class="content">
|
||||||
<p>An interview has been scheduled for the Royal Enfield Dealership application.</p>
|
<h2>Hi {{name}},</h2>
|
||||||
<div class="details">
|
<p>Your interview for the Royal Enfield dealership application (<strong>{{applicationId}}</strong>) has been scheduled.</p>
|
||||||
<strong>Level:</strong> {{level}}<br>
|
<div class="details">
|
||||||
<strong>Date & Time:</strong> {{dateTime}}<br>
|
<p><strong>Interview Level:</strong> {{level}}<br>
|
||||||
<strong>Type:</strong> {{type}}<br>
|
<strong>Date & Time:</strong> {{dateTime}}<br>
|
||||||
<strong>Location/Link:</strong> {{location}}
|
<strong>Mode/Location:</strong> {{location}}<br>
|
||||||
|
<strong>Type:</strong> {{type}}</p>
|
||||||
|
</div>
|
||||||
|
<p>Please ensure you are available at the scheduled time. If it's a virtual interview, the link will be shared separately or is included in the location field above.</p>
|
||||||
</div>
|
</div>
|
||||||
<p>Please ensure you are available at the scheduled time.</p>
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© {{year}} Royal Enfield. All rights reserved.
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
30
src/emailtemplates/loa_issued.html
Normal file
30
src/emailtemplates/loa_issued.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #000; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Welcome to the Family!</h2>
|
||||||
|
<p>Dear {{applicantName}},</p>
|
||||||
|
<p>We are honored to issue your Letter of Appointment (LOA) for the Royal Enfield dealership ({{applicationId}}). Your dealership is now officially authorized under the code: <span style="font-weight: bold; color: #e31837;">{{dealerCode}}</span>.</p>
|
||||||
|
<p>We look forward to a successful partnership.</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{portalLink}}" class="button">View LOA</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
src/emailtemplates/loi_issued.html
Normal file
29
src/emailtemplates/loi_issued.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Congratulations {{applicantName}}!</h2>
|
||||||
|
<p>We are delighted to inform you that your Letter of Intent (LOI) for the Royal Enfield dealership has been issued for the application <strong>{{applicationId}}</strong>.</p>
|
||||||
|
<p>Please log in to the portal to view and acknowledge the document to move to the next stage.</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{portalLink}}" class="button">View LOI</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,64 +1,27 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
font-family: Arial, sans-serif;
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
line-height: 1.6;
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
color: #333;
|
.content { padding: 30px; }
|
||||||
}
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background-color: #000;
|
|
||||||
color: #fff;
|
|
||||||
padding: 15px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
margin-top: 30px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
<h2>Royal Enfield Dealership Application</h2>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Dear {{applicantName}},</p>
|
<h2>Dear {{applicantName}},</h2>
|
||||||
<p>Thank you for your interest in Royal Enfield and for your application to represent our brand as an
|
<p>Thank you for your interest in a Royal Enfield dealership for <strong>{{location}}</strong>.</p>
|
||||||
authorized dealer.</p>
|
<p>After careful review of your application and the current organizational requirements, we regret to inform you that we are unable to proceed with your request at this time.</p>
|
||||||
<p>We have carefully reviewed your expression of interest in relation to our current network expansion
|
<p>We will keep your profile in our database for future opportunities. We wish you the very best in your future endeavors.</p>
|
||||||
strategy for <strong>{{location}}</strong>. At this juncture, we do not have any immediate vacancies or
|
<p>Regards,<br>Royal Enfield Team</p>
|
||||||
planned dealership opportunities available in this specific territory.</p>
|
|
||||||
<p>However, we have successfully retained your profile in our prospective partners database. Rest assured,
|
|
||||||
our team will proactively reach out to you should a suitable opportunity materialize in this region or
|
|
||||||
if our strategic requirements in your preferred location evolve further.</p>
|
|
||||||
<p>We appreciate the time you took to share your details and your continued enthusiasm for Royal Enfield.
|
|
||||||
</p>
|
|
||||||
<p>Best regards,<br>Dealer Development Team<br>Royal Enfield</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@ -2,31 +2,27 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
.header { background-color: #000; color: #fff; padding: 15px; text-align: center; }
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
.content { padding: 20px; background-color: #f9f9f9; }
|
.content { padding: 30px; }
|
||||||
.button { display: inline-block; padding: 10px 20px; background-color: #d32f2f; color: white; text-decoration: none; border-radius: 5px; margin-top: 20px; }
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
.footer { font-size: 12px; color: #777; margin-top: 30px; text-align: center; }
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
.highlight { color: #e31837; font-weight: bold; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
<h2>Royal Enfield Dealership Opportunity</h2>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Dear {{applicantName}},</p>
|
<h2>Hi {{applicantName}},</h2>
|
||||||
<p>Thank you for your interest in partnering with Royal Enfield.</p>
|
<p>Thank you for expressing interest in a Royal Enfield dealership opportunity for <span class="highlight">{{location}}</span>.</p>
|
||||||
<p>We are pleased to inform you that there is an open opportunity for a dealership in your requested location (<strong>{{location}}</strong>).</p>
|
<p>To proceed with your application, we require you to complete a mandatory business assessment questionnaire. This will help us evaluate your profile better.</p>
|
||||||
<p>To proceed with your application, please complete the detailed Dealership Assessment Questionnaire by clicking the link below:</p>
|
<div style="text-align: center;">
|
||||||
|
<a href="{{link}}" class="button">Complete Questionnaire</a>
|
||||||
<a href="{{link}}" class="button">Start Questionnaire</a>
|
</div>
|
||||||
|
<p>Please complete this at your earliest convenience. If you have any questions, feel free to contact our development team.</p>
|
||||||
<p style="margin-top: 20px;">Or copy this link to your browser:</p>
|
<p>Regards,<br>Royal Enfield Team</p>
|
||||||
<p>{{link}}</p>
|
|
||||||
|
|
||||||
<p><strong>Note:</strong> No login credentials are required at this stage. Your Application ID is <strong>{{applicationId}}</strong>.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
|||||||
30
src/emailtemplates/questionnaire_reminder.html
Normal file
30
src/emailtemplates/questionnaire_reminder.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi {{applicantName}},</h2>
|
||||||
|
<p>This is a reminder that we are awaiting your response to the dealership assessment questionnaire for <strong>{{location}}</strong>.</p>
|
||||||
|
<p>Completing this questionnaire is a mandatory step to move forward with your application.</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{link}}" class="button">Complete Now</a>
|
||||||
|
</div>
|
||||||
|
<p>If you have already submitted it, please ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,42 +1,25 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Questionnaire Submitted Successfully</title>
|
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
.container { max-width: 600px; margin: 20px auto; border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
.header { background: #A11B1E; color: #fff; padding: 30px; text-align: center; }
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
.content { padding: 30px; }
|
.content { padding: 30px; }
|
||||||
.footer { background: #f9f9f9; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
.button { display: inline-block; padding: 12px 24px; background: #A11B1E; color: #fff; text-decoration: none; border-radius: 4px; margin-top: 20px; }
|
|
||||||
.info-box { background: #fdf2f2; border-left: 4px solid #A11B1E; padding: 15px; margin: 20px 0; font-size: 14px; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
<h1 style="margin: 0; font-size: 24px;">Royal Enfield Dealership</h1>
|
|
||||||
<p style="margin: 5px 0 0; opacity: 0.8;">Questionnaire Submitted Successfully</p>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Dear <strong>{{applicantName}}</strong>,</p>
|
<h2>Hi {{applicantName}},</h2>
|
||||||
<p>Thank you for submitting your dealership assessment questionnaire for <strong>{{location}}</strong>. We have successfully received your responses.</p>
|
<p>Your business assessment questionnaire for <strong>{{location}}</strong> (Application ID: {{applicationId}}) has been successfully submitted.</p>
|
||||||
|
<p>Our team will review your responses and get back to you with the next steps.</p>
|
||||||
<div class="info-box">
|
<p>Regards,<br>Royal Enfield Team</p>
|
||||||
<strong>Application ID:</strong> {{applicationId}}<br>
|
|
||||||
<strong>Status:</strong> Under Review
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>Our Dealer Development team will now review your submission. You will be notified of the next steps via email. You can track the status of your application anytime by logging into the Dealer Onboarding Portal.</p>
|
|
||||||
|
|
||||||
<p>If you have any questions in the meantime, please feel free to reach out to our support team.</p>
|
|
||||||
|
|
||||||
<p>Regards,<br><strong>Dealer Development Team</strong><br>Royal Enfield</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© {{year}} Royal Enfield. All rights reserved.
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
27
src/emailtemplates/resignation_approved.html
Normal file
27
src/emailtemplates/resignation_approved.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Resignation Request Approved</h2>
|
||||||
|
<p>Dear {{dealerName}},</p>
|
||||||
|
<p>Your resignation request (Request ID: {{resignationId}}) has been approved by the Dealer Development team.</p>
|
||||||
|
<p><strong>Proposed Last Working Day:</strong> {{lwd}}</p>
|
||||||
|
<p>The clearance process has been initiated. Please ensure all department dues are cleared as per the timeline.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
src/emailtemplates/resignation_submitted.html
Normal file
30
src/emailtemplates/resignation_submitted.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi Team,</h2>
|
||||||
|
<p>A new resignation request has been submitted by <span style="font-weight: bold;">{{dealerName}}</span>.</p>
|
||||||
|
<p><strong>Request ID:</strong> {{resignationId}}<br><strong>Proposed Last Working Day:</strong> {{lwd}}</p>
|
||||||
|
<p>Please log in to the Dealer development portal to review and initiate the clearance process.</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{link}}" class="button">Review Resignation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/emailtemplates/resignation_update.html
Normal file
31
src/emailtemplates/resignation_update.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #e31837; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.status-badge { display: inline-block; padding: 6px 12px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; font-weight: bold; margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi {{dealerName}},</h2>
|
||||||
|
<p>This is to inform you that the status of your resignation request has been updated.</p>
|
||||||
|
<p><strong>Current Stage:</strong> <span class="status-badge">{{status}}</span></p>
|
||||||
|
<p>{{remarks}}</p>
|
||||||
|
<p>You can track the progress and clearance status on the dealer development portal.</p>
|
||||||
|
<div style="text-align: center; margin-top: 20px;">
|
||||||
|
<a href="{{link}}" style="display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">View Request</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/emailtemplates/termination_scn.html
Normal file
31
src/emailtemplates/termination_scn.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #000; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
.critical { color: #e31837; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2 class="critical">URGENT: Show Cause Notice Issued</h2>
|
||||||
|
<p>Dear {{dealerName}},</p>
|
||||||
|
<p>This is to inform you that a <span class="critical">Show Cause Notice</span> has been issued regarding your dealership contract (Request ID: {{terminationId}}).</p>
|
||||||
|
<p>You are required to submit your response on the dealer development portal by <span class="highlight">{{deadline}}</span>. Failure to respond may lead to further action as per the dealership agreement.</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{link}}" class="button">Submit Response</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/emailtemplates/termination_update.html
Normal file
31
src/emailtemplates/termination_update.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #000; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.status-badge { display: inline-block; padding: 6px 12px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 4px; font-weight: bold; margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi {{dealerName}},</h2>
|
||||||
|
<p>This is to inform you that the status of your dealership termination request has been updated.</p>
|
||||||
|
<p><strong>Current Stage:</strong> <span class="status-badge">{{status}}</span></p>
|
||||||
|
<p>{{remarks}}</p>
|
||||||
|
<p>Please log in to the portal to check if any actions are required from your end.</p>
|
||||||
|
<div style="text-align: center; margin-top: 20px;">
|
||||||
|
<a href="{{link}}" style="display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">View Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,54 +1,26 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
font-family: sans-serif;
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
line-height: 1.6;
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
color: #333;
|
.content { padding: 30px; }
|
||||||
}
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #c2410c;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">New Application Assignment</div>
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
<p>Hello {{userName}},</p>
|
<div class="content">
|
||||||
<p>You have been assigned as a <strong>{{participantType}}</strong> to the following application:</p>
|
<h2>Hi {{userName}},</h2>
|
||||||
<div class="info">
|
<p>You have been assigned as a <span style="font-weight: bold;">{{participantType}}</span> for the following dealership application:</p>
|
||||||
<strong>Application ID:</strong> {{applicationId}}<br>
|
<p><strong>Applicant:</strong> {{dealerName}}<br><strong>Application ID:</strong> {{applicationId}}</p>
|
||||||
<strong>Dealer Name:</strong> {{dealerName}}
|
<p>Please log in to the portal to review the application and complete your tasks.</p>
|
||||||
</div>
|
</div>
|
||||||
<p>You can access the application details in the dealership onboarding portal.</p>
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© {{year}} Royal Enfield. All rights reserved.
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
29
src/emailtemplates/worknote_notification.html
Normal file
29
src/emailtemplates/worknote_notification.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Arial', sans-serif; color: #333; line-height: 1.6; margin: 0; padding: 0; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; border: 1px solid #eee; }
|
||||||
|
.header { background-color: #333; padding: 20px; text-align: center; }
|
||||||
|
.content { padding: 30px; }
|
||||||
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; font-size: 12px; color: #777; }
|
||||||
|
.button { display: inline-block; padding: 12px 24px; background-color: #e31837; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold; margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header"><h1 style="color: white; margin: 0;">ROYAL ENFIELD</h1></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Hi {{userName}},</h2>
|
||||||
|
<p>You have a new work note or update on an application you are participating in.</p>
|
||||||
|
<p><strong>Application:</strong> {{dealerName}} ({{applicationId}})<br><strong>Update:</strong> {{message}}</p>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{link}}" class="button">View Update</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{year}} Royal Enfield. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -226,6 +226,21 @@ const processStageDecision = async (params: {
|
|||||||
targetStage = 'Statutory Work';
|
targetStage = 'Statutory Work';
|
||||||
targetProgress = 85;
|
targetProgress = 85;
|
||||||
} else if (stageCode === 'LOA_APPROVAL') {
|
} else if (stageCode === 'LOA_APPROVAL') {
|
||||||
|
// Hard-stop validation: Check for statutory and bank details
|
||||||
|
const missingFields = [];
|
||||||
|
if (!application.panNumber) missingFields.push('PAN Number');
|
||||||
|
if (!application.gstNumber) missingFields.push('GST Number');
|
||||||
|
if (!application.bankName) missingFields.push('Bank Name');
|
||||||
|
if (!application.accountNumber) missingFields.push('Account Number');
|
||||||
|
if (!application.ifscCode) missingFields.push('IFSC Code');
|
||||||
|
|
||||||
|
if (decision === 'Approved' && missingFields.length > 0) {
|
||||||
|
return {
|
||||||
|
forbidden: true,
|
||||||
|
message: `Cannot approve LOA: Missing mandatory fields: ${missingFields.join(', ')}. Please ensure they are filled in the application details.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
targetStatus = APPLICATION_STATUS.LOA_ISSUED;
|
targetStatus = APPLICATION_STATUS.LOA_ISSUED;
|
||||||
targetStage = 'LOA';
|
targetStage = 'LOA';
|
||||||
targetProgress = 95;
|
targetProgress = 95;
|
||||||
|
|||||||
91
src/modules/dealer/bank.controller.ts
Normal file
91
src/modules/dealer/bank.controller.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import db from '../../database/models/index.js';
|
||||||
|
const { DealerBankDetail, AuditLog } = db;
|
||||||
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
|
import { AUDIT_ACTIONS } from '../../common/config/constants.js';
|
||||||
|
|
||||||
|
export const getBankDetailsByDealerId = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { dealerId } = req.params;
|
||||||
|
const bankDetails = await DealerBankDetail.findAll({
|
||||||
|
where: { dealerId },
|
||||||
|
order: [['isPrimary', 'DESC'], ['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
res.json({ success: true, bankDetails });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bank details:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error fetching bank details' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertBankDetail = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { dealerId } = req.params;
|
||||||
|
const { id, accountHolderName, accountNumber, ifscCode, bankName, branchName, isPrimary } = req.body;
|
||||||
|
|
||||||
|
if (isPrimary) {
|
||||||
|
// Set others as non-primary
|
||||||
|
await DealerBankDetail.update(
|
||||||
|
{ isPrimary: false },
|
||||||
|
{ where: { dealerId } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bankDetail;
|
||||||
|
if (id) {
|
||||||
|
bankDetail = await DealerBankDetail.findByPk(id);
|
||||||
|
if (!bankDetail) return res.status(404).json({ success: false, message: 'Bank detail not found' });
|
||||||
|
|
||||||
|
await bankDetail.update({
|
||||||
|
accountHolderName, accountNumber, ifscCode, bankName, branchName, isPrimary
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.UPDATED,
|
||||||
|
entityType: 'dealer_bank',
|
||||||
|
entityId: id,
|
||||||
|
newData: { accountHolderName, bankName, isPrimary }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bankDetail = await DealerBankDetail.create({
|
||||||
|
dealerId, accountHolderName, accountNumber, ifscCode, bankName, branchName, isPrimary
|
||||||
|
});
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.CREATED,
|
||||||
|
entityType: 'dealer_bank',
|
||||||
|
entityId: bankDetail.id,
|
||||||
|
newData: { accountHolderName, bankName, isPrimary }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Bank detail saved successfully', bankDetail });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving bank detail:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error saving bank detail' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBankDetail = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const bankDetail = await DealerBankDetail.findByPk(id);
|
||||||
|
if (!bankDetail) return res.status(404).json({ success: false, message: 'Bank detail not found' });
|
||||||
|
|
||||||
|
await bankDetail.destroy();
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.DELETED,
|
||||||
|
entityType: 'dealer_bank',
|
||||||
|
entityId: id
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Bank detail deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting bank detail:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error deleting bank detail' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,7 +3,7 @@ import bcrypt from 'bcryptjs';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const {
|
const {
|
||||||
Dealer, DealerCode, Application, User, AuditLog, Outlet, Region, Zone,
|
Dealer, DealerCode, Application, User, AuditLog, Outlet, Region, Zone,
|
||||||
Resignation, RelocationRequest, ConstitutionalChange
|
Resignation, RelocationRequest, ConstitutionalChange, EorChecklist, EorChecklistItem, DealerBankDetail
|
||||||
} = db;
|
} = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
|
import { AUDIT_ACTIONS, APPLICATION_STATUS } from '../../common/config/constants.js';
|
||||||
@ -15,14 +15,34 @@ export const getDealers = async (req: Request, res: Response) => {
|
|||||||
const dealers = await Dealer.findAll({
|
const dealers = await Dealer.findAll({
|
||||||
include: [
|
include: [
|
||||||
{ model: DealerCode, as: 'dealerCode' },
|
{ model: DealerCode, as: 'dealerCode' },
|
||||||
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] }
|
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
|
||||||
|
{ model: User, as: 'user', attributes: ['id', 'email', 'status', 'isActive'] }
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
});
|
});
|
||||||
res.json({ success: true, data: dealers });
|
res.json({ success: true, data: dealers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get dealers error:', error);
|
console.error('Get dealers error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error fetching dealers' });
|
res.status(500).json({ success: false, message: 'Error fetching dealer dashboard' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDealerByApplicationId = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { applicationId } = req.params;
|
||||||
|
const dealer = await Dealer.findOne({
|
||||||
|
where: { applicationId },
|
||||||
|
include: [{ model: db.User, as: 'user' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dealer) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Dealer not found for this application' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: dealer });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get dealer by app ID error:', error);
|
||||||
|
res.status(500).json({ success: false, message: 'Error fetching dealer' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -33,20 +53,31 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
|||||||
const application = await Application.findByPk(applicationId);
|
const application = await Application.findByPk(applicationId);
|
||||||
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
||||||
|
|
||||||
// SRS Validation: Allow onboarding at 'Inauguration' or 'Approved' stage
|
// SRS 3.1.2 Validation: Enforce 100% EOR (Evidence of Readiness) Checklist completion
|
||||||
// 'Approved' is accepted because frontend may update status before calling createDealer
|
const eorChecklist = await EorChecklist.findOne({
|
||||||
if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION &&
|
where: { applicationId: application.id },
|
||||||
application.overallStatus !== APPLICATION_STATUS.APPROVED) {
|
include: [{ model: EorChecklistItem, as: 'items' }]
|
||||||
return res.status(400).json({
|
});
|
||||||
|
|
||||||
|
const isEorComplete = eorChecklist &&
|
||||||
|
eorChecklist.status === 'Completed' &&
|
||||||
|
eorChecklist.items.length > 0 &&
|
||||||
|
eorChecklist.items.every((item: any) => item.isCompliant);
|
||||||
|
|
||||||
|
if (!isEorComplete && application.overallStatus !== APPLICATION_STATUS.ONBOARDED) {
|
||||||
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' or '${APPLICATION_STATUS.APPROVED}' stage before final onboarding. Current status: ${application.overallStatus}`
|
message: `Final Onboarding Blocked: EOR Checklist is either missing, incomplete, or contains non-compliant items. All 100% readiness criteria must be verified by the auditor before inauguration.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Check EOR Progress (if progressPercentage is tracked accurately)
|
if (application.overallStatus !== APPLICATION_STATUS.INAUGURATION &&
|
||||||
if (application.progressPercentage < 95 && application.overallStatus !== APPLICATION_STATUS.INAUGURATION) {
|
application.overallStatus !== APPLICATION_STATUS.APPROVED &&
|
||||||
// We can be conservative here or strictly check 100%
|
application.overallStatus !== APPLICATION_STATUS.ONBOARDED) {
|
||||||
// For now, enforcing the Status is the most critical SRS requirement.
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Application must be in the '${APPLICATION_STATUS.INAUGURATION}' stage before final onboarding. Current status: ${application.overallStatus}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find existing dealer or auto-detect dealer code
|
// Find existing dealer or auto-detect dealer code
|
||||||
@ -106,6 +137,20 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
|||||||
where: { applicationId: application.id, status: { [Op.iLike]: 'Approved' } },
|
where: { applicationId: application.id, status: { [Op.iLike]: 'Approved' } },
|
||||||
order: [['approvedAt', 'DESC']]
|
order: [['approvedAt', 'DESC']]
|
||||||
});
|
});
|
||||||
|
const missingFields = [];
|
||||||
|
if (!application.panNumber) missingFields.push('PAN Number');
|
||||||
|
if (!application.gstNumber) missingFields.push('GST Number');
|
||||||
|
if (!application.bankName) missingFields.push('Bank Name');
|
||||||
|
if (!application.accountNumber) missingFields.push('Account Number');
|
||||||
|
if (!application.ifscCode) missingFields.push('IFSC Code');
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Final Onboarding Failed: Mandatory fields missing: ${missingFields.join(', ')}. These must be filled before the dealer can be onboarded.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const deposit = await db.SecurityDeposit.findOne({
|
const deposit = await db.SecurityDeposit.findOne({
|
||||||
where: {
|
where: {
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
@ -121,6 +166,9 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
|||||||
dealerCodeId: targetDealerCodeId,
|
dealerCodeId: targetDealerCodeId,
|
||||||
legalName: application.applicantName,
|
legalName: application.applicantName,
|
||||||
businessName: application.applicantName,
|
businessName: application.applicantName,
|
||||||
|
registeredAddress: application.address || application.businessAddress || 'Address Pending',
|
||||||
|
panNumber: application.panNumber,
|
||||||
|
gstNumber: application.gstNumber,
|
||||||
constitutionType: application.constitutionType || 'Proprietorship',
|
constitutionType: application.constitutionType || 'Proprietorship',
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
onboardedAt: new Date(),
|
onboardedAt: new Date(),
|
||||||
@ -130,6 +178,21 @@ export const createDealer = async (req: AuthRequest, res: Response) => {
|
|||||||
securityDepositDate: deposit?.verifiedAt
|
securityDepositDate: deposit?.verifiedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SRS 8.1: Automatically initialize Primary Bank Detail from application data
|
||||||
|
if (application.accountNumber && application.ifscCode) {
|
||||||
|
await DealerBankDetail.create({
|
||||||
|
dealerId: dealer.id,
|
||||||
|
accountHolderName: application.accountHolderName || application.applicantName,
|
||||||
|
accountNumber: application.accountNumber,
|
||||||
|
ifscCode: application.ifscCode,
|
||||||
|
bankName: application.bankName || 'Pending',
|
||||||
|
branchName: application.branchName || 'Pending',
|
||||||
|
isPrimary: true,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
console.log(`[Dealer Onboarding] Primary bank detail created for dealer ${dealer.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
await AuditLog.create({
|
await AuditLog.create({
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
action: AUDIT_ACTIONS.CREATED,
|
action: AUDIT_ACTIONS.CREATED,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import * as dealerController from './dealer.controller.js';
|
import * as dealerController from './dealer.controller.js';
|
||||||
|
import * as bankController from './bank.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
|
|
||||||
router.use(authenticate as any);
|
router.use(authenticate as any);
|
||||||
@ -8,6 +9,12 @@ router.use(authenticate as any);
|
|||||||
router.get('/dashboard', dealerController.getDealerDashboard);
|
router.get('/dashboard', dealerController.getDealerDashboard);
|
||||||
router.get('/', dealerController.getDealers);
|
router.get('/', dealerController.getDealers);
|
||||||
router.post('/', dealerController.createDealer);
|
router.post('/', dealerController.createDealer);
|
||||||
|
router.get('/application/:applicationId', dealerController.getDealerByApplicationId);
|
||||||
router.put('/:id', dealerController.updateDealer);
|
router.put('/:id', dealerController.updateDealer);
|
||||||
|
|
||||||
|
// Bank Details
|
||||||
|
router.get('/:dealerId/bank-details', bankController.getBankDetailsByDealerId);
|
||||||
|
router.post('/:dealerId/bank-details', bankController.upsertBankDetail);
|
||||||
|
router.delete('/bank-details/:id', bankController.deleteBankDetail);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { ConstitutionalChange, Outlet, User, Worknote } = db;
|
const { ConstitutionalChange, Outlet, User, Worknote, Dealer, Application, District } = db;
|
||||||
import { Op, Transaction } from 'sequelize';
|
import { Op, Transaction } from 'sequelize';
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js';
|
import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, ROLES } from '../../common/config/constants.js';
|
||||||
@ -77,8 +77,25 @@ export const getRequests = async (req: AuthRequest, res: Response) => {
|
|||||||
const requests = await ConstitutionalChange.findAll({
|
const requests = await ConstitutionalChange.findAll({
|
||||||
where,
|
where,
|
||||||
include: [
|
include: [
|
||||||
{ model: Outlet, as: 'outlet', attributes: ['code', 'name'] },
|
{ model: Outlet, as: 'outlet' },
|
||||||
{ model: User, as: 'dealer', attributes: ['fullName'] }
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'dealer',
|
||||||
|
attributes: ['fullName'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Dealer,
|
||||||
|
as: 'dealerProfile',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Application,
|
||||||
|
as: 'application',
|
||||||
|
include: [{ model: District, as: 'district' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|||||||
@ -99,7 +99,17 @@ export const getResignations = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
|
|
||||||
const resignations = await db.Resignation.findAll({
|
const resignations = await db.Resignation.findAll({
|
||||||
where,
|
where,
|
||||||
include: [{ model: db.Outlet, as: 'outlet' }],
|
include: [
|
||||||
|
{ model: db.Outlet, as: 'outlet' },
|
||||||
|
{
|
||||||
|
model: db.User,
|
||||||
|
as: 'dealer',
|
||||||
|
attributes: ['fullName'],
|
||||||
|
include: [
|
||||||
|
{ model: db.Dealer, as: 'dealerProfile', include: [{ model: db.DealerCode, as: 'dealerCode' }] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
});
|
});
|
||||||
res.json({ success: true, resignations });
|
res.json({ success: true, resignations });
|
||||||
@ -176,6 +186,8 @@ export const getResignationById = async (req: AuthRequest, res: Response, next:
|
|||||||
|
|
||||||
// Approve resignation (move to next stage)
|
// Approve resignation (move to next stage)
|
||||||
export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
export const approveResignation = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const targetOverride = (req as any).targetStage;
|
||||||
|
|
||||||
const transaction: Transaction = await db.sequelize.transaction();
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
if (!req.user) throw new Error('Unauthorized');
|
if (!req.user) throw new Error('Unauthorized');
|
||||||
@ -196,6 +208,15 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Authorization Check (Skip if targetOverride is from pushfnf, handled in updateResignationStatus)
|
||||||
|
if (!targetOverride) {
|
||||||
|
const isAuthorized = await ResignationWorkflowService.canUserAction(resignation, req.user);
|
||||||
|
if (!isAuthorized) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(403).json({ success: false, message: `You are not authorized to approve this request at the ${resignation.currentStage} stage` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stageFlow: Record<string, string> = {
|
const stageFlow: Record<string, string> = {
|
||||||
[RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM,
|
[RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM,
|
||||||
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
|
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
|
||||||
@ -207,10 +228,10 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
|
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.COMPLETED
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStage = stageFlow[resignation.currentStage];
|
const nextStage = targetOverride || stageFlow[resignation.currentStage];
|
||||||
if (!nextStage) {
|
if (!nextStage) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
return res.status(400).json({ success: false, message: 'Cannot move to next stage from current state' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transition via Workflow Service
|
// Transition via Workflow Service
|
||||||
@ -229,15 +250,22 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
|
||||||
if (lwd && today < new Date(lwd)) {
|
const { force } = req.body;
|
||||||
|
|
||||||
|
if (!force && lwd && today < new Date(lwd)) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ success: false, message: `F&F can only be initiated on or after the Last Working Day (${lwd}).` });
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `F&F can only be initiated on or after the Last Working Day (${lwd}).`,
|
||||||
|
canForce: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code);
|
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code);
|
||||||
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
const dealerProfileId = (resignation as any).dealer?.dealerId;
|
||||||
|
|
||||||
const fnf = await db.FnF.create({
|
const fnf = await db.FnF.create({
|
||||||
|
settlementId: NomenclatureService.generateSettlementId(),
|
||||||
resignationId: resignation.id,
|
resignationId: resignation.id,
|
||||||
outletId: resignation.outletId,
|
outletId: resignation.outletId,
|
||||||
dealerId: dealerProfileId, // Correctly using the Dealer model ID
|
dealerId: dealerProfileId, // Correctly using the Dealer model ID
|
||||||
@ -330,10 +358,20 @@ export const withdrawResignation = async (req: AuthRequest, res: Response, next:
|
|||||||
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
return res.status(404).json({ success: false, message: 'Resignation not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const restrictedStages = [RESIGNATION_STAGES.NBH, RESIGNATION_STAGES.DD_ADMIN, RESIGNATION_STAGES.LEGAL, RESIGNATION_STAGES.FNF_INITIATED, RESIGNATION_STAGES.COMPLETED];
|
const restrictedStages = [
|
||||||
if (restrictedStages.includes(resignation.currentStage)) {
|
RESIGNATION_STAGES.NBH,
|
||||||
|
RESIGNATION_STAGES.DD_ADMIN,
|
||||||
|
RESIGNATION_STAGES.LEGAL,
|
||||||
|
RESIGNATION_STAGES.FNF_INITIATED,
|
||||||
|
RESIGNATION_STAGES.COMPLETED
|
||||||
|
];
|
||||||
|
|
||||||
|
if (restrictedStages.includes(resignation.currentStage as any)) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ success: false, message: 'Withdrawal not allowed after NBH evaluation stage.' });
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Withdrawal not allowed after NBH evaluation stage. Current stage: ${resignation.currentStage}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
|
await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, {
|
||||||
@ -493,40 +531,40 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lineItemData = {
|
||||||
|
fnfId: fnf.id,
|
||||||
|
itemType: type || 'Receivable',
|
||||||
|
description: `${department} Clearance: ${remarks || 'Outstanding Dues'}`,
|
||||||
|
department,
|
||||||
|
amount: Math.abs(parseFloat(amount)), // Always positive magnitude
|
||||||
|
addedBy: req.user.id
|
||||||
|
};
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
await existingItem.update({
|
await existingItem.update(lineItemData, { transaction });
|
||||||
itemType: type === 'Payable' ? 'Payable' : 'Receivable',
|
|
||||||
description: `${department} Clearance: ${remarks || 'Outstanding Dues'}`,
|
|
||||||
amount: type === 'Payable' ? -Math.abs(parseFloat(amount)) : Math.abs(parseFloat(amount)),
|
|
||||||
addedBy: req.user.id
|
|
||||||
}, { transaction });
|
|
||||||
} else {
|
} else {
|
||||||
await db.FnFLineItem.create({
|
await db.FnFLineItem.create(lineItemData, { transaction });
|
||||||
fnfId: fnf.id,
|
|
||||||
itemType: type === 'Payable' ? 'Payable' : 'Receivable',
|
|
||||||
description: `${department} Clearance: ${remarks || 'Outstanding Dues'}`,
|
|
||||||
department,
|
|
||||||
amount: type === 'Payable' ? -Math.abs(parseFloat(amount)) : Math.abs(parseFloat(amount)),
|
|
||||||
addedBy: req.user.id
|
|
||||||
}, { transaction });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate totals
|
// Recalculate totals using the standardized formula
|
||||||
const items = await db.FnFLineItem.findAll({ where: { fnfId: fnf.id }, transaction });
|
const items = await db.FnFLineItem.findAll({ where: { fnfId: fnf.id }, transaction });
|
||||||
let totalPayables = 0;
|
let totalPayables = 0;
|
||||||
let totalReceivables = 0;
|
let totalReceivables = 0;
|
||||||
|
let totalDeductions = 0;
|
||||||
|
|
||||||
items.forEach((item: any) => {
|
items.forEach((item: any) => {
|
||||||
const val = parseFloat(item.amount);
|
const val = Math.abs(parseFloat(item.amount) || 0);
|
||||||
if (val < 0) totalPayables += Math.abs(val);
|
if (item.itemType === 'Payable') totalPayables += val;
|
||||||
else totalReceivables += val;
|
else if (item.itemType === 'Receivable') totalReceivables += val;
|
||||||
|
else if (item.itemType === 'Deduction') totalDeductions += val;
|
||||||
});
|
});
|
||||||
|
|
||||||
await fnf.update({
|
await fnf.update({
|
||||||
totalPayables,
|
totalPayables,
|
||||||
totalReceivables,
|
totalReceivables,
|
||||||
netSettlement: totalPayables - totalReceivables
|
totalDeductions,
|
||||||
|
netAmount: totalPayables - totalReceivables - totalDeductions
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -554,9 +592,15 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
case 'sendback':
|
case 'sendback':
|
||||||
return sendBackResignation(req, res, next);
|
return sendBackResignation(req, res, next);
|
||||||
case 'pushfnf':
|
case 'pushfnf':
|
||||||
// For push to F&F, we can trigger the same logic as Legal -> FNF_INITIATED approve
|
// Verify if user role is authorized for manual jump to F&F
|
||||||
// But specifically for the pushfnf button action
|
const authorizedRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN];
|
||||||
|
if (!authorizedRoles.includes(req.user.roleCode as any)) {
|
||||||
|
return res.status(403).json({ success: false, message: 'You do not have permission to push this request to F&F' });
|
||||||
|
}
|
||||||
|
// Jump directly to F&F Initiation
|
||||||
|
(req as any).targetStage = RESIGNATION_STAGES.FNF_INITIATED;
|
||||||
return approveResignation(req, res, next);
|
return approveResignation(req, res, next);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@ -10,10 +10,16 @@ router.post('/', authenticate as any, resignationController.createResignation);
|
|||||||
router.get('/', authenticate as any, resignationController.getResignations);
|
router.get('/', authenticate as any, resignationController.getResignations);
|
||||||
router.get('/:id', authenticate as any, resignationController.getResignationById);
|
router.get('/:id', authenticate as any, resignationController.getResignationById);
|
||||||
router.put('/:id/approve', authenticate as any, resignationController.approveResignation);
|
router.put('/:id/approve', authenticate as any, resignationController.approveResignation);
|
||||||
|
router.post('/:id/approve', authenticate as any, resignationController.approveResignation);
|
||||||
|
|
||||||
router.post('/:id/status', authenticate as any, resignationController.updateResignationStatus);
|
router.post('/:id/status', authenticate as any, resignationController.updateResignationStatus);
|
||||||
router.put('/:id/reject', authenticate as any, resignationController.rejectResignation);
|
router.put('/:id/reject', authenticate as any, resignationController.rejectResignation);
|
||||||
|
router.post('/:id/reject', authenticate as any, resignationController.rejectResignation);
|
||||||
router.put('/:id/withdraw', authenticate as any, resignationController.withdrawResignation);
|
router.put('/:id/withdraw', authenticate as any, resignationController.withdrawResignation);
|
||||||
|
router.post('/:id/withdraw', authenticate as any, resignationController.withdrawResignation);
|
||||||
router.put('/:id/sendback', authenticate as any, resignationController.sendBackResignation);
|
router.put('/:id/sendback', authenticate as any, resignationController.sendBackResignation);
|
||||||
|
router.post('/:id/sendback', authenticate as any, resignationController.sendBackResignation);
|
||||||
|
|
||||||
router.put('/:id/clearance', authenticate as any, uploadSingle, resignationController.updateClearance);
|
router.put('/:id/clearance', authenticate as any, uploadSingle, resignationController.updateClearance);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
import resignationRoutes from './resignation.routes.js';
|
|
||||||
import constitutionalRoutes from './constitutional.routes.js';
|
import constitutionalRoutes from './constitutional.routes.js';
|
||||||
import * as relocationController from './relocation.controller.js';
|
import * as relocationController from './relocation.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
|
|
||||||
// Resignations submodule
|
// Resignations submodule - MOVED TO TOP-LEVEL server.ts for consolidation
|
||||||
router.use('/resignations', resignationRoutes);
|
// router.use('/resignations', resignationRoutes);
|
||||||
|
|
||||||
|
|
||||||
// Constitutional changes submodule - using the new dedicated router
|
// Constitutional changes submodule - using the new dedicated router
|
||||||
router.use('/constitutional', constitutionalRoutes);
|
router.use('/constitutional', constitutionalRoutes);
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance } = db;
|
const { FinancePayment, FnF, Application, Resignation, User, Outlet, FnFLineItem, TerminationRequest, FffClearance, AuditLog } = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
|
import { FNF_STATUS, AUDIT_ACTIONS, FNF_DEPARTMENTS } from '../../common/config/constants.js';
|
||||||
|
|
||||||
|
export const getDepartments = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
res.json({ success: true, departments: FNF_DEPARTMENTS });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: 'Error fetching departments' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getOnboardingPayments = async (req: Request, res: Response) => {
|
export const getOnboardingPayments = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@ -59,16 +68,36 @@ export const updatePayment = async (req: AuthRequest, res: Response) => {
|
|||||||
export const updateFnF = async (req: AuthRequest, res: Response) => {
|
export const updateFnF = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { finalSettlementAmount, status } = req.body;
|
const { finalSettlementAmount, status, settlementDate, paymentMode, transactionReference, remarks } = req.body;
|
||||||
|
|
||||||
const fnf = await FnF.findByPk(id);
|
const fnf = await FnF.findByPk(id);
|
||||||
if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' });
|
if (!fnf) return res.status(404).json({ success: false, message: 'F&F settlement not found' });
|
||||||
|
|
||||||
await fnf.update({
|
await fnf.update({
|
||||||
status: status || fnf.status,
|
status: status || fnf.status,
|
||||||
netAmount: finalSettlementAmount || fnf.netAmount,
|
netAmount: finalSettlementAmount || fnf.netAmount,
|
||||||
|
settlementAmount: finalSettlementAmount || fnf.settlementAmount,
|
||||||
|
settlementDate: settlementDate || fnf.settlementDate,
|
||||||
|
paymentMode: paymentMode || fnf.paymentMode,
|
||||||
|
transactionReference: transactionReference || fnf.transactionReference,
|
||||||
|
remarks: remarks || fnf.remarks,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
});
|
});
|
||||||
res.json({ success: true, message: 'F&F settlement updated successfully' });
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
|
entityType: 'fnf',
|
||||||
|
entityId: id,
|
||||||
|
newData: { status, netAmount: finalSettlementAmount, remarks }
|
||||||
|
});
|
||||||
|
|
||||||
|
// If status is being set to Completed, we might want to trigger additional logic here
|
||||||
|
// like notifying the dealer or updating the resignation status if it's not already
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'F&F settlement updated successfully', data: fnf });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Update F&F error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error updating F&F settlement' });
|
res.status(500).json({ success: false, message: 'Error updating F&F settlement' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -96,7 +125,7 @@ export const getFnFById = async (req: Request, res: Response) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const fnf = await FnF.findByPk(id, {
|
const fnf = await FnF.findByPk(id, {
|
||||||
include: [
|
include: [
|
||||||
{ model: Resignation, as: 'resignation' },
|
{ model: Resignation, as: 'resignation', include: [{ model: db.Outlet, as: 'outlet' }] },
|
||||||
{ model: TerminationRequest, as: 'terminationRequest' },
|
{ model: TerminationRequest, as: 'terminationRequest' },
|
||||||
{
|
{
|
||||||
model: Outlet,
|
model: Outlet,
|
||||||
@ -104,9 +133,24 @@ export const getFnFById = async (req: Request, res: Response) => {
|
|||||||
include: [{
|
include: [{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'dealer',
|
as: 'dealer',
|
||||||
include: [{ model: db.Dealer, as: 'dealerProfile' }]
|
include: [{
|
||||||
|
model: db.Dealer,
|
||||||
|
as: 'dealerProfile',
|
||||||
|
include: [
|
||||||
|
{ model: db.DealerCode, as: 'dealerCode' },
|
||||||
|
{ model: db.DealerBankDetail, as: 'bankDetails' }
|
||||||
|
]
|
||||||
|
}]
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: db.Dealer,
|
||||||
|
as: 'dealer',
|
||||||
|
include: [
|
||||||
|
{ model: db.DealerCode, as: 'dealerCode' },
|
||||||
|
{ model: db.DealerBankDetail, as: 'bankDetails' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ model: FnFLineItem, as: 'lineItems' },
|
{ model: FnFLineItem, as: 'lineItems' },
|
||||||
{ model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] }
|
{ model: FffClearance, as: 'clearances', include: [{ model: User, as: 'clearanceOfficer', attributes: ['fullName'] }] }
|
||||||
]
|
]
|
||||||
@ -125,6 +169,18 @@ export const addLineItem = async (req: AuthRequest, res: Response) => {
|
|||||||
const lineItem = await FnFLineItem.create({
|
const lineItem = await FnFLineItem.create({
|
||||||
fnfId: id, itemType, description, department, amount, addedBy: req.user?.id
|
fnfId: id, itemType, description, department, amount, addedBy: req.user?.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update FnF progress and department statuses
|
||||||
|
await calculateFnFLogic(id);
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
|
entityType: 'fnf',
|
||||||
|
entityId: id,
|
||||||
|
newData: { action: 'ADD_LINE_ITEM', department, amount, description }
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, lineItem });
|
res.json({ success: true, lineItem });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error adding line item' });
|
res.status(500).json({ success: false, message: 'Error adding line item' });
|
||||||
@ -138,6 +194,18 @@ export const updateLineItem = async (req: AuthRequest, res: Response) => {
|
|||||||
const lineItem = await FnFLineItem.findByPk(itemId);
|
const lineItem = await FnFLineItem.findByPk(itemId);
|
||||||
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
||||||
await lineItem.update({ description, department, amount });
|
await lineItem.update({ description, department, amount });
|
||||||
|
|
||||||
|
// Update FnF progress and department statuses
|
||||||
|
await calculateFnFLogic(lineItem.fnfId);
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
|
entityType: 'fnf',
|
||||||
|
entityId: lineItem.fnfId,
|
||||||
|
newData: { action: 'UPDATE_LINE_ITEM', department, amount, description }
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, lineItem });
|
res.json({ success: true, lineItem });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error updating line item' });
|
res.status(500).json({ success: false, message: 'Error updating line item' });
|
||||||
@ -149,27 +217,124 @@ export const deleteLineItem = async (req: AuthRequest, res: Response) => {
|
|||||||
const { itemId } = req.params;
|
const { itemId } = req.params;
|
||||||
const lineItem = await FnFLineItem.findByPk(itemId);
|
const lineItem = await FnFLineItem.findByPk(itemId);
|
||||||
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
if (!lineItem) return res.status(404).json({ success: false, message: 'Line item not found' });
|
||||||
|
|
||||||
|
const fnfId = lineItem.fnfId;
|
||||||
await lineItem.destroy();
|
await lineItem.destroy();
|
||||||
|
|
||||||
|
// Update FnF progress and department statuses
|
||||||
|
await calculateFnFLogic(fnfId);
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
|
entityType: 'fnf',
|
||||||
|
entityId: fnfId,
|
||||||
|
newData: { action: 'DELETE_LINE_ITEM', department: lineItem.department, amount: lineItem.amount }
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: 'Line item deleted' });
|
res.json({ success: true, message: 'Line item deleted' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ success: false, message: 'Error deleting line item' });
|
res.status(500).json({ success: false, message: 'Error deleting line item' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to calculate and update FnF progress
|
||||||
|
const calculateFnFLogic = async (id: string) => {
|
||||||
|
const fnf = await FnF.findByPk(id, {
|
||||||
|
include: [{ model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' }]
|
||||||
|
});
|
||||||
|
if (!fnf) return null;
|
||||||
|
|
||||||
|
const lineItems = (fnf as any).lineItems || [];
|
||||||
|
const clearances = (fnf as any).clearances || [];
|
||||||
|
|
||||||
|
let totalReceivables = 0;
|
||||||
|
let totalPayables = 0;
|
||||||
|
let totalDeductions = 0;
|
||||||
|
|
||||||
|
lineItems.forEach((item: any) => {
|
||||||
|
const amt = Math.abs(parseFloat(item.amount) || 0);
|
||||||
|
if (item.itemType === 'Receivable') totalReceivables += amt;
|
||||||
|
else if (item.itemType === 'Payable') totalPayables += amt;
|
||||||
|
else if (item.itemType === 'Deduction') totalDeductions += amt;
|
||||||
|
});
|
||||||
|
|
||||||
|
const netAmount = totalPayables - totalReceivables - totalDeductions;
|
||||||
|
const allCleared = clearances.length > 0 && clearances.every((c: any) =>
|
||||||
|
['Cleared', 'NOC Submitted', 'Dues Pending', 'N/A'].includes(c.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate progress percentage based on clearances
|
||||||
|
let progressPercentage = 0;
|
||||||
|
if (clearances.length > 0) {
|
||||||
|
const clearedCount = clearances.filter((c: any) =>
|
||||||
|
['Cleared', 'NOC Submitted', 'Dues Pending', 'N/A'].includes(c.status)
|
||||||
|
).length;
|
||||||
|
progressPercentage = Math.round((clearedCount / clearances.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync individual department clearance statuses based on dues
|
||||||
|
for (const clearance of clearances) {
|
||||||
|
// Only update if it's already been processed (not Pending)
|
||||||
|
if (clearance.status === 'Pending') continue;
|
||||||
|
|
||||||
|
const deptDues = lineItems.filter((li: any) => li.department === clearance.department);
|
||||||
|
const totalDeptAmount = deptDues.reduce((sum: number, li: any) => sum + (parseFloat(li.amount) || 0), 0);
|
||||||
|
|
||||||
|
const targetStatus = totalDeptAmount > 0 ? 'Dues Pending' : 'NOC Submitted';
|
||||||
|
if (clearance.status !== targetStatus) {
|
||||||
|
await clearance.update({ status: targetStatus });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine Overall F&F Status
|
||||||
|
let newStatus = fnf.status;
|
||||||
|
if (fnf.status === FNF_STATUS.INITIATED && progressPercentage > 0) {
|
||||||
|
newStatus = FNF_STATUS.DD_CLEARANCE;
|
||||||
|
}
|
||||||
|
if (allCleared && fnf.status !== FNF_STATUS.COMPLETED) {
|
||||||
|
newStatus = FNF_STATUS.FINANCE_APPROVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fnf.update({
|
||||||
|
totalReceivables, totalPayables, totalDeductions, netAmount,
|
||||||
|
status: newStatus,
|
||||||
|
progressPercentage
|
||||||
|
});
|
||||||
|
|
||||||
|
return fnf;
|
||||||
|
};
|
||||||
|
|
||||||
export const updateClearance = async (req: AuthRequest, res: Response) => {
|
export const updateClearance = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id, clearanceId } = req.params;
|
const { id, clearanceId } = req.params;
|
||||||
const { status, remarks, documentId } = req.body;
|
const { status, remarks, documentId, supportingDocument } = req.body;
|
||||||
const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } });
|
const clearance = await FffClearance.findOne({ where: { id: clearanceId, fnfId: id } });
|
||||||
if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' });
|
if (!clearance) return res.status(404).json({ success: false, message: 'Clearance record not found' });
|
||||||
|
|
||||||
await clearance.update({
|
await clearance.update({
|
||||||
status, remarks, documentId,
|
status: status || clearance.status,
|
||||||
|
remarks: remarks || clearance.remarks,
|
||||||
|
documentId: documentId || clearance.documentId,
|
||||||
|
supportingDocument: supportingDocument || clearance.supportingDocument,
|
||||||
clearedBy: req.user?.id,
|
clearedBy: req.user?.id,
|
||||||
clearedAt: status === 'Cleared' ? new Date() : null
|
clearedAt: status === 'Cleared' ? new Date() : clearance.clearedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Automatically update FnF progress
|
||||||
|
await calculateFnFLogic(id);
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
userId: req.user?.id || null,
|
||||||
|
action: AUDIT_ACTIONS.FNF_UPDATED,
|
||||||
|
entityType: 'fnf',
|
||||||
|
entityId: id,
|
||||||
|
newData: { action: 'UPDATE_CLEARANCE', department: clearance.department, status, remarks }
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: 'Clearance updated successfully', clearance });
|
res.json({ success: true, message: 'Clearance updated successfully', clearance });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Update clearance error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error updating clearance' });
|
res.status(500).json({ success: false, message: 'Error updating clearance' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -177,43 +342,12 @@ export const updateClearance = async (req: AuthRequest, res: Response) => {
|
|||||||
export const calculateFnF = async (req: AuthRequest, res: Response) => {
|
export const calculateFnF = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const fnf = await FnF.findByPk(id, {
|
const fnf = await calculateFnFLogic(id);
|
||||||
include: [{ model: FnFLineItem, as: 'lineItems' }, { model: FffClearance, as: 'clearances' }]
|
|
||||||
});
|
|
||||||
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
|
if (!fnf) return res.status(404).json({ success: false, message: 'F&F not found' });
|
||||||
|
|
||||||
const lineItems = (fnf as any).lineItems || [];
|
res.json({ success: true, fnf });
|
||||||
const clearances = (fnf as any).clearances || [];
|
|
||||||
|
|
||||||
let totalReceivables = 0;
|
|
||||||
let totalPayables = 0;
|
|
||||||
let totalDeductions = 0;
|
|
||||||
|
|
||||||
lineItems.forEach((item: any) => {
|
|
||||||
const amt = parseFloat(item.amount) || 0;
|
|
||||||
if (item.itemType === 'Receivable') totalReceivables += amt;
|
|
||||||
else if (item.itemType === 'Payable') totalPayables += amt;
|
|
||||||
else if (item.itemType === 'Deduction') totalDeductions += amt;
|
|
||||||
});
|
|
||||||
|
|
||||||
const netAmount = totalPayables - totalReceivables - totalDeductions;
|
|
||||||
const allCleared = clearances.length > 0 && clearances.every((c: any) => c.status === 'Cleared' || c.status === 'N/A');
|
|
||||||
|
|
||||||
// Calculate progress percentage based on clearances
|
|
||||||
let progressPercentage = 0;
|
|
||||||
if (clearances.length > 0) {
|
|
||||||
const clearedCount = clearances.filter((c: any) => c.status === 'Cleared' || c.status === 'N/A').length;
|
|
||||||
progressPercentage = Math.round((clearedCount / clearances.length) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fnf.update({
|
|
||||||
totalReceivables, totalPayables, netAmount,
|
|
||||||
status: allCleared ? 'Cleared' : 'Calculated',
|
|
||||||
progressPercentage
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true, fnf, allCleared, progressPercentage });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Calculate F&F error:', error);
|
||||||
res.status(500).json({ success: false, message: 'Error calculating F&F' });
|
res.status(500).json({ success: false, message: 'Error calculating F&F' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import { ROLES } from '../../common/config/constants.js';
|
|||||||
// All routes require authentication
|
// All routes require authentication
|
||||||
router.use(authenticate as any);
|
router.use(authenticate as any);
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
router.get('/departments', settlementController.getDepartments);
|
||||||
|
|
||||||
// Finance user only routes
|
// Finance user only routes
|
||||||
router.get('/onboarding', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getOnboardingPayments);
|
router.get('/onboarding', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getOnboardingPayments);
|
||||||
router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getFnFSettlements);
|
router.get('/fnf', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.getFnFSettlements);
|
||||||
|
|||||||
@ -177,7 +177,8 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
[TERMINATION_STAGES.RBM_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
[TERMINATION_STAGES.RBM_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||||
[TERMINATION_STAGES.ZBH_REVIEW]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
[TERMINATION_STAGES.ZBH_REVIEW]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
||||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.NBH_EVALUATION,
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_HEAD_REVIEW,
|
||||||
|
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.NBH_EVALUATION,
|
||||||
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.SCN_ISSUED,
|
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.SCN_ISSUED,
|
||||||
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.PERSONAL_HEARING,
|
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.PERSONAL_HEARING,
|
||||||
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.NBH_FINAL_APPROVAL,
|
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.NBH_FINAL_APPROVAL,
|
||||||
@ -188,6 +189,7 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextStage = stageFlow[termination.currentStage];
|
const nextStage = stageFlow[termination.currentStage];
|
||||||
|
logger.info(`[TerminationController] transitioning from ${termination.currentStage} to ${nextStage}`);
|
||||||
if (!nextStage) {
|
if (!nextStage) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
||||||
@ -198,38 +200,23 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
|
|||||||
status: nextStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : `${nextStage}`
|
status: nextStage === TERMINATION_STAGES.TERMINATED ? 'Terminated' : `${nextStage}`
|
||||||
});
|
});
|
||||||
|
|
||||||
// If Terminated, create F&F record and clearances
|
// If Terminated, trigger F&F initiation via Workflow Service
|
||||||
|
// SRS REQUIREMENT: F&F settlement process is triggered only on the Last Working Day (LWD)
|
||||||
if (nextStage === TERMINATION_STAGES.TERMINATED) {
|
if (nextStage === TERMINATION_STAGES.TERMINATED) {
|
||||||
const dealer = await db.Dealer.findByPk(termination.dealerId);
|
const today = new Date();
|
||||||
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap(dealer?.dealerCode || 'MOCK-001');
|
const lwd = new Date(termination.proposedLwd);
|
||||||
|
|
||||||
|
// Clear time components for date-only comparison
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
lwd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const fnf = await db.FnF.create({
|
if (today >= lwd) {
|
||||||
terminationRequestId: termination.id,
|
logger.info(`[TerminationController] LWD reached or passed (${termination.proposedLwd}). Initiating F&F.`);
|
||||||
dealerId: termination.dealerId,
|
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction);
|
||||||
status: 'Initiated',
|
} else {
|
||||||
totalReceivables: sapDues.data.outstandingInvoices,
|
logger.info(`[TerminationController] Termination approved but LWD (${termination.proposedLwd}) not yet reached. F&F will be triggered on LWD.`);
|
||||||
totalPayables: sapDues.data.securityDeposit,
|
// Update status to reflect F&F is pending LWD arrivement
|
||||||
netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices
|
await termination.update({ status: 'Approved (F&F Pending LWD)' }, { transaction });
|
||||||
}, { transaction });
|
|
||||||
|
|
||||||
await db.FnFLineItem.bulkCreate([
|
|
||||||
{ fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: req.user.id },
|
|
||||||
{ fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.id }
|
|
||||||
], { transaction });
|
|
||||||
|
|
||||||
const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js');
|
|
||||||
await db.FffClearance.bulkCreate(
|
|
||||||
FNF_DEPARTMENTS.map(dept => ({
|
|
||||||
fnfId: fnf.id,
|
|
||||||
department: dept,
|
|
||||||
status: 'Pending'
|
|
||||||
})),
|
|
||||||
{ transaction }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dealer) {
|
|
||||||
ExternalMocksService.mockSyncDealerStatusToSap(dealer.dealerCode, 'Inactive')
|
|
||||||
.catch(err => logger.error('Error syncing termination to SAP:', err));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,3 +273,53 @@ export const recordPersonalHearing = async (req: AuthRequest, res: Response, nex
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Record Clearance from Departments (16-Department F&F)
|
||||||
|
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const transaction: Transaction = await db.sequelize.transaction();
|
||||||
|
try {
|
||||||
|
if (!req.user) throw new Error('Unauthorized');
|
||||||
|
const { id } = req.params;
|
||||||
|
const { department, status, amount, type, remarks } = req.body;
|
||||||
|
|
||||||
|
const termination = await db.TerminationRequest.findByPk(id);
|
||||||
|
if (!termination) throw new Error('Termination request not found');
|
||||||
|
|
||||||
|
const clearances = { ...(termination.departmentalClearances || {}) };
|
||||||
|
clearances[department] = {
|
||||||
|
status,
|
||||||
|
amount: Number(amount) || 0,
|
||||||
|
type: type || 'Receivable',
|
||||||
|
remarks: remarks || '',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy: req.user.fullName
|
||||||
|
};
|
||||||
|
|
||||||
|
await termination.update({ departmentalClearances: clearances }, { transaction });
|
||||||
|
|
||||||
|
// Update individual clearance record for unified dashboard
|
||||||
|
const fnf = await db.FnF.findOne({ where: { terminationRequestId: id } });
|
||||||
|
if (fnf) {
|
||||||
|
await db.FffClearance.update(
|
||||||
|
{ status, remarks, amount: Number(amount) || 0 },
|
||||||
|
{ where: { fnfId: fnf.id, department }, transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.AuditLog.create({
|
||||||
|
userId: req.user.id,
|
||||||
|
action: AUDIT_ACTIONS.UPDATED,
|
||||||
|
entityType: 'termination',
|
||||||
|
entityId: id,
|
||||||
|
newData: { department, status, amount }
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
res.json({ success: true, message: `Clearance updated for ${department}`, clearances });
|
||||||
|
} catch (error) {
|
||||||
|
if (transaction) await transaction.rollback();
|
||||||
|
logger.error('Error updating termination clearance:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import express from 'express';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
import {
|
import {
|
||||||
createTermination, getTerminations, getTerminationById, updateTerminationStatus,
|
createTermination, getTerminations, getTerminationById, updateTerminationStatus,
|
||||||
submitScnResponse, recordPersonalHearing
|
submitScnResponse, recordPersonalHearing, updateClearance
|
||||||
} from './termination.controller.js';
|
} from './termination.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
|
|
||||||
@ -15,5 +15,6 @@ router.put('/:id/status', updateTerminationStatus);
|
|||||||
router.post('/:id/status', updateTerminationStatus);
|
router.post('/:id/status', updateTerminationStatus);
|
||||||
router.post('/scn-response', submitScnResponse);
|
router.post('/scn-response', submitScnResponse);
|
||||||
router.post('/hearing-record', recordPersonalHearing);
|
router.post('/hearing-record', recordPersonalHearing);
|
||||||
|
router.put('/:id/clearance', updateClearance);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -16,11 +16,11 @@ const seedTemplates = async () => {
|
|||||||
description: 'Opportunity link for dealership application assessment',
|
description: 'Opportunity link for dealership application assessment',
|
||||||
subject: 'Action Required: Royal Enfield Dealership Opportunity',
|
subject: 'Action Required: Royal Enfield Dealership Opportunity',
|
||||||
fileName: 'opportunity.html',
|
fileName: 'opportunity.html',
|
||||||
placeholders: ['applicantName', 'location', 'applicationId', 'link', 'year']
|
placeholders: ['applicantName', 'location', 'link', 'year']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
templateCode: 'non_opportunity',
|
templateCode: 'non_opportunity',
|
||||||
description: 'Rejection/Hold email for non-opportunity applications',
|
description: 'Regret email for non-opportunity applications',
|
||||||
subject: 'Update on your Royal Enfield Dealership Application',
|
subject: 'Update on your Royal Enfield Dealership Application',
|
||||||
fileName: 'non_opportunity.html',
|
fileName: 'non_opportunity.html',
|
||||||
placeholders: ['applicantName', 'location', 'year']
|
placeholders: ['applicantName', 'location', 'year']
|
||||||
@ -52,21 +52,123 @@ const seedTemplates = async () => {
|
|||||||
subject: 'Congratulations! You are Shortlisted: {{applicationId}}',
|
subject: 'Congratulations! You are Shortlisted: {{applicationId}}',
|
||||||
fileName: 'applicant_shortlisted.html',
|
fileName: 'applicant_shortlisted.html',
|
||||||
placeholders: ['applicantName', 'location', 'applicationId', 'portalLink', 'year']
|
placeholders: ['applicantName', 'location', 'applicationId', 'portalLink', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'LOI_ISSUED',
|
||||||
|
description: 'Notification when Letter of Intent is issued',
|
||||||
|
subject: 'Letter of Intent (LOI) Issued: {{applicationId}}',
|
||||||
|
fileName: 'loi_issued.html',
|
||||||
|
placeholders: ['applicantName', 'applicationId', 'portalLink', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'LOA_ISSUED',
|
||||||
|
description: 'Notification when Letter of Appointment is issued',
|
||||||
|
subject: 'Letter of Appointment (LOA) Issued: {{applicationId}}',
|
||||||
|
fileName: 'loa_issued.html',
|
||||||
|
placeholders: ['applicantName', 'applicationId', 'dealerCode', 'portalLink', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'DEALER_CODE_READY',
|
||||||
|
description: 'Notification when SAP Dealer Codes are generated',
|
||||||
|
subject: 'SAP Dealer Codes Readiness for {{applicationId}}',
|
||||||
|
fileName: 'dealer_code_ready.html',
|
||||||
|
placeholders: ['applicantName', 'applicationId', 'salesCode', 'serviceCode', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'RESIGNATION_SUBMITTED',
|
||||||
|
description: 'Notification for new Resignation submission',
|
||||||
|
subject: 'New Resignation Request: {{resignationId}}',
|
||||||
|
fileName: 'resignation_submitted.html',
|
||||||
|
placeholders: ['dealerName', 'resignationId', 'lwd', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'RESIGNATION_APPROVED',
|
||||||
|
description: 'Notification when Resignation is approved',
|
||||||
|
subject: 'Resignation Request Approved: {{resignationId}}',
|
||||||
|
fileName: 'resignation_approved.html',
|
||||||
|
placeholders: ['dealerName', 'resignationId', 'lwd', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'TERMINATION_SCN_ISSUED',
|
||||||
|
description: 'Notification for Show Cause Notice issuance',
|
||||||
|
subject: 'URGENT: Show Cause Notice Issued: {{terminationId}}',
|
||||||
|
fileName: 'termination_scn.html',
|
||||||
|
placeholders: ['dealerName', 'terminationId', 'deadline', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'WORKNOTE_NOTIFICATION',
|
||||||
|
description: 'Notification for new work note or update',
|
||||||
|
subject: 'New Update on Application: {{applicationId}}',
|
||||||
|
fileName: 'worknote_notification.html',
|
||||||
|
placeholders: ['userName', 'applicationId', 'dealerName', 'message', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'GENERIC_NOTIFICATION',
|
||||||
|
description: 'Standard multi-purpose notification',
|
||||||
|
subject: '{{title}}',
|
||||||
|
fileName: 'generic_notification.html',
|
||||||
|
placeholders: ['title', 'message', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||||
|
description: 'Periodic reminder to complete assessment',
|
||||||
|
subject: 'Reminder: Complete your Dealer Assessment',
|
||||||
|
fileName: 'questionnaire_reminder.html',
|
||||||
|
placeholders: ['applicantName', 'location', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
||||||
|
description: 'Notification for new Constitutional Change request',
|
||||||
|
subject: 'New Constitutional Change Request: {{requestId}}',
|
||||||
|
fileName: 'constitutional_change_submitted.html',
|
||||||
|
placeholders: ['dealerName', 'changeType', 'requestId', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'RESIGNATION_UPDATE',
|
||||||
|
description: 'General status update for Resignation',
|
||||||
|
subject: 'Resignation Status Update: {{status}}',
|
||||||
|
fileName: 'resignation_update.html',
|
||||||
|
placeholders: ['dealerName', 'status', 'remarks', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'TERMINATION_UPDATE',
|
||||||
|
description: 'General status update for Termination',
|
||||||
|
subject: 'Termination Status Update: {{status}}',
|
||||||
|
fileName: 'termination_update.html',
|
||||||
|
placeholders: ['dealerName', 'status', 'remarks', 'link', 'year']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE',
|
||||||
|
description: 'General status update for Constitutional Change',
|
||||||
|
subject: 'Constitutional Change Update: {{status}}',
|
||||||
|
fileName: 'constitutional_change_update.html',
|
||||||
|
placeholders: ['dealerName', 'status', 'remarks', 'link', 'year']
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const t of templates) {
|
for (const t of templates) {
|
||||||
const body = fs.readFileSync(path.join(templatesDir, t.fileName), 'utf-8');
|
let body = '';
|
||||||
|
try {
|
||||||
|
const filePath = path.join(templatesDir, t.fileName);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
body = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
} else {
|
||||||
|
console.warn(`File not found: ${t.fileName}. Creating a placeholder body.`);
|
||||||
|
body = `<html><body><h1>${t.description}</h1><p>This is a placeholder for ${t.templateCode}</p></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
await db.EmailTemplate.upsert({
|
await db.EmailTemplate.upsert({
|
||||||
templateCode: t.templateCode,
|
templateCode: t.templateCode,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
body: body,
|
body: body,
|
||||||
placeholders: t.placeholders,
|
placeholders: t.placeholders,
|
||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
console.log(`Seeded/Updated template: ${t.templateCode}`);
|
console.log(`Seeded/Updated template: ${t.templateCode}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to seed ${t.templateCode}:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Email template seeding completed.');
|
console.log('Email template seeding completed.');
|
||||||
|
|||||||
@ -110,11 +110,9 @@ app.get('/health', (req: Request, res: Response) => {
|
|||||||
// API Routes (Modular)
|
// API Routes (Modular)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/onboarding', onboardingRoutes);
|
app.use('/api/onboarding', onboardingRoutes);
|
||||||
app.use('/api/self-service', selfServiceRoutes);
|
|
||||||
app.use('/api/master', masterRoutes);
|
app.use('/api/master', masterRoutes);
|
||||||
app.use('/api/settlement', settlementRoutes);
|
app.use('/api/settlement', settlementRoutes);
|
||||||
app.use('/api/collaboration', collaborationRoutes);
|
app.use('/api/collaboration', collaborationRoutes);
|
||||||
// New Routes
|
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/opportunity', opportunityRoutes);
|
app.use('/api/opportunity', opportunityRoutes);
|
||||||
app.use('/api/assessment', assessmentRoutes);
|
app.use('/api/assessment', assessmentRoutes);
|
||||||
@ -130,20 +128,26 @@ app.use('/api/questionnaire', questionnaireRoutes);
|
|||||||
app.use('/api/prospective-login', prospectiveLoginRoutes);
|
app.use('/api/prospective-login', prospectiveLoginRoutes);
|
||||||
app.use('/api/termination', terminationRoutes);
|
app.use('/api/termination', terminationRoutes);
|
||||||
|
|
||||||
|
// Self-Service Modules (Explicitly mounted for consolidation)
|
||||||
|
app.use('/api/self-service/resignations', resignationRoutes);
|
||||||
|
app.use('/api/self-service/relocation', relocationRoutes);
|
||||||
|
app.use('/api/self-service/constitutional', constitutionalRoutes);
|
||||||
|
app.use('/api/self-service', selfServiceRoutes);
|
||||||
|
|
||||||
// Backward Compatibility & Frontend Mapping Aliases
|
// Backward Compatibility & Frontend Mapping Aliases
|
||||||
app.use('/api/applications', onboardingRoutes);
|
app.use('/api/applications', onboardingRoutes);
|
||||||
app.use('/api/resignation', resignationRoutes);
|
|
||||||
app.use('/api/resignations', resignationRoutes);
|
|
||||||
app.use('/api/constitutional-change', constitutionalRoutes);
|
app.use('/api/constitutional-change', constitutionalRoutes);
|
||||||
|
app.use('/api/resignations', resignationRoutes);
|
||||||
// Relocation routes - direct mount
|
app.use('/api/resignation', resignationRoutes);
|
||||||
app.use('/api/relocation', relocationRoutes);
|
|
||||||
app.use('/api/relocations', relocationRoutes);
|
app.use('/api/relocations', relocationRoutes);
|
||||||
|
app.use('/api/relocation', relocationRoutes);
|
||||||
app.use('/api/outlets', outletRoutes);
|
app.use('/api/outlets', outletRoutes);
|
||||||
app.use('/api/dealers', dealerRoutes);
|
app.use('/api/dealers', dealerRoutes);
|
||||||
app.use('/api/finance', settlementRoutes);
|
app.use('/api/finance', settlementRoutes);
|
||||||
app.use('/api/worknotes', collaborationRoutes);
|
app.use('/api/worknotes', collaborationRoutes);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((req: Request, res: Response) => {
|
app.use((req: Request, res: Response) => {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
@ -164,13 +168,25 @@ const startServer = async () => {
|
|||||||
await db.sequelize.authenticate();
|
await db.sequelize.authenticate();
|
||||||
logger.info('Database connection established successfully');
|
logger.info('Database connection established successfully');
|
||||||
|
|
||||||
/*
|
|
||||||
// Sync database (in development only)
|
// Sync database (in development only)
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
await db.sequelize.sync();
|
// Temporarily disabling sync to resolve constraint errors during E2E testing
|
||||||
logger.info('Database models synchronized');
|
// await db.sequelize.sync();
|
||||||
|
logger.info('Database synchronization skipped (Manual Schema Management)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start BullMQ Workers
|
||||||
|
if (process.env.ENABLE_REDIS === 'true') {
|
||||||
|
const { notificationWorker } = await import('./common/queues/notification.worker.js');
|
||||||
|
const { slaWorker } = await import('./common/queues/sla.worker.js');
|
||||||
|
const { scheduleSLACheck } = await import('./common/queues/sla.queue.js');
|
||||||
|
|
||||||
|
await scheduleSLACheck(); // Register repeatable job
|
||||||
|
|
||||||
|
logger.info('BullMQ Workers initialized and repeatable jobs scheduled');
|
||||||
|
} else {
|
||||||
|
logger.info('⚙️ BullMQ (Redis) is disabled. Background jobs will not run.');
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
httpServer.listen(PORT, () => {
|
httpServer.listen(PORT, () => {
|
||||||
@ -178,6 +194,7 @@ const startServer = async () => {
|
|||||||
logger.info(`📍 Environment: ${process.env.NODE_ENV}`);
|
logger.info(`📍 Environment: ${process.env.NODE_ENV}`);
|
||||||
logger.info(`🔗 API Base URL: http://localhost:${PORT}/api`);
|
logger.info(`🔗 API Base URL: http://localhost:${PORT}/api`);
|
||||||
logger.info(`🔌 Socket.io initialized`);
|
logger.info(`🔌 Socket.io initialized`);
|
||||||
|
logger.info(`⚙️ BullMQ: ${process.env.ENABLE_REDIS === 'true' ? 'Active (Notifications/SLA)' : 'Disabled'}`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Unable to start server:', error);
|
logger.error('Unable to start server:', error);
|
||||||
|
|||||||
122
src/services/NotificationService.ts
Normal file
122
src/services/NotificationService.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { sendEmail } from '../common/utils/email.service.js';
|
||||||
|
import db from '../database/models/index.js';
|
||||||
|
const { Notification, PushSubscription } = db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Notification Service to handle Email, WhatsApp, and Push Notifications.
|
||||||
|
* This satisfies the SRS v2.0 requirement for centralized communication logic.
|
||||||
|
*/
|
||||||
|
export class NotificationService {
|
||||||
|
/**
|
||||||
|
* Sends a unified notification across multiple channels
|
||||||
|
*/
|
||||||
|
static async notify(userId: string | null, email: string | null, data: {
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
channels: ('email' | 'whatsapp' | 'push' | 'system')[],
|
||||||
|
templateCode?: string,
|
||||||
|
placeholders?: any,
|
||||||
|
metadata?: any
|
||||||
|
}) {
|
||||||
|
const { title, message, channels, templateCode, placeholders, metadata } = data;
|
||||||
|
|
||||||
|
// 1. System Notification (In-app) - Always synchronous for immediate feedback
|
||||||
|
if (channels.includes('system') && userId) {
|
||||||
|
await Notification.create({
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type: metadata?.type || 'info',
|
||||||
|
isRead: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Offload other channels to Job Queue (BullMQ)
|
||||||
|
const asyncChannels = channels.filter(c => c !== 'system');
|
||||||
|
if (asyncChannels.length > 0) {
|
||||||
|
if (process.env.ENABLE_REDIS === 'true') {
|
||||||
|
const { addNotificationJob } = await import('../common/queues/notification.queue.js');
|
||||||
|
await addNotificationJob({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
channels: asyncChannels,
|
||||||
|
templateCode,
|
||||||
|
placeholders,
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`[Notification Service] Redis disabled. Skipping async channels: ${asyncChannels.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor for BullMQ jobs
|
||||||
|
*/
|
||||||
|
static async processJob(jobData: any) {
|
||||||
|
const { userId, email, title, message, channels, templateCode, placeholders, metadata } = jobData;
|
||||||
|
|
||||||
|
for (const channel of channels) {
|
||||||
|
try {
|
||||||
|
if (channel === 'email' && email) {
|
||||||
|
await sendEmail(email, title, templateCode || 'generic_notification', {
|
||||||
|
...placeholders,
|
||||||
|
title,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
} else if (channel === 'whatsapp') {
|
||||||
|
const phoneNumber = placeholders?.phone || placeholders?.mobileNumber || 'Unknown';
|
||||||
|
await this.sendWhatsApp(phoneNumber, templateCode || 'generic_msg', placeholders);
|
||||||
|
} else if (channel === 'push' && userId) {
|
||||||
|
await this.sendPush(userId, title, message, metadata);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Notification Service] Failed to process ${channel} for ${userId || email}:`, error);
|
||||||
|
throw error; // Re-throw to trigger BullMQ retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock WhatsApp integration as requested in SRS
|
||||||
|
*/
|
||||||
|
static async sendWhatsApp(to: string, templateCode: string, placeholders: any) {
|
||||||
|
// Log to console for audit during dev/mock phase
|
||||||
|
console.log(`[WhatsApp Service] Triggered for ${to} using template ${templateCode}`);
|
||||||
|
console.log(`[WhatsApp Service] Payload:`, placeholders);
|
||||||
|
|
||||||
|
// In reality, this would call Meta's WhatsApp Business API or Twilio
|
||||||
|
// return await WhatsAppAPI.send(to, templateCode, placeholders);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web Push Notification logic
|
||||||
|
*/
|
||||||
|
private static async sendPush(userId: string, title: string, message: string, metadata: any) {
|
||||||
|
try {
|
||||||
|
const subscriptions = await PushSubscription.findAll({ where: { userId } });
|
||||||
|
if (subscriptions.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`[Push Service] Sending ${subscriptions.length} push notifications to User: ${userId}`);
|
||||||
|
// Integration with web-push library would go here
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Push Service] Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specific Trigger: Questionnaire Reminder
|
||||||
|
*/
|
||||||
|
static async sendQuestionnaireReminder(email: string, phone: string, applicantName: string) {
|
||||||
|
await this.notify(null, email, {
|
||||||
|
title: 'Action Required: Complete your Dealership Questionnaire',
|
||||||
|
message: `Hi ${applicantName}, please complete the questionnaire to proceed with your application.`,
|
||||||
|
channels: ['email', 'whatsapp'],
|
||||||
|
templateCode: 'QUESTIONNAIRE_REMINDER',
|
||||||
|
placeholders: { applicantName, phone }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -101,7 +101,7 @@ export class ParticipantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. National roles - Crucial for Termination Review
|
// 2. National roles - Crucial for Termination Review
|
||||||
const nationalRoles = [ROLES.DD_LEAD, ROLES.NBH, ROLES.CCO, ROLES.CEO, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
|
const nationalRoles = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.CCO, ROLES.CEO, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
|
||||||
const nationalUsers = await User.findAll({
|
const nationalUsers = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
roleCode: { [Op.in]: nationalRoles },
|
roleCode: { [Op.in]: nationalRoles },
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
const { AuditLog, User, Worknote } = db;
|
const { AuditLog, User, Worknote } = db;
|
||||||
import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js';
|
import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js';
|
||||||
|
import { NotificationService } from './NotificationService.js';
|
||||||
|
import logger from '../common/utils/logger.js';
|
||||||
|
|
||||||
|
|
||||||
export class ResignationWorkflowService {
|
export class ResignationWorkflowService {
|
||||||
/**
|
/**
|
||||||
@ -57,6 +60,39 @@ export class ResignationWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`);
|
console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`);
|
||||||
|
|
||||||
|
// 5. Send Notifications
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
dealerId: resignation.dealerId,
|
||||||
|
roleCode: ROLES.DEALER
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await NotificationService.notify(user.id, user.email, {
|
||||||
|
title: `Resignation Update: ${targetStage}`,
|
||||||
|
message: `Your resignation request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`,
|
||||||
|
channels: ['email', 'whatsapp', 'system'],
|
||||||
|
templateCode: 'RESIGNATION_UPDATE',
|
||||||
|
placeholders: {
|
||||||
|
status: targetStage,
|
||||||
|
dealerName: user.fullName || 'Dealer',
|
||||||
|
remarks: remarks || 'N/A'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Deactivate User Account on final completion (SRS 1.1.5 / 2.3.5)
|
||||||
|
if (targetStage === RESIGNATION_STAGES.COMPLETED || targetStage === RESIGNATION_STAGES.LEGAL) {
|
||||||
|
logger.info(`[ResignationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`);
|
||||||
|
await user.update({
|
||||||
|
status: 'deactivated',
|
||||||
|
isActive: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`[ResignationWorkflowService] No user account found with dealerId ${resignation.dealerId} and role ${ROLES.DEALER}`);
|
||||||
|
}
|
||||||
|
|
||||||
return resignation;
|
return resignation;
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/services/SLAService.ts
Normal file
78
src/services/SLAService.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import db from '../database/models/index.js';
|
||||||
|
const { SLATracking, SLAConfiguration, SLABreach, Application, User } = db;
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import { NotificationService } from './NotificationService.js';
|
||||||
|
|
||||||
|
export class SLAService {
|
||||||
|
/**
|
||||||
|
* Periodically check for SLA breaches across all active tracking records
|
||||||
|
*/
|
||||||
|
static async checkBreaches() {
|
||||||
|
console.log('[SLA Service] Starting breach check...');
|
||||||
|
|
||||||
|
// Find all active tracking records that are still 'In Progress' (not concluded)
|
||||||
|
const activeTracking = await SLATracking.findAll({
|
||||||
|
where: { isActive: true, isBreached: false, endTime: null },
|
||||||
|
include: [
|
||||||
|
{ model: Application, as: 'application' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const track of activeTracking) {
|
||||||
|
// Fetch SLA configuration for this specific stage and entity type
|
||||||
|
const config = await SLAConfiguration.findOne({
|
||||||
|
where: {
|
||||||
|
stageName: track.stageName,
|
||||||
|
entityType: track.entityType,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) continue;
|
||||||
|
|
||||||
|
const startTime = new Date(track.startTime);
|
||||||
|
const slaHours = config.slaHours;
|
||||||
|
const deadline = new Date(startTime.getTime() + (slaHours * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
if (now > deadline) {
|
||||||
|
// BREACH DETECTED
|
||||||
|
console.log(`[SLA Service] Breach detected for ${track.entityType}: ${track.application?.applicationId || track.entityId}, Stage: ${track.stageName}`);
|
||||||
|
|
||||||
|
// Update tracking record
|
||||||
|
await track.update({ isBreached: true });
|
||||||
|
|
||||||
|
// Create Breach Record
|
||||||
|
await SLABreach.create({
|
||||||
|
trackingId: track.id,
|
||||||
|
applicationId: track.applicationId,
|
||||||
|
stageCode: track.stageName,
|
||||||
|
breachedAt: now,
|
||||||
|
severity: 'High',
|
||||||
|
status: 'Open'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify Stakeholders (Escalation)
|
||||||
|
await this.notifyEscalated(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[SLA Service] Breach check completed at ${now.toISOString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async notifyEscalated(track: any) {
|
||||||
|
// Find the assigned user to notify them and potentially their manager
|
||||||
|
const application = await Application.findByPk(track.applicationId, {
|
||||||
|
include: [{ model: User, as: 'assignedToUser' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (application?.assignedToUser) {
|
||||||
|
await NotificationService.notify(application.assignedTo, application.assignedToUser.email, {
|
||||||
|
title: `SLA BREACH: ${track.stageName}`,
|
||||||
|
message: `The application ${application.applicationId} has breached the SLA for ${track.stageName}. Current stage: ${application.currentStage}.`,
|
||||||
|
channels: ['email', 'system'],
|
||||||
|
metadata: { type: 'error', applicationId: application.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
const { AuditLog, User, TerminationScnResponse, TerminationHearingRecord } = db;
|
const { AuditLog, User, TerminationScnResponse, TerminationHearingRecord, Dealer, FnF, FnFLineItem, FffClearance } = db;
|
||||||
import { AUDIT_ACTIONS, TERMINATION_STAGES, ROLES } from '../common/config/constants.js';
|
import { AUDIT_ACTIONS, TERMINATION_STAGES, ROLES, FNF_DEPARTMENTS } from '../common/config/constants.js';
|
||||||
|
import { NotificationService } from './NotificationService.js';
|
||||||
|
import ExternalMocksService from '../common/utils/externalMocks.service.js';
|
||||||
|
import logger from '../common/utils/logger.js';
|
||||||
|
import { NomenclatureService } from '../common/utils/nomenclature.js';
|
||||||
|
|
||||||
export class TerminationWorkflowService {
|
export class TerminationWorkflowService {
|
||||||
/**
|
/**
|
||||||
@ -44,11 +48,103 @@ export class TerminationWorkflowService {
|
|||||||
newData: { status: updateData.status, stage: targetStage, remarks }
|
newData: { status: updateData.status, stage: targetStage, remarks }
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[TerminationWorkflowService] Transitioned Termination ${termination.id} to ${targetStage}`);
|
// 4. Send Notifications
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
dealerId: termination.dealerId,
|
||||||
|
roleCode: ROLES.DEALER
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await NotificationService.notify(user.id, user.email, {
|
||||||
|
title: `Termination Status Update: ${targetStage}`,
|
||||||
|
message: `Your dealership termination request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`,
|
||||||
|
channels: ['email', 'whatsapp', 'system'],
|
||||||
|
templateCode: 'TERMINATION_UPDATE',
|
||||||
|
placeholders: {
|
||||||
|
status: targetStage,
|
||||||
|
dealerName: user.fullName || 'Dealer',
|
||||||
|
remarks: remarks || 'N/A'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5)
|
||||||
|
// We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued
|
||||||
|
if (targetStage === TERMINATION_STAGES.TERMINATED || targetStage === TERMINATION_STAGES.LEGAL_LETTER) {
|
||||||
|
logger.info(`[TerminationWorkflowService] Revoking portal access for user ${user.email} (Stage: ${targetStage})`);
|
||||||
|
await user.update({
|
||||||
|
status: 'deactivated',
|
||||||
|
isActive: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`[TerminationWorkflowService] No user account found with dealerId ${termination.dealerId} and role ${ROLES.DEALER}`);
|
||||||
|
}
|
||||||
|
|
||||||
return termination;
|
return termination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates Full & Final Settlement for a terminated dealer
|
||||||
|
*/
|
||||||
|
static async initiateFnF(termination: any, userId: string, transaction: any) {
|
||||||
|
// 1. Get Dealer User with associated Outlets
|
||||||
|
const dealerUser = await User.findOne({
|
||||||
|
where: { dealerId: termination.dealerId, roleCode: ROLES.DEALER },
|
||||||
|
include: [{ model: db.Outlet, as: 'outlets' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryOutlet = dealerUser?.outlets?.find((o: any) => o.isPrimary) || dealerUser?.outlets?.[0];
|
||||||
|
|
||||||
|
const dealerProfile = await Dealer.findByPk(termination.dealerId);
|
||||||
|
if (!dealerProfile) throw new Error('Dealer record not found for termination');
|
||||||
|
|
||||||
|
const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap(dealerProfile.dealerCode);
|
||||||
|
|
||||||
|
// 2. Create FnF Settlement Record with direct IDs and readable identifier
|
||||||
|
const fnf = await FnF.create({
|
||||||
|
settlementId: NomenclatureService.generateSettlementId(),
|
||||||
|
terminationRequestId: termination.id,
|
||||||
|
dealerId: termination.dealerId,
|
||||||
|
outletId: primaryOutlet?.id || null,
|
||||||
|
status: 'Initiated',
|
||||||
|
totalReceivables: sapDues.data.outstandingInvoices,
|
||||||
|
totalPayables: sapDues.data.securityDeposit,
|
||||||
|
netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
// 2. Initialize Line Items with SAP values
|
||||||
|
await FnFLineItem.bulkCreate([
|
||||||
|
{ fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: userId },
|
||||||
|
{ fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: userId }
|
||||||
|
], { transaction });
|
||||||
|
|
||||||
|
// 3. Initialize CLEARANCE JSON Structure in TerminationRequest (Matching Resignation module)
|
||||||
|
const initialClearances: Record<string, any> = {};
|
||||||
|
FNF_DEPARTMENTS.forEach(dept => {
|
||||||
|
initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await termination.update({ departmentalClearances: initialClearances }, { transaction });
|
||||||
|
|
||||||
|
// 4. Initialize individual FffClearance records for tracking (Unified Dashboard)
|
||||||
|
await FffClearance.bulkCreate(
|
||||||
|
FNF_DEPARTMENTS.map(dept => ({
|
||||||
|
fnfId: fnf.id,
|
||||||
|
department: dept,
|
||||||
|
status: 'Pending'
|
||||||
|
})),
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Sync Deactivation to SAP
|
||||||
|
ExternalMocksService.mockSyncDealerStatusToSap(dealerProfile.dealerCode, 'Inactive')
|
||||||
|
.catch(err => console.error('Error syncing termination deactivation to SAP:', err));
|
||||||
|
|
||||||
|
return fnf;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps termination stages to progress percentage
|
* Maps termination stages to progress percentage
|
||||||
*/
|
*/
|
||||||
@ -58,7 +154,8 @@ export class TerminationWorkflowService {
|
|||||||
[TERMINATION_STAGES.RBM_REVIEW]: 20,
|
[TERMINATION_STAGES.RBM_REVIEW]: 20,
|
||||||
[TERMINATION_STAGES.ZBH_REVIEW]: 30,
|
[TERMINATION_STAGES.ZBH_REVIEW]: 30,
|
||||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: 40,
|
[TERMINATION_STAGES.DD_LEAD_REVIEW]: 40,
|
||||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: 50,
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: 45,
|
||||||
|
[TERMINATION_STAGES.DD_HEAD_REVIEW]: 50,
|
||||||
[TERMINATION_STAGES.NBH_EVALUATION]: 60,
|
[TERMINATION_STAGES.NBH_EVALUATION]: 60,
|
||||||
[TERMINATION_STAGES.SCN_ISSUED]: 70,
|
[TERMINATION_STAGES.SCN_ISSUED]: 70,
|
||||||
[TERMINATION_STAGES.PERSONAL_HEARING]: 75,
|
[TERMINATION_STAGES.PERSONAL_HEARING]: 75,
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import db from '../database/models/index.js';
|
import db from '../database/models/index.js';
|
||||||
const { Application, ApplicationStatusHistory, AuditLog } = db;
|
const {
|
||||||
|
Application, ApplicationStatusHistory, AuditLog,
|
||||||
|
User, Dealer
|
||||||
|
} = db;
|
||||||
import { syncApplicationProgress } from '../common/utils/progress.js';
|
import { syncApplicationProgress } from '../common/utils/progress.js';
|
||||||
import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js';
|
import { AUDIT_ACTIONS, APPLICATION_STAGES } from '../common/config/constants.js';
|
||||||
|
import { NotificationService } from './NotificationService.js';
|
||||||
|
|
||||||
export class WorkflowService {
|
export class WorkflowService {
|
||||||
/**
|
/**
|
||||||
@ -71,6 +75,35 @@ export class WorkflowService {
|
|||||||
// 4. Synchronize Progress Tracker (The true source of truth for the frontend UI)
|
// 4. Synchronize Progress Tracker (The true source of truth for the frontend UI)
|
||||||
await syncApplicationProgress(application.id, targetStatus);
|
await syncApplicationProgress(application.id, targetStatus);
|
||||||
|
|
||||||
|
// 5. Send Status Update Notification (Intelligent Template Selection)
|
||||||
|
if (application.email) {
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: { email: application.email },
|
||||||
|
attributes: ['id']
|
||||||
|
});
|
||||||
|
const targetUserId = user ? user.id : null;
|
||||||
|
|
||||||
|
let templateCode = 'ONBOARDING_STATUS_UPDATE';
|
||||||
|
if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED';
|
||||||
|
if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED';
|
||||||
|
if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY';
|
||||||
|
|
||||||
|
await NotificationService.notify(targetUserId, application.email, {
|
||||||
|
title: `Onboarding Update: ${targetStatus}`,
|
||||||
|
message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`,
|
||||||
|
channels: ['email', 'whatsapp', 'system'],
|
||||||
|
templateCode: templateCode,
|
||||||
|
placeholders: {
|
||||||
|
status: targetStatus,
|
||||||
|
applicantName: application.applicantName,
|
||||||
|
applicationId: application.applicationId,
|
||||||
|
reason: reason || 'N/A',
|
||||||
|
salesCode: application.dealerCode?.salesCode || 'N/A',
|
||||||
|
serviceCode: application.dealerCode?.serviceCode || 'N/A'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);
|
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);
|
||||||
|
|
||||||
return application;
|
return application;
|
||||||
|
|||||||
@ -12,7 +12,20 @@ const EMAILS = {
|
|||||||
DD_LEAD: 'ddlead@royalenfield.com',
|
DD_LEAD: 'ddlead@royalenfield.com',
|
||||||
DD_HEAD: 'ddhead@royalenfield.com',
|
DD_HEAD: 'ddhead@royalenfield.com',
|
||||||
NBH: 'nbh@royalenfield.com',
|
NBH: 'nbh@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com'
|
LEGAL: 'legal@royalenfield.com',
|
||||||
|
SALES: 'sales@royalenfield.com',
|
||||||
|
SERVICE: 'service@royalenfield.com',
|
||||||
|
SPARES: 'spares@royalenfield.com',
|
||||||
|
FINANCE: 'finance@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) {
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||||
@ -58,14 +71,6 @@ async function run() {
|
|||||||
console.log(`[STEP 1] Request Created. RequestID: ${requestId}`);
|
console.log(`[STEP 1] Request Created. RequestID: ${requestId}`);
|
||||||
|
|
||||||
// Sequence of users taking actions to advance stages
|
// Sequence of users taking actions to advance stages
|
||||||
// 1. Dealer SUBMITTED -> ASM_REVIEW
|
|
||||||
// 2. ASM moves it to ZM_RBM_REVIEW
|
|
||||||
// 3. ZM moves it to ZBH_REVIEW
|
|
||||||
// 4. ZBH moves it to LEAD_REVIEW
|
|
||||||
// 5. LEAD moves it to HEAD_REVIEW
|
|
||||||
// 6. HEAD moves it to NBH_APPROVAL
|
|
||||||
// 7. NBH moves it to LEGAL_REVIEW
|
|
||||||
// 8. LEGAL moves it to COMPLETED
|
|
||||||
const approvalSequence = [
|
const approvalSequence = [
|
||||||
{ name: 'ASM', email: EMAILS.ASM },
|
{ name: 'ASM', email: EMAILS.ASM },
|
||||||
{ name: 'ZM/RBM', email: EMAILS.RBM_L1 },
|
{ name: 'ZM/RBM', email: EMAILS.RBM_L1 },
|
||||||
@ -73,7 +78,8 @@ async function run() {
|
|||||||
{ name: 'DD Lead', email: EMAILS.DD_LEAD },
|
{ name: 'DD Lead', email: EMAILS.DD_LEAD },
|
||||||
{ name: 'DD Head', email: EMAILS.DD_HEAD },
|
{ name: 'DD Head', email: EMAILS.DD_HEAD },
|
||||||
{ name: 'NBH', email: EMAILS.NBH },
|
{ name: 'NBH', email: EMAILS.NBH },
|
||||||
{ name: 'Legal Admin', email: EMAILS.LEGAL }
|
{ name: 'Legal Review', email: EMAILS.LEGAL },
|
||||||
|
{ name: 'Legal Finalize', email: EMAILS.LEGAL }
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentStep = 2;
|
let currentStep = 2;
|
||||||
@ -92,13 +98,17 @@ async function run() {
|
|||||||
console.log('[FINAL STEP] Verifying Completion Status...');
|
console.log('[FINAL STEP] Verifying Completion Status...');
|
||||||
const adminToken = await login(EMAILS.DD_ADMIN);
|
const adminToken = await login(EMAILS.DD_ADMIN);
|
||||||
const finalDetails = await apiRequest(`/self-service/constitutional/${requestId}`, 'GET', null, adminToken);
|
const finalDetails = await apiRequest(`/self-service/constitutional/${requestId}`, 'GET', null, adminToken);
|
||||||
console.log(`Final Stage REACHED: ${finalDetails.request.currentStage}`);
|
|
||||||
console.log(`Final Status: ${finalDetails.request.status}`);
|
if (finalDetails.request.status === 'Completed' || finalDetails.request.currentStage === 'Completed') {
|
||||||
|
console.log(`[STEP ${currentStep}] SUCCESS: Request reached COMPLETED state.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Verification Failed: Final stage is ${finalDetails.request.currentStage}. Status: ${finalDetails.request.status}`);
|
||||||
|
throw new Error('Constitutional change completion check failed.');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n--- VERIFICATION RESULTS ---');
|
console.log('\n--- VERIFICATION RESULTS ---');
|
||||||
console.log('Legal Team Participation: CONFIRMED (Participated at Step 8)');
|
console.log('Outcome: CONSTITUTIONAL CHANGE FLOW COMPLETED SUCCESSFULLY');
|
||||||
console.log('Final Outcome: SUCCESS (Workflow reached COMPLETED stage)');
|
process.exit(0);
|
||||||
console.log('--- CONSTITUTIONAL CHANGE FLOW COMPLETED SUCCESSFULLY! ---');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Workflow failed:', error.message);
|
console.error('Workflow failed:', error.message);
|
||||||
|
|||||||
@ -11,7 +11,20 @@ const EMAILS = {
|
|||||||
ZBH: 'yashwin@gmail.com',
|
ZBH: 'yashwin@gmail.com',
|
||||||
DD_LEAD: 'ddlead@royalenfield.com',
|
DD_LEAD: 'ddlead@royalenfield.com',
|
||||||
NBH: 'nbh@royalenfield.com',
|
NBH: 'nbh@royalenfield.com',
|
||||||
LEGAL: 'legal@royalenfield.com'
|
LEGAL: 'legal@royalenfield.com',
|
||||||
|
FINANCE: 'finance@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) {
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||||
@ -41,6 +54,7 @@ async function login(email) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms = 500) => new Promise(res => setTimeout(res, ms));
|
const delay = (ms = 500) => new Promise(res => setTimeout(res, ms));
|
||||||
|
const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@ -48,71 +62,193 @@ async function run() {
|
|||||||
|
|
||||||
const adminToken = await login(EMAILS.DD_ADMIN);
|
const adminToken = await login(EMAILS.DD_ADMIN);
|
||||||
const appsRes = await apiRequest('/onboarding/applications', 'GET', null, adminToken);
|
const appsRes = await apiRequest('/onboarding/applications', 'GET', null, adminToken);
|
||||||
const targetApp = appsRes.data.find(a => a.status === 'Onboarded') || appsRes.data[0];
|
const onboardedApps = appsRes.data.filter(a => (a.overallStatus || a.status || '').toLowerCase() === 'onboarded');
|
||||||
|
|
||||||
if (!targetApp) throw new Error('No onboarded applications found for resignation test.');
|
if (onboardedApps.length === 0) throw new Error('No onboarded applications found for resignation test.');
|
||||||
|
|
||||||
|
// 1.0 Find an active Dealer and Login
|
||||||
|
let targetApp = null;
|
||||||
|
let dealerToken = null;
|
||||||
|
|
||||||
|
for (const app of onboardedApps) {
|
||||||
|
try {
|
||||||
|
process.stdout.write(`Testing login for ${app.email}... `);
|
||||||
|
const dealerData = await apiRequest('/auth/login', 'POST', {
|
||||||
|
email: app.email,
|
||||||
|
password: 'Dealer@123'
|
||||||
|
});
|
||||||
|
dealerToken = dealerData.token;
|
||||||
|
targetApp = app;
|
||||||
|
console.log('SUCCESS');
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('FAILED (Deactivated)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetApp) throw new Error('All onboarded applications are deactivated. Run onboarding first.');
|
||||||
|
|
||||||
console.log(`Targeting Application: ${targetApp.applicantName} (${targetApp.id}) - Email: ${targetApp.email}`);
|
console.log(`Targeting Application: ${targetApp.applicantName} (${targetApp.id}) - Email: ${targetApp.email}`);
|
||||||
|
await delay();
|
||||||
// 1.0 Login as the Dealer
|
|
||||||
console.log(`[STEP 1.0] Logging in as Dealer (${targetApp.email})...`);
|
|
||||||
const dealerData = await apiRequest('/auth/login', 'POST', {
|
|
||||||
email: targetApp.email,
|
|
||||||
password: 'Dealer@123' // Standard default password from onboarding
|
|
||||||
});
|
|
||||||
const dealerToken = dealerData.token;
|
|
||||||
|
|
||||||
// 1.1 Discover Dealer's Outlet
|
// 1.1 Discover Dealer's Outlet
|
||||||
console.log(`[STEP 1.1] Discovering Outlets for Dealer...`);
|
console.log(`[STEP 1.1] Discovering Outlets for Dealer...`);
|
||||||
const dealerDashboard = await apiRequest('/dealers/dashboard', 'GET', null, dealerToken);
|
const dealerDashboard = await apiRequest('/dealer/dashboard', 'GET', null, dealerToken);
|
||||||
const targetOutlet = dealerDashboard.data.outlets[0];
|
const targetOutlet = dealerDashboard.data.outlets[0];
|
||||||
|
await delay();
|
||||||
|
|
||||||
if (!targetOutlet) throw new Error('No outlets found for this dealer. Ensure they are fully onboarded.');
|
if (!targetOutlet) throw new Error('No outlets found for this dealer. Ensure they are fully onboarded.');
|
||||||
console.log(`Found Target Outlet: ${targetOutlet.name} (${targetOutlet.code})`);
|
console.log(`Found Target Outlet: ${targetOutlet.name} (${targetOutlet.code})`);
|
||||||
|
|
||||||
console.log(`[STEP 1.2] Dealer Submitting Resignation for Outlet...`);
|
console.log(`[STEP 1.2] Dealer Submitting Resignation for Outlet...`);
|
||||||
const createRes = await apiRequest('/self-service/resignations', 'POST', {
|
let resignationId;
|
||||||
outletId: targetOutlet.id,
|
try {
|
||||||
resignationType: 'Voluntary',
|
const createRes = await apiRequest('/self-service/resignations', 'POST', {
|
||||||
lastOperationalDateSales: new Date().toISOString().split('T')[0],
|
outletId: targetOutlet.id,
|
||||||
lastOperationalDateServices: new Date().toISOString().split('T')[0],
|
resignationType: 'Voluntary',
|
||||||
reason: 'Focusing on other business ventures',
|
lastOperationalDateSales: new Date().toISOString().split('T')[0],
|
||||||
remarks: 'Initiating voluntary resignation for E2E validation.'
|
lastOperationalDateServices: new Date().toISOString().split('T')[0],
|
||||||
}, dealerToken);
|
reason: 'Focusing on other business ventures',
|
||||||
|
remarks: 'Initiating voluntary resignation for E2E validation.'
|
||||||
const resignationId = createRes.resignation.id;
|
}, dealerToken);
|
||||||
console.log(`[STEP 1] Resignation Created. ID: ${resignationId}`);
|
resignationId = createRes.resignation.id;
|
||||||
|
log(1, `Resignation Created. ID: ${resignationId}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('already has an active resignation request')) {
|
||||||
|
console.log(`[STEP 1.2] Active resignation already exists. Fetching...`);
|
||||||
|
// Use plural route for listing
|
||||||
|
const activeResRes = await apiRequest('/self-service/resignations', 'GET', null, dealerToken);
|
||||||
|
const activeRes = (activeResRes.resignations || activeResRes.data).find(r => r.outletId === targetOutlet.id && !['Completed', 'Rejected'].includes(r.status));
|
||||||
|
resignationId = activeRes.id;
|
||||||
|
log(1, `Resuming with existig Resignation: ${resignationId}`);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay();
|
||||||
|
|
||||||
const approvals = [
|
const approvals = [
|
||||||
{ name: 'ASM', email: EMAILS.ASM },
|
{ name: 'ASM', email: EMAILS.ASM, remarks: 'Verified physical assets and dealer intent. Recommended for resignation.' },
|
||||||
{ name: 'RBM', email: EMAILS.RBM },
|
{ name: 'RBM', email: EMAILS.RBM, remarks: 'No active sales pipeline issues in the territory. Transition plan discussed.' },
|
||||||
{ name: 'ZBH', email: EMAILS.ZBH },
|
{ name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' },
|
||||||
{ name: 'DD Lead', email: EMAILS.DD_LEAD },
|
{ name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
|
||||||
{ name: 'NBH', email: EMAILS.NBH },
|
{ name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
|
||||||
{ name: 'DD Admin', email: EMAILS.DD_ADMIN },
|
{ name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' },
|
||||||
{ name: 'Legal Admin', email: EMAILS.LEGAL }
|
{ name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Fetch resignation data to determine current stage for skipping
|
||||||
|
const resignationData = await apiRequest(`/self-service/resignations/${resignationId}`, 'GET', null, adminToken);
|
||||||
|
const currentStage = resignationData.resignation.currentStage;
|
||||||
|
|
||||||
|
console.log(`Current Stage: ${currentStage}`);
|
||||||
|
|
||||||
|
const stageOrder = [
|
||||||
|
'ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal Admin', 'F&F Initiated', 'Completed'
|
||||||
|
];
|
||||||
|
|
||||||
|
const startIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage);
|
||||||
|
|
||||||
let currentStep = 2;
|
let currentStep = 2;
|
||||||
for (const actor of approvals) {
|
for (let i = startIndex; i < approvals.length; i++) {
|
||||||
console.log(`[STEP ${currentStep}] ${actor.name} (${actor.email}) approving...`);
|
const actor = approvals[i];
|
||||||
|
log(currentStep, `${actor.name} (${actor.email}) approving...`);
|
||||||
const token = await login(actor.email);
|
const token = await login(actor.email);
|
||||||
const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
|
const res = await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
|
||||||
remarks: `${actor.name} approved the resignation request.`
|
remarks: actor.remarks,
|
||||||
|
force: true
|
||||||
}, token);
|
}, token);
|
||||||
console.log(`[STEP ${currentStep}] ${actor.name} Result: ${res.message}`);
|
log(currentStep, `${actor.name} Result: ${res.message || 'SUCCESS'}`);
|
||||||
currentStep++;
|
currentStep++;
|
||||||
await delay(500);
|
await delay();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[FINAL STEP] Verifying Acceptance Status...');
|
// --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) ---
|
||||||
const finalRes = await apiRequest(`/self-service/resignations/${resignationId}`, 'GET', null, adminToken);
|
console.log('[STEP 9] Starting 16-Department F&F Clearance Flow...');
|
||||||
console.log(`Final Stage: ${finalRes.resignation.currentStage}`);
|
|
||||||
console.log(`Final Status: ${finalRes.resignation.status}`);
|
// Re-fetch to ensure we have the F&F ID regardless of start point
|
||||||
|
const finalResData = await apiRequest(`/self-service/resignations/${resignationId}`, 'GET', null, adminToken);
|
||||||
|
const fnfId = finalResData.resignation.settlement?.id;
|
||||||
|
|
||||||
|
if (!fnfId) {
|
||||||
|
throw new Error(`F&F Settlement ID not found for Resignation ${resignationId}. Ensure it reached F&F Initiation stage.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`F&F Settlement ID: ${fnfId}`);
|
||||||
|
await delay();
|
||||||
|
|
||||||
console.log('\n--- VERIFICATION SUCCESSFUL ---');
|
|
||||||
console.log('Outcome: RESIGNATION ACCEPTED BY ALL STAKEHOLDERS (Includng Legal).');
|
|
||||||
process.exit(0);
|
const departments = [
|
||||||
|
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No pending claims.' },
|
||||||
|
{ name: 'Accessories Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Stock returned and verified.' },
|
||||||
|
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Allocations transferred.' },
|
||||||
|
{ name: 'RTO Department', status: 'Dues', amount: 1500, type: 'Recovery', remarks: 'Pending RTO tax recovery.' },
|
||||||
|
{ name: 'Service Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Service tools handed over.' },
|
||||||
|
{ name: 'Parts Department', status: 'Dues', amount: 45000, type: 'Payable', remarks: 'Parts credit note adjustment.' },
|
||||||
|
{ name: 'Finance Department', status: 'Dues', amount: 25000, type: 'Recovery', remarks: 'Short-term loan interest.' },
|
||||||
|
{ name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Policy renewals handled.' },
|
||||||
|
{ name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Physical inventory reconciled.' },
|
||||||
|
{ name: 'Marketing Department', status: 'Dues', amount: 5000, type: 'Recovery', remarks: 'Glow-sign board removal cost.' },
|
||||||
|
{ name: 'HR Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Dealer staff settlement verified.' },
|
||||||
|
{ name: 'IT Department', status: 'Dues', amount: 12000, type: 'Recovery', remarks: 'Laptop / DMS hardware dues.' },
|
||||||
|
{ name: 'Legal Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Legal NOC issued.' },
|
||||||
|
{ name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Quality audit passed.' },
|
||||||
|
{ name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Last vehicle transit clear.' },
|
||||||
|
{ name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Customer complaints resolved.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dept of departments) {
|
||||||
|
log('9.1', `Clearing Dept: ${dept.name} [${dept.status}] - ${dept.remarks}`);
|
||||||
|
await apiRequest(`/self-service/resignations/${resignationId}/clearance`, 'PUT', {
|
||||||
|
department: dept.name,
|
||||||
|
status: dept.status,
|
||||||
|
remarks: dept.remarks,
|
||||||
|
amount: dept.amount,
|
||||||
|
type: dept.type
|
||||||
|
}, adminToken);
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
log(9, 'All 16 Departments Cleared.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// --- FINAL FINANCE SETTLEMENT ---
|
||||||
|
console.log('[STEP 10] Finance Finalizing Settlement...');
|
||||||
|
const financeToken = await login(EMAILS.FINANCE);
|
||||||
|
await apiRequest(`/settlement/fnf/${fnfId}`, 'PUT', {
|
||||||
|
status: 'Completed',
|
||||||
|
finalSettlementAmount: 0,
|
||||||
|
remarks: 'Settlement completed'
|
||||||
|
}, financeToken);
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// --- FINAL COMPLETION ---
|
||||||
|
console.log('[STEP 11] Moving Resignation to COMPLETED...');
|
||||||
|
await apiRequest(`/self-service/resignations/${resignationId}/approve`, 'PUT', {
|
||||||
|
remarks: 'Final resignation completion.'
|
||||||
|
}, adminToken);
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
// [FINAL STEP] Verification of deactivation
|
||||||
|
console.log(`[FINAL STEP] Verifying Account Deactivation...`);
|
||||||
|
|
||||||
|
// Get updated user status
|
||||||
|
const userRes = await apiRequest('/admin/users', 'GET', null, adminToken);
|
||||||
|
|
||||||
|
// Fetch dealer to get its associated user ID
|
||||||
|
const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken);
|
||||||
|
const targetD = dealersRes.data.find(d => d.id === targetOutlet.dealerId);
|
||||||
|
const dealerU = userRes.data.find(u => u.id === targetD.user?.id);
|
||||||
|
|
||||||
|
if (dealerU && (dealerU.status === 'deactivated' || !dealerU.isActive)) {
|
||||||
|
console.log(`[VERIFICATION] SUCCESS: Account ${dealerU.email} is deactivated. Status: ${dealerU.status}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[VERIFICATION] Failed: Account ${targetApp.email} is still active. Status: ${dealerU?.status || 'unknown'}`);
|
||||||
|
throw new Error('Automated account deactivation check failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('--- DEALER RESIGNATION E2E FLOW COMPLETED SUCCESSFULLY ---');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Workflow failed:', error.message);
|
console.error('Workflow failed:', error.message);
|
||||||
|
|||||||
@ -12,7 +12,19 @@ const EMAILS = {
|
|||||||
LEGAL: 'legal@royalenfield.com',
|
LEGAL: 'legal@royalenfield.com',
|
||||||
NBH: 'nbh@royalenfield.com',
|
NBH: 'nbh@royalenfield.com',
|
||||||
CCO: 'cco@royalenfield.com',
|
CCO: 'cco@royalenfield.com',
|
||||||
CEO: 'ceo@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) {
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||||
@ -37,6 +49,7 @@ async function login(email) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms = 500) => new Promise(res => setTimeout(res, ms));
|
const delay = (ms = 500) => new Promise(res => setTimeout(res, ms));
|
||||||
|
const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@ -57,7 +70,7 @@ async function run() {
|
|||||||
dealerId: targetDealer.id,
|
dealerId: targetDealer.id,
|
||||||
category: 'Performance',
|
category: 'Performance',
|
||||||
reason: 'Consistently failed to meet commitment targets.',
|
reason: 'Consistently failed to meet commitment targets.',
|
||||||
proposedLwd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
proposedLwd: new Date().toISOString(),
|
||||||
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
comments: 'Auto termination for testing flow ending with Legal Team, CCO, CEO.'
|
||||||
}, asmToken);
|
}, asmToken);
|
||||||
|
|
||||||
@ -65,39 +78,95 @@ async function run() {
|
|||||||
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}`);
|
console.log(`[STEP 1] Termination Request Created. ID: ${terminationId}`);
|
||||||
|
|
||||||
const approvals = [
|
const approvals = [
|
||||||
{ name: 'RBM Review', email: EMAILS.RBM },
|
{ name: 'RBM Review', email: EMAILS.RBM, remarks: 'Performance concerns validated on-ground. Proceed with termination.' },
|
||||||
{ name: 'ZBH Review', email: EMAILS.ZBH },
|
{ name: 'ZBH Review', email: EMAILS.ZBH, remarks: 'Strategic decision aligned with regional growth targets. Approved.' },
|
||||||
{ name: 'DD Lead Review', email: EMAILS.DD_LEAD },
|
{ name: 'DD Lead Review', email: EMAILS.DD_LEAD, remarks: 'Contractual breaches documented. Verified.' },
|
||||||
{ name: 'Legal Verification', email: EMAILS.LEGAL },
|
{ name: 'Legal Verification', email: EMAILS.LEGAL, remarks: 'Legal audit complete. Case is legally sound.' },
|
||||||
{ name: 'NBH Evaluation', email: EMAILS.NBH },
|
{ name: 'DD Head Review', email: EMAILS.NBH, remarks: 'Strategic impact assessed. Proceeding with SCN approval.' },
|
||||||
{ name: 'SCN Issued', email: EMAILS.NBH },
|
{ name: 'NBH Evaluation', email: EMAILS.NBH, remarks: 'Functional teams aligned. SCN to be issued.' },
|
||||||
{ name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD },
|
{ name: 'SCN Issued', email: EMAILS.NBH, remarks: 'Show Cause Notice formally dispatched.' },
|
||||||
{ name: 'NBH Final Approval', email: EMAILS.NBH },
|
{ name: 'Personal Hearing Outcome', email: EMAILS.DD_LEAD, remarks: 'Hearing completed. Dealer defense not sufficient.' },
|
||||||
{ name: 'CCO Approval', email: EMAILS.CCO },
|
{ name: 'NBH Final Approval', email: EMAILS.NBH, remarks: 'Final recommendation for termination sent to CEO.' },
|
||||||
{ name: 'CEO Final Approval', email: EMAILS.CEO },
|
{ name: 'CCO Approval', email: EMAILS.CCO, remarks: 'Commercial impact assessed. Approved.' },
|
||||||
{ name: 'Legal Termination Letter', email: EMAILS.LEGAL }
|
{ name: 'CEO Final Approval', email: EMAILS.CEO, remarks: 'Final authorization granted. Issue termination letter.' },
|
||||||
|
{ name: 'Legal Termination Letter', email: EMAILS.LEGAL, remarks: 'Termination letter shared via registered mail.' },
|
||||||
|
{ name: 'Final Terminated Status', email: EMAILS.DD_ADMIN, remarks: 'Closure completed.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
let currentStep = 2;
|
let currentStep = 2;
|
||||||
for (const actor of approvals) {
|
for (const actor of approvals) {
|
||||||
console.log(`[STEP ${currentStep}] ${actor.name} (${actor.email}) processing approval...`);
|
log(currentStep, `${actor.name} (${actor.email}) processing approval...`);
|
||||||
const token = await login(actor.email);
|
const token = await login(actor.email);
|
||||||
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
await apiRequest(`/termination/${terminationId}/status`, 'PUT', {
|
||||||
action: 'approve',
|
action: 'approve',
|
||||||
remarks: `${actor.name} verification completed.`
|
remarks: actor.remarks
|
||||||
}, token);
|
}, token);
|
||||||
console.log(`[STEP ${currentStep}] ${actor.name} Result: SUCCESS`);
|
log(currentStep, `${actor.name} Result: SUCCESS`);
|
||||||
currentStep++;
|
currentStep++;
|
||||||
await delay(500);
|
await delay();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[FINAL STEP] Verifying Terminated Status...');
|
// --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) ---
|
||||||
|
log(13, 'Starting 16-Department F&F Clearance Flow for Termination...');
|
||||||
|
const terminationData = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
||||||
|
const fnfId = terminationData.termination.fnfSettlement?.id;
|
||||||
|
|
||||||
|
if (!fnfId) {
|
||||||
|
log('SKIP', 'FnF Settlement not initialized for this termination case.');
|
||||||
|
} else {
|
||||||
|
const departments = [
|
||||||
|
{ name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No pending claims.' },
|
||||||
|
{ name: 'Accessories Department', status: 'Dues', amount: 15000, type: 'Recovery', remarks: 'Shortage in accessory stock.' },
|
||||||
|
{ name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Allocations transferred.' },
|
||||||
|
{ name: 'RTO Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' },
|
||||||
|
{ name: 'Service Department', status: 'Dues', amount: 8000, type: 'Recovery', remarks: 'Loaner vehicle damange charges.' },
|
||||||
|
{ name: 'Parts Department', status: 'Dues', amount: 20000, type: 'Payable', remarks: 'Return parts credit.' },
|
||||||
|
{ name: 'Finance Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No interest dues.' },
|
||||||
|
{ name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' },
|
||||||
|
{ name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Inventory handed over.' },
|
||||||
|
{ name: 'Marketing Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' },
|
||||||
|
{ name: 'HR Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Staff settlement clear.' },
|
||||||
|
{ name: 'IT Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Hardware recovered.' },
|
||||||
|
{ name: 'Legal Department', status: 'Dues', amount: 50000, type: 'Recovery', remarks: 'Litigation cost recovery as per agreement.' },
|
||||||
|
{ name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' },
|
||||||
|
{ name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' },
|
||||||
|
{ name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Recovery', 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
console.log('[FINAL STEP] Verifying Terminated Status & Account Deactivation...');
|
||||||
const finalDetails = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
const finalDetails = await apiRequest(`/termination/${terminationId}`, 'GET', null, adminToken);
|
||||||
console.log(`Final Stage REACHED: ${finalDetails.termination.currentStage}`);
|
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.id === targetDealer.user?.id);
|
||||||
|
|
||||||
|
if (dealerUser && !dealerUser.isActive && dealerUser.status === 'deactivated') {
|
||||||
|
console.log(`[VERIFICATION] Account ${dealerUser.email} successfully DEACTIVATED.`);
|
||||||
|
} else {
|
||||||
|
console.error(`[VERIFICATION] Failed: Account ${dealerUser?.email} is still active. Status: ${dealerUser?.status}`);
|
||||||
|
throw new Error('Automated account deactivation check failed.');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('\n--- VERIFICATION SUCCESSFUL ---');
|
console.log('\n--- VERIFICATION SUCCESSFUL ---');
|
||||||
console.log('Role Participation: LEGAL TEAM, CCO, and CEO roles verified.');
|
console.log('Outcome: DEALER TERMINATED & PORTAL ACCESS REVOKED');
|
||||||
console.log('Outcome: DEALER TERMINATED SUCCESSFULLY');
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -23,7 +23,20 @@ const EMAILS = {
|
|||||||
FDD: 'fdd@royalenfield.com',
|
FDD: 'fdd@royalenfield.com',
|
||||||
FINANCE: 'finance@royalenfield.com',
|
FINANCE: 'finance@royalenfield.com',
|
||||||
DD_ADMIN: 'lince@gmail.com',
|
DD_ADMIN: 'lince@gmail.com',
|
||||||
ASM: 'asm.sdelhi@royalenfield.com'
|
ASM: 'asm.sdelhi@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',
|
||||||
|
LEGAL: 'legal@royalenfield.com',
|
||||||
|
LOGISTICS: 'logistics@royalenfield.com',
|
||||||
|
QUALITY: 'quality@royalenfield.com',
|
||||||
|
APPAREL: 'apparel@royalenfield.com',
|
||||||
|
DMS: 'dms@royalenfield.com'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROSPECT_PAYLOAD = {
|
const PROSPECT_PAYLOAD = {
|
||||||
@ -47,7 +60,14 @@ const PROSPECT_PAYLOAD = {
|
|||||||
existingDealer: "No",
|
existingDealer: "No",
|
||||||
ownRoyalEnfield: "Yes",
|
ownRoyalEnfield: "Yes",
|
||||||
royalEnfieldModel: "Classic 350",
|
royalEnfieldModel: "Classic 350",
|
||||||
description: "Interested in opening a main dealership."
|
description: "Interested in opening a main dealership.",
|
||||||
|
panNumber: 'ABCDE1234F',
|
||||||
|
gstNumber: '07ABCDE1234F1Z5',
|
||||||
|
bankName: 'HDFC Bank',
|
||||||
|
accountNumber: '50100223344556',
|
||||||
|
ifscCode: 'HDFC0001234',
|
||||||
|
accountHolderName: 'Kumar Automobiles Private Limited',
|
||||||
|
registeredAddress: '123, Main Road, New Delhi'
|
||||||
};
|
};
|
||||||
|
|
||||||
// State to store IDs between steps
|
// State to store IDs between steps
|
||||||
@ -94,7 +114,7 @@ async function prospectLogin(phone) {
|
|||||||
|
|
||||||
async function mockUploadDocument(appId, token, docType) {
|
async function mockUploadDocument(appId, token, docType) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-26 10-08-00.png');
|
const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG');
|
||||||
const blob = new Blob([fileBuffer], { type: 'image/png' });
|
const blob = new Blob([fileBuffer], { type: 'image/png' });
|
||||||
formData.append('file', blob, 'screenshot.png');
|
formData.append('file', blob, 'screenshot.png');
|
||||||
formData.append('documentType', docType);
|
formData.append('documentType', docType);
|
||||||
@ -380,6 +400,19 @@ async function triggerWorkflow() {
|
|||||||
log(9.1, 'Final Security Deposit Verified.');
|
log(9.1, 'Final Security Deposit Verified.');
|
||||||
await delay();
|
await delay();
|
||||||
|
|
||||||
|
// 9.2 ADMIN UPDATING STATUTORY & BANK DETAILS
|
||||||
|
log(9.2, 'Admin Updating Statutory & Bank Details for LOA Approval Gate...');
|
||||||
|
await apiRequest(`/onboarding/applications/${applicationUUID}`, 'PUT', {
|
||||||
|
accountHolderName: 'Ramesh Automobiles Private Limited',
|
||||||
|
panNumber: 'ABCDE1234F',
|
||||||
|
gstNumber: '07ABCDE1234F1Z5',
|
||||||
|
bankName: 'HDFC Bank',
|
||||||
|
accountNumber: '50100223344556',
|
||||||
|
ifscCode: 'HDFC0001234'
|
||||||
|
}, adminToken);
|
||||||
|
log(9.2, 'Statutory & Bank details updated.');
|
||||||
|
await delay();
|
||||||
|
|
||||||
// 10. FINAL LOA APPROVAL
|
// 10. FINAL LOA APPROVAL
|
||||||
log(10, 'NBH & Head Approving Final LOA...');
|
log(10, '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);
|
||||||
@ -397,22 +430,72 @@ async function triggerWorkflow() {
|
|||||||
log(10, 'LOA Fully Approved.');
|
log(10, 'LOA Fully Approved.');
|
||||||
await delay();
|
await delay();
|
||||||
|
|
||||||
// 11. MOVE TO INAUGURATION / APPROVED (Manual Transition)
|
// 11. EOR (EVIDENCE OF READINESS) CHECKLIST VERIFICATION
|
||||||
log(11, 'Admin Moving Application to Approved stage for final onboarding...');
|
log(11, 'Admin Initializing EOR Checklist (100% Readiness Requirement)...');
|
||||||
await apiRequest(`/onboarding/applications/${applicationUUID}/status`, 'PUT', {
|
const eorInit = await apiRequest('/eor', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
status: 'Approved',
|
const checklistId = eorInit.data.id;
|
||||||
stage: 'Inauguration',
|
log(11, `EOR Checklist Created (ID: ${checklistId})`);
|
||||||
reason: 'Pre-onboarding verification complete'
|
|
||||||
|
log(11.1, 'Auditor Verifying all 12 mandatory EOR items as COMPLIANT...');
|
||||||
|
const eorItems = [
|
||||||
|
{ itemType: 'Sales', description: 'Sales Standards' },
|
||||||
|
{ itemType: 'Service', description: 'Service & Spares' },
|
||||||
|
{ itemType: 'IT', description: 'DMS infra' },
|
||||||
|
{ itemType: 'Training', description: 'Manpower Training' },
|
||||||
|
{ itemType: 'Statutory', description: 'Trade certificate with test ride bikes registration' },
|
||||||
|
{ itemType: 'Statutory', description: 'GST certificate including Accessories & Apparels billing' },
|
||||||
|
{ itemType: 'Finance', description: 'Inventory Funding' },
|
||||||
|
{ itemType: 'IT', description: 'Virtual code availability' },
|
||||||
|
{ itemType: 'Finance', description: 'Vendor payments' },
|
||||||
|
{ itemType: 'Marketing', description: 'Details for website submission' },
|
||||||
|
{ itemType: 'Insurance', description: 'Infra Insurance both Showroom and Service center' },
|
||||||
|
{ itemType: 'IT', description: 'Auto ordering' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of eorItems) {
|
||||||
|
process.stdout.write(`.`); // Visual progress
|
||||||
|
await apiRequest(`/eor/item/${checklistId}`, 'POST', {
|
||||||
|
...item,
|
||||||
|
isCompliant: true,
|
||||||
|
remarks: 'Verified by Auditor - Compliant'
|
||||||
|
}, adminToken);
|
||||||
|
}
|
||||||
|
console.log('\n[STEP 11.1] All EOR items marked as compliant.');
|
||||||
|
|
||||||
|
log(11.2, 'Auditor Submitting Final EOR Audit...');
|
||||||
|
await apiRequest(`/eor/audit/${checklistId}`, 'POST', {
|
||||||
|
status: 'Completed',
|
||||||
|
overallComments: 'Dealer is 100% ready for inauguration. All infra and statutory items verified.'
|
||||||
}, adminToken);
|
}, adminToken);
|
||||||
log(11, 'Application is now in Approved status.');
|
|
||||||
|
// Status check
|
||||||
|
const finalAppStatus = await apiRequest(`/onboarding/applications/${applicationUUID}`, 'GET', null, adminToken);
|
||||||
|
log(11.2, `Application Status after EOR: ${finalAppStatus.data.overallStatus}`);
|
||||||
await delay();
|
await delay();
|
||||||
|
|
||||||
// 12. FINAL ONBOARDING
|
// 12. FINAL ONBOARDING
|
||||||
log(12, 'Admin Finalizing Dealer Onboarding...');
|
log(12, 'Admin Finalizing Dealer Onboarding...');
|
||||||
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
await apiRequest('/dealers', 'POST', { applicationId: applicationUUID }, adminToken);
|
||||||
|
await delay();
|
||||||
|
|
||||||
log(12, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
// 13. VERIFICATION
|
||||||
log(12, `The application ${applicationId} is now at 'ONBOARDED' status.`);
|
log(13, 'Verifying Dealer Record Creation...');
|
||||||
|
const dealerRes = await apiRequest(`/dealers/application/${applicationUUID}`, 'GET', null, adminToken);
|
||||||
|
if (!dealerRes.success || !dealerRes.data) {
|
||||||
|
throw new Error('Verification Failed: Dealer record not found after onboarding.');
|
||||||
|
}
|
||||||
|
log(13, `Dealer Found: ${dealerRes.data.legalName} (${dealerRes.data.id})`);
|
||||||
|
|
||||||
|
log(13.1, 'Verifying User Account Role Update...');
|
||||||
|
const userRes = await apiRequest(`/admin/users`, 'GET', null, adminToken);
|
||||||
|
const dealerUser = userRes.data.find(u => u.email === PROSPECT_EMAIL);
|
||||||
|
if (!dealerUser || dealerUser.roleCode !== 'Dealer') {
|
||||||
|
throw new Error(`Verification Failed: User role not updated to 'Dealer'. Current role: ${dealerUser?.roleCode}`);
|
||||||
|
}
|
||||||
|
log(13.1, `User role confirmed: ${dealerUser.roleCode}`);
|
||||||
|
|
||||||
|
log(13.2, '--- WORKFLOW COMPLETED SUCCESSFULLY! ---');
|
||||||
|
log(13.2, `The application ${applicationId} is now at 'ONBOARDED' status and Dealer profile is active.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user