From 944238d39a4cbb4403d453c1e2559fbf6af150df Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Thu, 18 Jun 2026 18:29:46 +0530 Subject: [PATCH] GWM : Next set of work --- .env.example | 6 + BACKEND_TASKS.md | 231 +++++- src/app.js | 3 + src/config/env.js | 5 + src/docs/assets-routes.yaml | 195 +++++ src/docs/completed-routes.yaml | 50 +- src/docs/grn-routes.yaml | 155 ++++ src/docs/purchase-orders-routes.yaml | 243 +++++++ src/docs/vendors-routes.yaml | 411 +++++++++++ src/modules/assets/assets.constants.js | 11 + src/modules/assets/assets.controller.js | 40 + src/modules/assets/assets.repository.js | 33 + src/modules/assets/assets.routes.js | 37 + src/modules/assets/assets.service.js | 516 +++++++++++++ src/modules/assets/assets.validation.js | 117 +++ src/modules/auth/auth.controller.js | 7 +- src/modules/auth/auth.routes.js | 2 + src/modules/auth/auth.service.js | 60 +- .../deployment/deployment.controller.js | 105 +++ src/modules/grn/grn.constants.js | 17 + src/modules/grn/grn.controller.js | 37 + src/modules/grn/grn.poStatus.js | 40 + src/modules/grn/grn.repository.js | 125 ++++ src/modules/grn/grn.routes.js | 26 + src/modules/grn/grn.service.js | 432 +++++++++++ src/modules/grn/grn.validation.js | 73 ++ .../purchase-orders.calculations.js | 47 ++ .../purchase-orders.constants.js | 37 + .../purchase-orders.controller.js | 74 ++ .../purchase-orders.repository.js | 46 ++ .../purchase-orders/purchase-orders.routes.js | 73 ++ .../purchase-orders.service.js | 682 ++++++++++++++++++ .../purchase-orders.validation.js | 108 +++ src/modules/roles/roles.routes.js | 4 +- src/modules/roles/roles.service.js | 34 +- src/modules/roles/roles.validation.js | 2 + src/modules/vendors/vendors.controller.js | 155 ++++ src/modules/vendors/vendors.routes.js | 105 +++ src/modules/vendors/vendors.service.js | 494 +++++++++++++ src/modules/vendors/vendors.validation.js | 121 ++++ src/routes/v1/index.js | 4 + src/utils/simplePdf.js | 38 + 42 files changed, 4932 insertions(+), 69 deletions(-) create mode 100644 src/docs/assets-routes.yaml create mode 100644 src/docs/grn-routes.yaml create mode 100644 src/docs/purchase-orders-routes.yaml create mode 100644 src/docs/vendors-routes.yaml create mode 100644 src/modules/assets/assets.constants.js create mode 100644 src/modules/assets/assets.controller.js create mode 100644 src/modules/assets/assets.repository.js create mode 100644 src/modules/assets/assets.routes.js create mode 100644 src/modules/assets/assets.service.js create mode 100644 src/modules/assets/assets.validation.js create mode 100644 src/modules/deployment/deployment.controller.js create mode 100644 src/modules/grn/grn.constants.js create mode 100644 src/modules/grn/grn.controller.js create mode 100644 src/modules/grn/grn.poStatus.js create mode 100644 src/modules/grn/grn.repository.js create mode 100644 src/modules/grn/grn.routes.js create mode 100644 src/modules/grn/grn.service.js create mode 100644 src/modules/grn/grn.validation.js create mode 100644 src/modules/purchase-orders/purchase-orders.calculations.js create mode 100644 src/modules/purchase-orders/purchase-orders.constants.js create mode 100644 src/modules/purchase-orders/purchase-orders.controller.js create mode 100644 src/modules/purchase-orders/purchase-orders.repository.js create mode 100644 src/modules/purchase-orders/purchase-orders.routes.js create mode 100644 src/modules/purchase-orders/purchase-orders.service.js create mode 100644 src/modules/purchase-orders/purchase-orders.validation.js create mode 100644 src/modules/vendors/vendors.controller.js create mode 100644 src/modules/vendors/vendors.routes.js create mode 100644 src/modules/vendors/vendors.service.js create mode 100644 src/modules/vendors/vendors.validation.js create mode 100644 src/utils/simplePdf.js diff --git a/.env.example b/.env.example index 1702da3..3701408 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,9 @@ MAX_FILE_SIZE_MB=5 # Account lockout MAX_LOGIN_ATTEMPTS=5 LOCKOUT_DURATION_MINUTES=30 + +# Bitbucket deploy webhook (POST /deploy?apikey=...) +BITBUCKET_API_KEY= +EXPECTED_REPO=your-org/your-repo +EXPECTED_BRANCH=main +DEPLOY_SCRIPT=/path/to/deploy.sh diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index 67a103c..a0ffdfd 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -26,7 +26,8 @@ Use this checklist when building or extending the backend. Follow the build orde - [ ] Add common columns on every business table: `is_active`, `created_by`, `updated_by`, `created_at`, `updated_at`, `deleted_at` - [ ] Run `npx prisma migrate dev --name init` - [x] Run `npx prisma generate` -- [ ] Implement idempotent `prisma/seed.js` (modules → permissions → roles → role_permissions → Super Admin user) +- [x] Implement `prisma/seed.js` — bootstrap Super Admin user (modules/permissions/roles from DDL) +- [ ] Full idempotent seed (modules → permissions → roles → role_permissions) if not using DDL ### Seed requirements @@ -45,12 +46,12 @@ Use this checklist when building or extending the backend. Follow the build orde - [x] `src/config/logger.js` — Winston + daily rotate; redact sensitive keys - [x] `src/config/morgan.js` — HTTP logs into Winston with request ID + user - [x] `src/config/prisma.js` — singleton client with query/error logging -- [x] `src/config/swagger.js` — OpenAPI 3.0 from route JSDoc +- [x] `src/config/swagger.js` — OpenAPI 3.0 from `src/docs/completed-routes.yaml` - [x] `src/utils/` — ApiError, ApiResponse, asyncHandler, encryption, auditLog, pagination, generateCode - [x] `src/middlewares/` — requestId, error, validate, rateLimiter, auth, rbac, upload - [x] `src/app.js` — Helmet, CORS, HPP, compression, rate limit, routes, Swagger, 404, error handler - [x] `src/server.js` — graceful shutdown, BigInt JSON serializer, unhandled rejection/exception handlers -- [ ] Verify `GET /health` responds +- [x] Verify `GET /health` responds --- @@ -58,23 +59,29 @@ Use this checklist when building or extending the backend. Follow the build orde Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.service.js` → `*.validation.js` (+ `*.repository.js` for complex modules). -| # | Module | Notes | -|---|--------|-------| -| 8 | **auth** | login, refresh, logout; rotating hashed refresh tokens; account lockout | -| 9 | **masters** | UOM first as template, then replicate 13 sub-masters under `masters/index.js` | -| 10 | **vendors** | CRUD + addresses/contacts/bank-details; encrypt bank account numbers | -| 11 | **purchase-orders** | Header + line items; repository for transactions; submit/approve/reject/amend/cancel | -| 12 | **grn** | Receipt + PO status recalc + asset auto-creation in one transaction | -| 13 | **assets** | CRUD + transfer + transfer history | -| — | **users** | CRUD, RBAC: `USERS` | -| — | **roles** | CRUD + permission assignment, RBAC: `ROLES` | +| # | Module | Status | Notes | +|---|--------|--------|-------| +| 8 | **auth** | [x] Done | login, refresh, logout, me; rotating hashed refresh tokens; account lockout | +| 9 | **masters** | [x] Done | 14 sub-masters under `masters/index.js` (UOM template replicated) | +| — | **users** | [x] Done | CRUD + summary/filters/export; RBAC: `USERS` | +| — | **roles** | [x] Done | CRUD + permission assignment + matrix; RBAC: `ROLES` | +| 10 | **vendors** | [x] Done | CRUD + addresses/contacts/bank-details; encrypt bank accounts | +| 11 | **purchase-orders** | [x] Done | Header + line items; submit/approve/reject/amend/cancel; PDF export | +| 12 | **grn** | [x] Done | Receipt + PO status recalc + asset auto-creation | +| 13 | **assets** | [x] Done | CRUD + transfer + transfer history | Current progress: -- [x] `auth` module scaffold (`login`, `refresh`, `logout`) implemented -- [x] `masters/uom` module implemented as template (CRUD + RBAC + validation + audit log) -- [x] Replicated masters CRUD modules: `item-categories`, `brands`, `gst-rates`, `payment-terms`, `delivery-terms`, `asset-categories`, `departments`, `designations`, `document-series` -- [x] Pending masters now completed: `item-subcategories`, `items`, `warehouses`, `plants` +- [x] `auth` module (`login`, `refresh`, `logout`) +- [x] `users` module (screen APIs: summary, filters, export, CRUD) +- [x] `roles` module (cards, permission catalog, matrix, CRUD) +- [x] `masters/uom` template + 13 replicated sub-masters +- [x] Swagger docs for all completed routes (`src/docs/completed-routes.yaml`) +- [x] `vendors` module (CRUD, status, addresses, contacts, bank-details) +- [x] `purchase-orders` module (CRUD, workflow, PDF) +- [x] `grn` module (transactional receipt, PO status recalc, asset auto-creation) +- [x] `assets` module (CRUD, transfer, transfer history) +- [x] `GET /auth/me` (current user + permissions for FE) ### Layer rules @@ -88,18 +95,177 @@ Current progress: --- -## 5. API Routes (Phase 1) +## 5. Module API List (Phase 1) -``` -/api/v1/auth/{login,refresh,logout} -/api/v1/users -/api/v1/roles -/api/v1/masters/{uom,item-categories,item-subcategories,items,brands,gst-rates,warehouses,payment-terms,delivery-terms,asset-categories,departments,designations,plants,document-series} -/api/v1/vendors (+ /addresses, /contacts, /bank-details) -/api/v1/purchase-orders (+ /submit, /approve, /reject, /amend, /cancel, /pdf) -/api/v1/grn (+ /cancel, /pdf) -/api/v1/assets (+ /transfer, /transfer-history) -``` +Base path: `/api/v1` · Auth: `Authorization: Bearer ` (except public auth routes) + +### System + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/health` | — | App health (root, not under v1) | +| [x] | GET | `/healthz` | — | API v1 health | +| [x] | GET | `/api-docs` | — | Swagger UI | +| [x] | GET | `/api-docs.json` | — | OpenAPI spec | + +--- + +### Auth (`/auth`) + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | POST | `/auth/login` | Public | Returns `accessToken`, `refreshToken` | +| [x] | POST | `/auth/refresh` | Public | Rotating refresh token | +| [x] | POST | `/auth/logout` | Public | Revoke refresh token | +| [ ] | POST | `/auth/forgot-password` | Public | Not implemented | +| [x] | GET | `/auth/me` | Authenticated | Current user + permissions (for FE) | + +--- + +### Users (`/users`) — module: `USERS` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/users/summary` | view | Dashboard cards (total/active/inactive/locked, roles count) | +| [x] | GET | `/users/filters` | view | Role, department, status dropdowns | +| [x] | GET | `/users/export` | export | CSV export (same filters as list) | +| [x] | GET | `/users` | view | Paginated list (`search`, `status`, `role_id`, `department_id`) | +| [x] | GET | `/users/:id` | view | User detail | +| [x] | POST | `/users` | create | Create user (bcrypt password, mobile encrypted) | +| [x] | PUT | `/users/:id` | edit | Update user | +| [x] | DELETE | `/users/:id` | delete | Soft delete | + +--- + +### Roles (`/roles`) — module: `ROLES` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/roles/permissions` | view | Permission catalog (modules × actions) | +| [x] | GET | `/roles/cards` | view | Role cards (name, description, user/permission counts) | +| [x] | GET | `/roles` | view | Paginated role list | +| [x] | GET | `/roles/:id` | view | Role detail with permissions | +| [x] | POST | `/roles` | create | Create role | +| [x] | PUT | `/roles/:id` | edit | Update role | +| [x] | DELETE | `/roles/:id` | delete | Soft delete (blocked if users assigned) | +| [x] | PUT | `/roles/:id/permissions` | edit | Assign by `permission_ids[]` (replaces all) | +| [x] | GET | `/roles/:id/permission-matrix` | view | Matrix UI (view/create/edit/delete/approve/export) | +| [x] | PUT | `/roles/:id/permission-matrix` | edit | Save matrix checkboxes | + +--- + +### Masters (`/masters/*`) — module: `MASTERS` + +Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DELETE /:id` with RBAC `view|create|edit|delete`. + +| Status | Sub-master | Base path | +|--------|------------|-----------| +| [x] | UOM | `/masters/uom` | +| [x] | Item Categories | `/masters/item-categories` | +| [x] | Item Subcategories | `/masters/item-subcategories` | +| [x] | Items | `/masters/items` | +| [x] | Brands | `/masters/brands` | +| [x] | GST Rates | `/masters/gst-rates` | +| [x] | Payment Terms | `/masters/payment-terms` | +| [x] | Delivery Terms | `/masters/delivery-terms` | +| [x] | Asset Categories | `/masters/asset-categories` | +| [x] | Departments | `/masters/departments` | +| [x] | Designations | `/masters/designations` | +| [x] | Plants | `/masters/plants` | +| [x] | Warehouses | `/masters/warehouses` | +| [x] | Document Series | `/masters/document-series` | + +**Masters total:** 14 modules × 5 endpoints = **70 APIs** [x] + +--- + +### Vendors (`/vendors`) — module: `VENDOR` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/vendors` | view | List (`search`, `status`, `vendor_type`) | +| [x] | POST | `/vendors` | create | Auto `vendor_code` via VENDOR series | +| [x] | GET | `/vendors/:id` | view | Detail + addresses, contacts, bank details | +| [x] | PUT | `/vendors/:id` | edit | Update vendor | +| [x] | PATCH | `/vendors/:id/status` | edit | `active` / `inactive` / `blacklisted` | +| [x] | DELETE | `/vendors/:id` | delete | Soft delete | +| [x] | GET | `/vendors/:vendorId/addresses` | view | List addresses | +| [x] | POST | `/vendors/:vendorId/addresses` | create | Add address | +| [x] | GET | `/vendors/:vendorId/addresses/:addressId` | view | Get address | +| [x] | PUT | `/vendors/:vendorId/addresses/:addressId` | edit | Update address | +| [x] | DELETE | `/vendors/:vendorId/addresses/:addressId` | delete | Deactivate address | +| [x] | GET | `/vendors/:vendorId/contacts` | view | List contacts | +| [x] | POST | `/vendors/:vendorId/contacts` | create | Add contact | +| [x] | GET | `/vendors/:vendorId/contacts/:contactId` | view | Get contact | +| [x] | PUT | `/vendors/:vendorId/contacts/:contactId` | edit | Update contact | +| [x] | DELETE | `/vendors/:vendorId/contacts/:contactId` | delete | Deactivate contact | +| [x] | GET | `/vendors/:vendorId/bank-details` | view | List bank details (decrypted) | +| [x] | POST | `/vendors/:vendorId/bank-details` | create | Add bank detail (encrypted) | +| [x] | GET | `/vendors/:vendorId/bank-details/:bankDetailId` | view | Get bank detail | +| [x] | PUT | `/vendors/:vendorId/bank-details/:bankDetailId` | edit | Update bank detail | +| [x] | DELETE | `/vendors/:vendorId/bank-details/:bankDetailId` | delete | Deactivate bank detail | + +--- + +### Purchase Orders (`/purchase-orders`) — module: `PURCHASE_ORDER` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/purchase-orders` | view | List POs | +| [x] | GET | `/purchase-orders/:id` | view | PO detail + line items | +| [x] | POST | `/purchase-orders` | create | Create PO | +| [x] | PUT | `/purchase-orders/:id` | edit | Update PO | +| [x] | DELETE | `/purchase-orders/:id` | delete | Soft delete | +| [x] | POST | `/purchase-orders/:id/submit` | edit | Submit for approval | +| [x] | POST | `/purchase-orders/:id/approve` | approve | Approve PO | +| [x] | POST | `/purchase-orders/:id/reject` | approve | Reject PO | +| [x] | POST | `/purchase-orders/:id/amend` | edit | Amend PO | +| [x] | POST | `/purchase-orders/:id/cancel` | edit | Cancel PO | +| [x] | GET | `/purchase-orders/:id/pdf` | view | PDF export | + +--- + +### GRN (`/grn`) — module: `GRN` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/grn` | view | List GRNs | +| [x] | GET | `/grn/:id` | view | GRN detail + line items | +| [x] | POST | `/grn` | create | Create GRN (transactional) | +| [x] | PUT | `/grn/:id` | edit | Update GRN | +| [x] | POST | `/grn/:id/cancel` | edit | Cancel GRN | +| [x] | GET | `/grn/:id/pdf` | view | PDF export | + +--- + +### Assets (`/assets`) — module: `ASSET` + +| Status | Method | Endpoint | RBAC | Notes | +|--------|--------|----------|------|-------| +| [x] | GET | `/assets` | view | List assets | +| [x] | GET | `/assets/:id` | view | Asset detail | +| [x] | POST | `/assets` | create | Create asset | +| [x] | PUT | `/assets/:id` | edit | Update asset | +| [x] | DELETE | `/assets/:id` | delete | Soft delete | +| [x] | POST | `/assets/:id/transfer` | edit | Transfer asset | +| [x] | GET | `/assets/:id/transfer-history` | view | Transfer history | + +--- + +### API progress summary + +| Module | Endpoints done | Endpoints total | Status | +|--------|----------------|-----------------|--------| +| System | 4 | 4 | [x] Done | +| Auth | 3 | 5 | [ ] Partial | +| Users | 8 | 8 | [x] Done | +| Roles | 10 | 10 | [x] Done | +| Masters | 70 | 70 | [x] Done | +| Vendors | 20 | 20 | [x] Done | +| Purchase Orders | 11 | 11 | [x] Done | +| GRN | 6 | 6 | [x] Done | +| Assets | 7 | 7 | [x] Done | +| **Total** | **139** | **139** | **[x] Phase 1 APIs complete** | --- @@ -114,7 +280,7 @@ Current progress: - [x] Rate limiting: global on `/api`, stricter on `/auth/login` and `/auth/forgot-password` - [x] Helmet, CORS (no `*` in production), HPP, compression - [x] File uploads: MIME allow-list, random filenames, RBAC-protected download (not direct web serve) -- [ ] `auditLog()` on every create/update/status-change/approve/reject +- [x] `auditLog()` on every create/update/status-change/approve/reject (completed modules) - [x] `X-Request-Id` on every request for log correlation ### RBAC permission actions @@ -147,14 +313,15 @@ Current progress: - [ ] Auth: login, refresh, lockout - [ ] RBAC: allowed vs forbidden - [ ] Vendor: CRUD + status transitions -- [ ] PO: status lifecycle -- [ ] GRN: partial receipt → PO status recalculation +- [x] PO: status lifecycle +- [x] GRN: partial receipt → PO status recalculation --- ## 9. Documentation & DevOps -- [ ] Add `@swagger` JSDoc blocks to every route file +- [x] OpenAPI spec for completed routes (`src/docs/completed-routes.yaml` + `/api-docs`) +- [ ] Add `@swagger` JSDoc blocks to route files (optional; YAML spec in use) - [ ] `Dockerfile` (Node 20 Alpine) - [ ] `docker-compose.yml` (API + Postgres 15) - [ ] Verify `docker-compose up` boots API + Postgres diff --git a/src/app.js b/src/app.js index 898b5a2..958cb2e 100644 --- a/src/app.js +++ b/src/app.js @@ -12,6 +12,7 @@ const { generalLimiter } = require('./middlewares/rateLimiter.middleware'); const errorMiddleware = require('./middlewares/error.middleware'); const ApiError = require('./utils/ApiError'); const routesV1 = require('./routes/v1'); +const deploymentController = require('./modules/deployment/deployment.controller'); const app = express(); @@ -28,6 +29,8 @@ app.get('/health', (req, res) => { res.json({ success: true, message: 'OK', uptime: process.uptime() }); }); +app.post('/deploy', deploymentController.deployment); + app.use('/api', generalLimiter); app.use('/api/v1', routesV1); diff --git a/src/config/env.js b/src/config/env.js index b6d16b1..debf810 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -35,6 +35,11 @@ const envSchema = Joi.object({ MAX_LOGIN_ATTEMPTS: Joi.number().default(5), LOCKOUT_DURATION_MINUTES: Joi.number().default(30), + + BITBUCKET_API_KEY: Joi.string().allow('').optional(), + EXPECTED_REPO: Joi.string().allow('').optional(), + EXPECTED_BRANCH: Joi.string().allow('').optional(), + DEPLOY_SCRIPT: Joi.string().allow('').optional(), }).unknown(); const { error, value: env } = envSchema.validate(process.env); diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml new file mode 100644 index 0000000..b58c272 --- /dev/null +++ b/src/docs/assets-routes.yaml @@ -0,0 +1,195 @@ +tags: + - name: Assets + +components: + schemas: + AssetsCreateBody: + type: object + required: [asset_name, asset_category_id, plant_id] + properties: + asset_name: { type: string, example: 'CNC Lathe Machine' } + asset_category_id: { type: integer, example: 1 } + brand_model: { type: string, example: 'Haas ST-20' } + manufacturer: { type: string, example: 'Haas Automation' } + serial_number: { type: string, example: SN-12345 } + part_number: { type: string, example: PN-9876 } + plant_id: { type: integer, example: 1 } + department_id: { type: integer, nullable: true, example: 1 } + warehouse_id: { type: integer, nullable: true, example: 1 } + location_detail: { type: string, example: 'Production Bay 2' } + assigned_to_user_id: { type: integer, nullable: true, example: 1 } + vendor_id: { type: integer, nullable: true, example: 1 } + po_id: { type: integer, nullable: true } + grn_id: { type: integer, nullable: true } + grn_item_id: { type: integer, nullable: true } + purchase_date: { type: string, format: date, nullable: true } + purchase_cost: { type: number, example: 850000 } + useful_life_years: { type: integer, example: 15 } + depreciation_method: { type: string, enum: [SLM, WDV], example: WDV } + salvage_value: { type: number, example: 50000 } + warranty_expiry_date: { type: string, format: date, nullable: true } + amc_start_date: { type: string, format: date, nullable: true } + amc_end_date: { type: string, format: date, nullable: true } + amc_vendor_id: { type: integer, nullable: true } + insurance_policy_no: { type: string, example: POL-2026-001 } + insurance_expiry_date: { type: string, format: date, nullable: true } + condition: { type: string, enum: [NEW, GOOD, FAIR, POOR], example: NEW } + status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED], example: IN_USE } + qr_code_value: { type: string, example: QR-ASSET-001 } + disposal_date: { type: string, format: date, nullable: true } + disposal_reason: { type: string, nullable: true } + disposal_value: { type: number, nullable: true } + remarks: { type: string, example: 'Capital asset' } + is_active: { type: boolean, example: true } + AssetsUpdateBody: + type: object + minProperties: 1 + properties: + asset_name: { type: string } + asset_category_id: { type: integer } + brand_model: { type: string } + manufacturer: { type: string } + serial_number: { type: string } + part_number: { type: string } + plant_id: { type: integer } + department_id: { type: integer, nullable: true } + warehouse_id: { type: integer, nullable: true } + location_detail: { type: string } + assigned_to_user_id: { type: integer, nullable: true } + vendor_id: { type: integer, nullable: true } + po_id: { type: integer, nullable: true } + grn_id: { type: integer, nullable: true } + grn_item_id: { type: integer, nullable: true } + purchase_date: { type: string, format: date, nullable: true } + purchase_cost: { type: number } + useful_life_years: { type: integer, nullable: true } + depreciation_method: { type: string, enum: [SLM, WDV], nullable: true } + salvage_value: { type: number } + warranty_expiry_date: { type: string, format: date, nullable: true } + amc_start_date: { type: string, format: date, nullable: true } + amc_end_date: { type: string, format: date, nullable: true } + amc_vendor_id: { type: integer, nullable: true } + insurance_policy_no: { type: string } + insurance_expiry_date: { type: string, format: date, nullable: true } + condition: { type: string, enum: [NEW, GOOD, FAIR, POOR] } + status: { type: string, enum: [IN_USE, IDLE, UNDER_MAINTENANCE, DISPOSED, SCRAPPED] } + qr_code_value: { type: string } + disposal_date: { type: string, format: date, nullable: true } + disposal_reason: { type: string } + disposal_value: { type: number, nullable: true } + remarks: { type: string } + is_active: { type: boolean } + AssetsTransferBody: + type: object + required: [transfer_date] + properties: + transfer_date: { type: string, format: date, example: '2026-06-18' } + to_plant_id: { type: integer, nullable: true, example: 2 } + to_department_id: { type: integer, nullable: true, example: 3 } + to_user_id: { type: integer, nullable: true, example: 5 } + to_warehouse_id: { type: integer, nullable: true, example: 1 } + reason: { type: string, example: 'Moved to new production line' } + +paths: + /assets: + get: + tags: [Assets] + summary: List assets + parameters: + - { name: page, in: query, schema: { type: integer, example: 1 } } + - { name: limit, in: query, schema: { type: integer, example: 20 } } + - { name: search, in: query, schema: { type: string, example: MCH- } } + - { name: status, in: query, schema: { type: string, example: IN_USE } } + - { name: condition, in: query, schema: { type: string, example: NEW } } + - { name: asset_category_id, in: query, schema: { type: integer } } + - { name: plant_id, in: query, schema: { type: integer } } + - { name: department_id, in: query, schema: { type: integer } } + - { name: is_active, in: query, schema: { type: boolean } } + responses: + '200': + description: Assets fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + post: + tags: [Assets] + summary: Create asset + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AssetsCreateBody' } + responses: + '201': + description: Asset created + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /assets/{id}: + get: + tags: [Assets] + summary: Get asset detail + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: Asset fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + put: + tags: [Assets] + summary: Update asset + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AssetsUpdateBody' } + responses: + '200': + description: Asset updated + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + delete: + tags: [Assets] + summary: Soft delete asset + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: Asset deleted + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /assets/{id}/transfer: + post: + tags: [Assets] + summary: Transfer asset location/assignment + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AssetsTransferBody' } + responses: + '200': + description: Asset transferred + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /assets/{id}/transfer-history: + get: + tags: [Assets] + summary: Get asset transfer history + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: Transfer history fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 7271a09..730ad51 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -38,6 +38,31 @@ components: required: [refresh_token] properties: refresh_token: { type: string, example: "" } + AuthMeData: + type: object + properties: + id: { type: string, example: "1" } + employee_code: { type: string, example: EMP001 } + full_name: { type: string, example: Super Admin } + email: { type: string, format: email, example: "admin@bharaterp.com" } + mobile: { type: string, nullable: true, example: "9876543210" } + status: { type: string, example: active } + is_active: { type: boolean, example: true } + last_login_at: { type: string, format: date-time, nullable: true } + role: + type: object + nullable: true + properties: + id: { type: string } + name: { type: string } + description: { type: string, nullable: true } + department: { type: object, nullable: true } + designation: { type: object, nullable: true } + plant: { type: object, nullable: true } + permissions: + type: array + items: { type: string } + example: ["USERS:view", "ROLES:edit", "VENDOR:create"] UomCreateBody: type: object required: [code, name] @@ -363,10 +388,12 @@ components: module_id: { type: integer, example: 1 } actions: type: object - required: [view, edit, approve, export] + required: [view, create, edit, delete, approve, export] properties: view: { type: boolean, example: true } - edit: { type: boolean, example: true, description: 'Add & edit (grants create + edit)' } + create: { type: boolean, example: true } + edit: { type: boolean, example: true } + delete: { type: boolean, example: false } approve: { type: boolean, example: false } export: { type: boolean, example: true } @@ -416,6 +443,23 @@ paths: description: Success content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } "401": { description: Invalid or expired token } + /auth/me: + get: + tags: [Auth] + summary: Get current user profile and permissions + description: Returns logged-in user details and flat permission codes for FE gating (e.g. USERS:view) + responses: + "200": + description: Current user fetched + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: { $ref: "#/components/schemas/AuthMeData" } + "401": { description: Unauthorized } /masters/uom: get: tags: [UOM] @@ -1617,7 +1661,7 @@ paths: schema: { type: string } get: tags: [Roles] - summary: Permission matrix for role (modules x view/edit/approve/export; edit = add + edit) + summary: Permission matrix for role (modules x view/create/edit/delete/approve/export) responses: "200": description: Matrix fetched diff --git a/src/docs/grn-routes.yaml b/src/docs/grn-routes.yaml new file mode 100644 index 0000000..675c4a1 --- /dev/null +++ b/src/docs/grn-routes.yaml @@ -0,0 +1,155 @@ +tags: + - name: GRN + +components: + schemas: + GrnItemBody: + type: object + required: [po_item_id, line_no, current_qty, accepted_qty] + properties: + po_item_id: { type: integer, example: 1 } + line_no: { type: integer, example: 1 } + current_qty: { type: number, example: 50 } + accepted_qty: { type: number, example: 48 } + rejected_qty: { type: number, example: 2 } + rejection_reason: { type: string, example: 'Damaged packaging' } + rate: { type: number, example: 250.5 } + batch_no: { type: string, example: BATCH-001 } + mfg_date: { type: string, format: date, nullable: true } + expiry_date: { type: string, format: date, nullable: true } + storage_location: { type: string, example: 'Rack A-12' } + remarks: { type: string, example: 'Partial receipt' } + asset_category_id: { type: integer, nullable: true, example: 1, description: Required when PO item is an asset item } + GrnCreateBody: + type: object + required: [grn_date, po_id, warehouse_id, items] + properties: + grn_date: { type: string, format: date, example: '2026-06-18' } + po_id: { type: integer, example: 1 } + warehouse_id: { type: integer, example: 1 } + vendor_invoice_no: { type: string, example: INV-2026-001 } + vendor_invoice_date: { type: string, format: date, nullable: true } + vendor_invoice_amount: { type: number, example: 12000 } + vehicle_no: { type: string, example: MH15AB1234 } + lr_no: { type: string, example: LR-7788 } + lr_date: { type: string, format: date, nullable: true } + received_by: { type: integer, nullable: true } + quality_checked_by: { type: integer, nullable: true } + remarks: { type: string, example: 'Received in good condition' } + items: + type: array + items: { $ref: '#/components/schemas/GrnItemBody' } + GrnUpdateBody: + type: object + minProperties: 1 + properties: + grn_date: { type: string, format: date } + warehouse_id: { type: integer } + vendor_invoice_no: { type: string } + vendor_invoice_date: { type: string, format: date, nullable: true } + vendor_invoice_amount: { type: number } + vehicle_no: { type: string } + lr_no: { type: string } + lr_date: { type: string, format: date, nullable: true } + received_by: { type: integer, nullable: true } + quality_checked_by: { type: integer, nullable: true } + remarks: { type: string } + GrnCancelBody: + type: object + required: [cancellation_reason] + properties: + cancellation_reason: { type: string, example: 'Posted against wrong PO' } + +paths: + /grn: + get: + tags: [GRN] + summary: List GRNs + parameters: + - { name: page, in: query, schema: { type: integer, example: 1 } } + - { name: limit, in: query, schema: { type: integer, example: 20 } } + - { name: search, in: query, schema: { type: string, example: 'GRN/2026' } } + - { name: status, in: query, schema: { type: string, enum: [POSTED, CANCELLED] } } + - { name: po_id, in: query, schema: { type: integer } } + - { name: vendor_id, in: query, schema: { type: integer } } + - { name: warehouse_id, in: query, schema: { type: integer } } + - { name: date_from, in: query, schema: { type: string, format: date } } + - { name: date_to, in: query, schema: { type: string, format: date } } + responses: + '200': + description: GRNs fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + post: + tags: [GRN] + summary: Create GRN (transactional PO receipt) + description: Posts GRN, updates PO received quantities, recalculates PO status, and auto-creates assets for asset items + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/GrnCreateBody' } + responses: + '201': + description: GRN created + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /grn/{id}: + get: + tags: [GRN] + summary: Get GRN detail + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: GRN fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + put: + tags: [GRN] + summary: Update GRN header (POSTED only, no line changes) + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/GrnUpdateBody' } + responses: + '200': + description: GRN updated + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /grn/{id}/cancel: + post: + tags: [GRN] + summary: Cancel GRN and reverse PO receipts + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/GrnCancelBody' } + responses: + '200': + description: GRN cancelled + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /grn/{id}/pdf: + get: + tags: [GRN] + summary: Download GRN PDF + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: PDF file + content: + application/pdf: + schema: { type: string, format: binary } diff --git a/src/docs/purchase-orders-routes.yaml b/src/docs/purchase-orders-routes.yaml new file mode 100644 index 0000000..bd51abb --- /dev/null +++ b/src/docs/purchase-orders-routes.yaml @@ -0,0 +1,243 @@ +tags: + - name: Purchase Orders + +components: + schemas: + PurchaseOrderItemBody: + type: object + required: [item_id, line_no, ordered_qty, uom_id, rate] + properties: + item_id: { type: integer, example: 1 } + line_no: { type: integer, example: 1 } + ordered_qty: { type: number, example: 100 } + uom_id: { type: integer, example: 1 } + rate: { type: number, example: 250.5 } + discount_pct: { type: number, example: 0 } + discount_amount: { type: number, example: 0 } + gst_rate_id: { type: integer, nullable: true, example: 1 } + hsn_code_id: { type: integer, nullable: true, example: 1 } + remarks: { type: string, example: 'Urgent line' } + PurchaseOrdersCreateBody: + type: object + required: [po_date, po_type, vendor_id, plant_id, items] + properties: + po_date: { type: string, format: date, example: '2026-06-18' } + po_type: + type: string + enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL] + example: RAW_MATERIAL + vendor_id: { type: integer, example: 1 } + plant_id: { type: integer, example: 1 } + warehouse_id: { type: integer, nullable: true, example: 1 } + brand_id: { type: integer, nullable: true, example: 1 } + payment_term_id: { type: integer, nullable: true, example: 1 } + delivery_term_id: { type: integer, nullable: true, example: 1 } + expected_delivery_date: { type: string, format: date, nullable: true, example: '2026-07-01' } + discount_amount: { type: number, example: 0 } + freight_charges: { type: number, example: 0 } + other_charges: { type: number, example: 0 } + terms_and_conditions: { type: string, example: 'Standard terms apply' } + remarks: { type: string, example: 'Monthly RM order' } + items: + type: array + items: { $ref: '#/components/schemas/PurchaseOrderItemBody' } + PurchaseOrdersUpdateBody: + type: object + minProperties: 1 + properties: + po_date: { type: string, format: date, example: '2026-06-18' } + po_type: + type: string + enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL] + vendor_id: { type: integer, example: 1 } + plant_id: { type: integer, example: 1 } + warehouse_id: { type: integer, nullable: true } + brand_id: { type: integer, nullable: true } + payment_term_id: { type: integer, nullable: true } + delivery_term_id: { type: integer, nullable: true } + expected_delivery_date: { type: string, format: date, nullable: true } + discount_amount: { type: number } + freight_charges: { type: number } + other_charges: { type: number } + terms_and_conditions: { type: string } + remarks: { type: string } + items: + type: array + items: { $ref: '#/components/schemas/PurchaseOrderItemBody' } + PurchaseOrdersWorkflowBody: + type: object + properties: + remarks: { type: string, example: 'Approved for procurement' } + PurchaseOrdersRejectBody: + type: object + required: [remarks] + properties: + remarks: { type: string, example: 'Rates not competitive' } + +paths: + /purchase-orders: + get: + tags: [Purchase Orders] + summary: List purchase orders + parameters: + - { name: page, in: query, schema: { type: integer, example: 1 } } + - { name: limit, in: query, schema: { type: integer, example: 20 } } + - { name: search, in: query, schema: { type: string, example: 'PO/2026' } } + - { name: status, in: query, schema: { type: string, example: DRAFT } } + - { name: po_type, in: query, schema: { type: string, example: RAW_MATERIAL } } + - { name: vendor_id, in: query, schema: { type: integer, example: 1 } } + - { name: plant_id, in: query, schema: { type: integer, example: 1 } } + - { name: date_from, in: query, schema: { type: string, format: date } } + - { name: date_to, in: query, schema: { type: string, format: date } } + responses: + '200': + description: Purchase orders fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + post: + tags: [Purchase Orders] + summary: Create purchase order + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersCreateBody' } + responses: + '201': + description: Purchase order created + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}: + get: + tags: [Purchase Orders] + summary: Get purchase order detail + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: Purchase order fetched + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + put: + tags: [Purchase Orders] + summary: Update purchase order (DRAFT/REJECTED only) + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersUpdateBody' } + responses: + '200': + description: Purchase order updated + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + delete: + tags: [Purchase Orders] + summary: Soft delete purchase order (DRAFT/REJECTED only) + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: Purchase order deleted + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}/submit: + post: + tags: [Purchase Orders] + summary: Submit PO for approval + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersWorkflowBody' } + responses: + '200': + description: Purchase order submitted + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}/approve: + post: + tags: [Purchase Orders] + summary: Approve purchase order + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersWorkflowBody' } + responses: + '200': + description: Purchase order approved + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}/reject: + post: + tags: [Purchase Orders] + summary: Reject purchase order + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersRejectBody' } + responses: + '200': + description: Purchase order rejected + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}/amend: + post: + tags: [Purchase Orders] + summary: Amend approved PO (creates new DRAFT revision) + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersUpdateBody' } + responses: + '201': + description: Amended purchase order created + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}/cancel: + post: + tags: [Purchase Orders] + summary: Cancel purchase order + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/PurchaseOrdersWorkflowBody' } + responses: + '200': + description: Purchase order cancelled + content: + application/json: + schema: { $ref: '#/components/schemas/ApiResponse' } + /purchase-orders/{id}/pdf: + get: + tags: [Purchase Orders] + summary: Download purchase order PDF + parameters: + - { name: id, in: path, required: true, schema: { type: string, example: '1' } } + responses: + '200': + description: PDF file + content: + application/pdf: + schema: { type: string, format: binary } diff --git a/src/docs/vendors-routes.yaml b/src/docs/vendors-routes.yaml new file mode 100644 index 0000000..4f838a4 --- /dev/null +++ b/src/docs/vendors-routes.yaml @@ -0,0 +1,411 @@ +tags: + - name: Vendors + +components: + schemas: + VendorsCreateBody: + type: object + required: [vendor_name, vendor_type] + properties: + vendor_name: { type: string, example: 'ABC Chemicals Pvt Ltd' } + vendor_type: + type: string + enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL] + example: RAW_MATERIAL + gstin: { type: string, example: '27ABCDE1234F1Z5' } + pan: { type: string, example: 'ABCDE1234F' } + payment_term_id: { type: integer, example: 1 } + credit_period_days: { type: integer, example: 30 } + remarks: { type: string, example: 'Preferred supplier' } + is_active: { type: boolean, example: true } + VendorsUpdateBody: + type: object + minProperties: 1 + properties: + vendor_name: { type: string, example: 'ABC Chemicals Pvt Ltd' } + vendor_type: + type: string + enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL] + gstin: { type: string, example: '27ABCDE1234F1Z5' } + pan: { type: string, example: 'ABCDE1234F' } + payment_term_id: { type: integer, example: 1 } + credit_period_days: { type: integer, example: 30 } + remarks: { type: string, example: 'Preferred supplier' } + is_active: { type: boolean, example: true } + VendorsStatusBody: + type: object + required: [status] + properties: + status: + type: string + enum: [active, inactive, blacklisted] + example: active + VendorAddressesCreateBody: + type: object + required: [address_type] + properties: + address_type: + type: string + enum: [REGISTERED, BILLING, DISPATCH] + example: REGISTERED + address_line1: { type: string, example: 'Plot 12, MIDC' } + address_line2: { type: string, example: 'Nashik Road' } + city: { type: string, example: 'Nashik' } + state: { type: string, example: 'Maharashtra' } + pincode: { type: string, example: '422001' } + country: { type: string, example: India } + gstin: { type: string, example: '27ABCDE1234F1Z5' } + is_active: { type: boolean, example: true } + VendorAddressesUpdateBody: + type: object + minProperties: 1 + properties: + address_type: + type: string + enum: [REGISTERED, BILLING, DISPATCH] + address_line1: { type: string, example: 'Plot 12, MIDC' } + address_line2: { type: string, example: 'Nashik Road' } + city: { type: string, example: 'Nashik' } + state: { type: string, example: 'Maharashtra' } + pincode: { type: string, example: '422001' } + country: { type: string, example: India } + gstin: { type: string, example: '27ABCDE1234F1Z5' } + is_active: { type: boolean, example: true } + VendorContactsCreateBody: + type: object + required: [contact_name] + properties: + contact_name: { type: string, example: 'Ramesh Kumar' } + designation: { type: string, example: 'Sales Manager' } + phone: { type: string, example: '9876543210' } + email: { type: string, format: email, example: 'ramesh@vendor.com' } + is_primary: { type: boolean, example: true } + is_active: { type: boolean, example: true } + VendorContactsUpdateBody: + type: object + minProperties: 1 + properties: + contact_name: { type: string, example: 'Ramesh Kumar' } + designation: { type: string, example: 'Sales Manager' } + phone: { type: string, example: '9876543210' } + email: { type: string, format: email, example: 'ramesh@vendor.com' } + is_primary: { type: boolean, example: true } + is_active: { type: boolean, example: true } + VendorBankDetailsCreateBody: + type: object + required: [bank_name, account_number, ifsc, account_holder_name] + properties: + bank_name: { type: string, example: 'State Bank of India' } + branch: { type: string, example: 'Nashik Main' } + account_number: { type: string, example: '123456789012' } + ifsc: { type: string, example: 'SBIN0001234' } + account_holder_name: { type: string, example: 'ABC Chemicals Pvt Ltd' } + account_type: + type: string + enum: [CURRENT, SAVINGS, OVERDRAFT] + example: CURRENT + is_primary: { type: boolean, example: true } + is_active: { type: boolean, example: true } + VendorBankDetailsUpdateBody: + type: object + minProperties: 1 + properties: + bank_name: { type: string, example: 'State Bank of India' } + branch: { type: string, example: 'Nashik Main' } + account_number: { type: string, example: '123456789012' } + ifsc: { type: string, example: 'SBIN0001234' } + account_holder_name: { type: string, example: 'ABC Chemicals Pvt Ltd' } + account_type: + type: string + enum: [CURRENT, SAVINGS, OVERDRAFT] + is_primary: { type: boolean, example: true } + is_active: { type: boolean, example: true } + +paths: + /vendors: + get: + tags: [Vendors] + summary: List vendors + parameters: + - in: query + name: page + schema: { type: integer, default: 1 } + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 100 } + - in: query + name: search + schema: { type: string } + - in: query + name: status + schema: { type: string, enum: [active, inactive, blacklisted] } + - in: query + name: vendor_type + schema: + type: string + enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL] + - in: query + name: is_active + schema: { type: boolean } + responses: + "200": + description: List fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + post: + tags: [Vendors] + summary: Create vendor (auto vendor_code from VENDOR series) + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorsCreateBody" } + responses: + "201": + description: Created + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + /vendors/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: Get vendor with addresses, contacts, bank details + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + put: + tags: [Vendors] + summary: Update vendor + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorsUpdateBody" } + responses: + "200": + description: Updated + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + delete: + tags: [Vendors] + summary: Delete vendor (soft delete) + responses: + "200": + description: Deleted + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + /vendors/{id}/status: + parameters: + - in: path + name: id + required: true + schema: { type: string } + patch: + tags: [Vendors] + summary: Change vendor status + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorsStatusBody" } + responses: + "200": + description: Status updated + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + /vendors/{vendorId}/addresses: + parameters: + - in: path + name: vendorId + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: List vendor addresses + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + post: + tags: [Vendors] + summary: Add vendor address + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorAddressesCreateBody" } + responses: + "201": + description: Created + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + /vendors/{vendorId}/addresses/{addressId}: + parameters: + - in: path + name: vendorId + required: true + schema: { type: string } + - in: path + name: addressId + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: Get vendor address + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + put: + tags: [Vendors] + summary: Update vendor address + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorAddressesUpdateBody" } + responses: + "200": + description: Updated + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + delete: + tags: [Vendors] + summary: Delete vendor address + responses: + "200": + description: Deleted + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + /vendors/{vendorId}/contacts: + parameters: + - in: path + name: vendorId + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: List vendor contacts + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + post: + tags: [Vendors] + summary: Add vendor contact + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorContactsCreateBody" } + responses: + "201": + description: Created + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + /vendors/{vendorId}/contacts/{contactId}: + parameters: + - in: path + name: vendorId + required: true + schema: { type: string } + - in: path + name: contactId + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: Get vendor contact + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + put: + tags: [Vendors] + summary: Update vendor contact + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorContactsUpdateBody" } + responses: + "200": + description: Updated + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + delete: + tags: [Vendors] + summary: Delete vendor contact + responses: + "200": + description: Deleted + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + /vendors/{vendorId}/bank-details: + parameters: + - in: path + name: vendorId + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: List vendor bank details + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + post: + tags: [Vendors] + summary: Add vendor bank detail (account number encrypted) + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorBankDetailsCreateBody" } + responses: + "201": + description: Created + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + /vendors/{vendorId}/bank-details/{bankDetailId}: + parameters: + - in: path + name: vendorId + required: true + schema: { type: string } + - in: path + name: bankDetailId + required: true + schema: { type: string } + get: + tags: [Vendors] + summary: Get vendor bank detail + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + put: + tags: [Vendors] + summary: Update vendor bank detail + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/VendorBankDetailsUpdateBody" } + responses: + "200": + description: Updated + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + delete: + tags: [Vendors] + summary: Delete vendor bank detail + responses: + "200": + description: Deleted + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } diff --git a/src/modules/assets/assets.constants.js b/src/modules/assets/assets.constants.js new file mode 100644 index 0000000..b7416d0 --- /dev/null +++ b/src/modules/assets/assets.constants.js @@ -0,0 +1,11 @@ +const ASSET_CONDITIONS = ['NEW', 'GOOD', 'FAIR', 'POOR']; +const ASSET_STATUSES = ['IN_USE', 'IDLE', 'UNDER_MAINTENANCE', 'DISPOSED', 'SCRAPPED']; +const DEPRECIATION_METHODS = ['SLM', 'WDV']; +const DISPOSAL_STATUSES = ['DISPOSED', 'SCRAPPED']; + +module.exports = { + ASSET_CONDITIONS, + ASSET_STATUSES, + DEPRECIATION_METHODS, + DISPOSAL_STATUSES, +}; diff --git a/src/modules/assets/assets.controller.js b/src/modules/assets/assets.controller.js new file mode 100644 index 0000000..e654bcc --- /dev/null +++ b/src/modules/assets/assets.controller.js @@ -0,0 +1,40 @@ +const asyncHandler = require('../../utils/asyncHandler'); +const ApiResponse = require('../../utils/ApiResponse'); +const service = require('./assets.service'); + +const create = asyncHandler(async (req, res) => { + const data = await service.createAsset(req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Asset created successfully')); +}); + +const list = asyncHandler(async (req, res) => { + const result = await service.listAssets(req.query); + res.json(new ApiResponse(200, result.data, 'Assets fetched', result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getAssetById(req.params.id); + res.json(new ApiResponse(200, data, 'Asset fetched')); +}); + +const update = asyncHandler(async (req, res) => { + const data = await service.updateAsset(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Asset updated successfully')); +}); + +const remove = asyncHandler(async (req, res) => { + await service.deleteAsset(req.params.id, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'Asset deleted successfully')); +}); + +const transfer = asyncHandler(async (req, res) => { + const data = await service.transferAsset(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Asset transferred successfully')); +}); + +const transferHistory = asyncHandler(async (req, res) => { + const data = await service.getTransferHistory(req.params.id); + res.json(new ApiResponse(200, data, 'Asset transfer history fetched')); +}); + +module.exports = { create, list, getOne, update, remove, transfer, transferHistory }; diff --git a/src/modules/assets/assets.repository.js b/src/modules/assets/assets.repository.js new file mode 100644 index 0000000..44ea3cb --- /dev/null +++ b/src/modules/assets/assets.repository.js @@ -0,0 +1,33 @@ +const prisma = require('../../config/prisma'); + +const transferAsset = async ({ assetId, transfer, updates, userId }) => + prisma.$transaction(async (tx) => { + const transferRow = await tx.asset_transfers.create({ + data: { + asset_id: BigInt(assetId), + transfer_date: transfer.transfer_date, + from_plant_id: transfer.from_plant_id, + to_plant_id: transfer.to_plant_id, + from_department_id: transfer.from_department_id, + to_department_id: transfer.to_department_id, + from_user_id: transfer.from_user_id, + to_user_id: transfer.to_user_id, + from_warehouse_id: transfer.from_warehouse_id, + to_warehouse_id: transfer.to_warehouse_id, + reason: transfer.reason, + transferred_by: userId ? BigInt(userId) : null, + }, + }); + + const updatedAsset = await tx.assets.update({ + where: { id: BigInt(assetId) }, + data: { + ...updates, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + return { transferRow, updatedAsset }; + }); + +module.exports = { transferAsset }; diff --git a/src/modules/assets/assets.routes.js b/src/modules/assets/assets.routes.js new file mode 100644 index 0000000..b16c170 --- /dev/null +++ b/src/modules/assets/assets.routes.js @@ -0,0 +1,37 @@ +const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); +const authorize = require('../../middlewares/rbac.middleware'); +const validate = require('../../middlewares/validate.middleware'); +const controller = require('./assets.controller'); +const { + createAssetSchema, + updateAssetSchema, + listAssetsQuerySchema, + transferAssetSchema, +} = require('./assets.validation'); + +const router = express.Router(); + +router.use(authenticate); + +router.get( + '/', + authorize('ASSET', 'view'), + validate(listAssetsQuerySchema, 'query'), + controller.list +); +router.post('/', authorize('ASSET', 'create'), validate(createAssetSchema), controller.create); + +router.get('/:id/transfer-history', authorize('ASSET', 'view'), controller.transferHistory); +router.post( + '/:id/transfer', + authorize('ASSET', 'edit'), + validate(transferAssetSchema), + controller.transfer +); + +router.get('/:id', authorize('ASSET', 'view'), controller.getOne); +router.put('/:id', authorize('ASSET', 'edit'), validate(updateAssetSchema), controller.update); +router.delete('/:id', authorize('ASSET', 'delete'), controller.remove); + +module.exports = router; diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js new file mode 100644 index 0000000..8fb9b38 --- /dev/null +++ b/src/modules/assets/assets.service.js @@ -0,0 +1,516 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { getPagination } = require('../../utils/pagination'); +const { nextDocumentNumber } = require('../../utils/generateCode'); +const { DISPOSAL_STATUSES } = require('./assets.constants'); +const repository = require('./assets.repository'); + +const SOFT_DELETE_TABLES = new Set([ + 'vendors', + 'plants', + 'warehouses', + 'departments', + 'users', + 'purchase_orders', + 'grn', + 'grn_items', + 'asset_categories', +]); + +const assetInclude = { + asset_categories: { select: { id: true, code: true, name: true, code_prefix: true } }, + plants: { select: { id: true, code: true, name: true } }, + departments: { select: { id: true, name: true } }, + warehouses: { select: { id: true, code: true, name: true } }, + users_assets_assigned_to_user_idTousers: { + select: { id: true, full_name: true, employee_code: true }, + }, + vendors_assets_vendor_idTovendors: { select: { id: true, vendor_code: true, vendor_name: true } }, + purchase_orders: { select: { id: true, po_number: true } }, + grn: { select: { id: true, grn_number: true } }, + users_assets_created_byTousers: { select: { id: true, full_name: true } }, +}; + +const assetDetailInclude = { + ...assetInclude, + vendors_assets_amc_vendor_idTovendors: { + select: { id: true, vendor_code: true, vendor_name: true }, + }, + users_assets_updated_byTousers: { select: { id: true, full_name: true } }, +}; + +const transferInclude = { + plants_asset_transfers_from_plant_idToplants: { select: { id: true, code: true, name: true } }, + plants_asset_transfers_to_plant_idToplants: { select: { id: true, code: true, name: true } }, + departments_asset_transfers_from_department_idTodepartments: { select: { id: true, name: true } }, + departments_asset_transfers_to_department_idTodepartments: { select: { id: true, name: true } }, + users_asset_transfers_from_user_idTousers: { select: { id: true, full_name: true } }, + users_asset_transfers_to_user_idTousers: { select: { id: true, full_name: true } }, + warehouses_asset_transfers_from_warehouse_idTowarehouses: { + select: { id: true, code: true, name: true }, + }, + warehouses_asset_transfers_to_warehouse_idTowarehouses: { + select: { id: true, code: true, name: true }, + }, + users_asset_transfers_transferred_byTousers: { select: { id: true, full_name: true } }, +}; + +const toDateOnly = (value) => { + if (!value) return null; + const date = new Date(value); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +}; + +const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`; + +const sanitizeAsset = (asset) => { + if (!asset) return null; + const { + asset_categories, + plants, + departments, + warehouses, + users_assets_assigned_to_user_idTousers, + vendors_assets_vendor_idTovendors, + vendors_assets_amc_vendor_idTovendors, + purchase_orders, + grn, + users_assets_created_byTousers, + users_assets_updated_byTousers, + ...rest + } = asset; + + return { + ...rest, + asset_category: asset_categories || null, + plant: plants || null, + department: departments || null, + warehouse: warehouses || null, + assigned_to_user: users_assets_assigned_to_user_idTousers || null, + vendor: vendors_assets_vendor_idTovendors || null, + amc_vendor: vendors_assets_amc_vendor_idTovendors || null, + purchase_order: purchase_orders || null, + grn: grn || null, + created_by_user: users_assets_created_byTousers || null, + updated_by_user: users_assets_updated_byTousers || null, + asset_categories: undefined, + plants: undefined, + departments: undefined, + warehouses: undefined, + users_assets_assigned_to_user_idTousers: undefined, + vendors_assets_vendor_idTovendors: undefined, + vendors_assets_amc_vendor_idTovendors: undefined, + purchase_orders: undefined, + users_assets_created_byTousers: undefined, + users_assets_updated_byTousers: undefined, + }; +}; + +const sanitizeTransfer = (row) => { + if (!row) return null; + const { + plants_asset_transfers_from_plant_idToplants, + plants_asset_transfers_to_plant_idToplants, + departments_asset_transfers_from_department_idTodepartments, + departments_asset_transfers_to_department_idTodepartments, + users_asset_transfers_from_user_idTousers, + users_asset_transfers_to_user_idTousers, + warehouses_asset_transfers_from_warehouse_idTowarehouses, + warehouses_asset_transfers_to_warehouse_idTowarehouses, + users_asset_transfers_transferred_byTousers, + ...rest + } = row; + + return { + ...rest, + from_plant: plants_asset_transfers_from_plant_idToplants || null, + to_plant: plants_asset_transfers_to_plant_idToplants || null, + from_department: departments_asset_transfers_from_department_idTodepartments || null, + to_department: departments_asset_transfers_to_department_idTodepartments || null, + from_user: users_asset_transfers_from_user_idTousers || null, + to_user: users_asset_transfers_to_user_idTousers || null, + from_warehouse: warehouses_asset_transfers_from_warehouse_idTowarehouses || null, + to_warehouse: warehouses_asset_transfers_to_warehouse_idTowarehouses || null, + transferred_by_user: users_asset_transfers_transferred_byTousers || null, + }; +}; + +const assertReference = async (table, id, label, { requireActive = true } = {}) => { + if (!id) return null; + const row = await prisma[table].findFirst({ + where: { + id: BigInt(id), + ...(SOFT_DELETE_TABLES.has(table) ? { deleted_at: null } : {}), + }, + }); + if (!row) throw new ApiError(422, `Invalid ${label}`); + if (requireActive && row.is_active === false) throw new ApiError(422, `${label} is inactive`); + return row; +}; + +const assertDisposalFields = (status, disposalDate) => { + if (DISPOSAL_STATUSES.includes(status) && !disposalDate) { + throw new ApiError(422, 'disposal_date is required when status is DISPOSED or SCRAPPED'); + } +}; + +const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { + const category = await assertReference( + 'asset_categories', + payload.asset_category_id, + 'asset_category_id' + ); + await assertReference('plants', payload.plant_id, 'plant_id'); + if (payload.department_id) + await assertReference('departments', payload.department_id, 'department_id'); + if (payload.warehouse_id) + await assertReference('warehouses', payload.warehouse_id, 'warehouse_id'); + if (payload.assigned_to_user_id) + await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id'); + if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id'); + if (payload.amc_vendor_id) + await assertReference('vendors', payload.amc_vendor_id, 'amc_vendor_id'); + if (payload.po_id) await assertReference('purchase_orders', payload.po_id, 'po_id'); + if (payload.grn_id) await assertReference('grn', payload.grn_id, 'grn_id'); + if (payload.grn_item_id) + await assertReference('grn_items', payload.grn_item_id, 'grn_item_id', { + requireActive: false, + }); + + const status = payload.status ?? 'IN_USE'; + const disposalDate = payload.disposal_date ? toDateOnly(payload.disposal_date) : null; + assertDisposalFields(status, disposalDate); + + return { + asset_name: payload.asset_name, + asset_category_id: BigInt(payload.asset_category_id), + brand_model: payload.brand_model || null, + manufacturer: payload.manufacturer || null, + serial_number: payload.serial_number || null, + part_number: payload.part_number || null, + plant_id: BigInt(payload.plant_id), + department_id: payload.department_id ? BigInt(payload.department_id) : null, + warehouse_id: payload.warehouse_id ? BigInt(payload.warehouse_id) : null, + location_detail: payload.location_detail || null, + assigned_to_user_id: payload.assigned_to_user_id ? BigInt(payload.assigned_to_user_id) : null, + vendor_id: payload.vendor_id ? BigInt(payload.vendor_id) : null, + po_id: payload.po_id ? BigInt(payload.po_id) : null, + grn_id: payload.grn_id ? BigInt(payload.grn_id) : null, + grn_item_id: payload.grn_item_id ? BigInt(payload.grn_item_id) : null, + purchase_date: payload.purchase_date ? toDateOnly(payload.purchase_date) : null, + purchase_cost: payload.purchase_cost ?? 0, + useful_life_years: + payload.useful_life_years !== undefined && payload.useful_life_years !== null + ? payload.useful_life_years + : category.default_useful_life_years, + depreciation_method: + payload.depreciation_method || category.default_depreciation_method || null, + salvage_value: payload.salvage_value ?? 0, + warranty_expiry_date: payload.warranty_expiry_date + ? toDateOnly(payload.warranty_expiry_date) + : null, + amc_start_date: payload.amc_start_date ? toDateOnly(payload.amc_start_date) : null, + amc_end_date: payload.amc_end_date ? toDateOnly(payload.amc_end_date) : null, + amc_vendor_id: payload.amc_vendor_id ? BigInt(payload.amc_vendor_id) : null, + insurance_policy_no: payload.insurance_policy_no || null, + insurance_expiry_date: payload.insurance_expiry_date + ? toDateOnly(payload.insurance_expiry_date) + : null, + condition: payload.condition ?? (isCreate ? 'NEW' : undefined), + status, + qr_code_value: payload.qr_code_value || null, + disposal_date: disposalDate, + disposal_reason: payload.disposal_reason || null, + disposal_value: payload.disposal_value ?? null, + remarks: payload.remarks || null, + is_active: payload.is_active ?? (isCreate ? true : undefined), + category, + }; +}; + +const getAssetOrThrow = async (id) => { + const asset = await prisma.assets.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + include: assetDetailInclude, + }); + if (!asset) throw new ApiError(404, 'Asset not found'); + return asset; +}; + +const createAsset = async (payload, userId, requestId) => { + const normalized = await normalizeAssetPayload(payload, { isCreate: true }); + const { category, ...data } = normalized; + void category; + + const assetCode = await nextDocumentNumber(assetSeriesCode(normalized.category.code)); + const created = await prisma.assets.create({ + data: { + ...data, + asset_code: assetCode, + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + include: assetDetailInclude, + }); + + await auditLog({ + tableName: 'assets', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeAsset(created), + userId, + requestId, + }); + + return sanitizeAsset(created); +}; + +const listAssets = async (query) => { + const { page, limit, skip } = getPagination(query); + const where = { + deleted_at: null, + ...(query.status ? { status: query.status } : {}), + ...(query.condition ? { condition: query.condition } : {}), + ...(query.asset_category_id ? { asset_category_id: BigInt(query.asset_category_id) } : {}), + ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), + ...(query.department_id ? { department_id: BigInt(query.department_id) } : {}), + ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), + ...(query.search + ? { + OR: [ + { asset_code: { contains: query.search, mode: 'insensitive' } }, + { asset_name: { contains: query.search, mode: 'insensitive' } }, + { serial_number: { contains: query.search, mode: 'insensitive' } }, + { qr_code_value: { contains: query.search, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.assets.findMany({ + where, + include: assetInclude, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.assets.count({ where }), + ]); + + return { data: rows.map(sanitizeAsset), meta: { page, limit, total } }; +}; + +const getAssetById = async (id) => sanitizeAsset(await getAssetOrThrow(id)); + +const updateAsset = async (id, payload, userId, requestId) => { + const existing = await getAssetOrThrow(id); + const merged = { + asset_name: payload.asset_name ?? existing.asset_name, + asset_category_id: payload.asset_category_id ?? existing.asset_category_id, + brand_model: payload.brand_model !== undefined ? payload.brand_model : existing.brand_model, + manufacturer: payload.manufacturer !== undefined ? payload.manufacturer : existing.manufacturer, + serial_number: + payload.serial_number !== undefined ? payload.serial_number : existing.serial_number, + part_number: payload.part_number !== undefined ? payload.part_number : existing.part_number, + plant_id: payload.plant_id ?? existing.plant_id, + department_id: + payload.department_id !== undefined ? payload.department_id : existing.department_id, + warehouse_id: payload.warehouse_id !== undefined ? payload.warehouse_id : existing.warehouse_id, + location_detail: + payload.location_detail !== undefined ? payload.location_detail : existing.location_detail, + assigned_to_user_id: + payload.assigned_to_user_id !== undefined + ? payload.assigned_to_user_id + : existing.assigned_to_user_id, + vendor_id: payload.vendor_id !== undefined ? payload.vendor_id : existing.vendor_id, + po_id: payload.po_id !== undefined ? payload.po_id : existing.po_id, + grn_id: payload.grn_id !== undefined ? payload.grn_id : existing.grn_id, + grn_item_id: payload.grn_item_id !== undefined ? payload.grn_item_id : existing.grn_item_id, + purchase_date: + payload.purchase_date !== undefined ? payload.purchase_date : existing.purchase_date, + purchase_cost: payload.purchase_cost ?? Number(existing.purchase_cost), + useful_life_years: + payload.useful_life_years !== undefined + ? payload.useful_life_years + : existing.useful_life_years, + depreciation_method: + payload.depreciation_method !== undefined + ? payload.depreciation_method + : existing.depreciation_method, + salvage_value: payload.salvage_value ?? Number(existing.salvage_value ?? 0), + warranty_expiry_date: + payload.warranty_expiry_date !== undefined + ? payload.warranty_expiry_date + : existing.warranty_expiry_date, + amc_start_date: + payload.amc_start_date !== undefined ? payload.amc_start_date : existing.amc_start_date, + amc_end_date: payload.amc_end_date !== undefined ? payload.amc_end_date : existing.amc_end_date, + amc_vendor_id: + payload.amc_vendor_id !== undefined ? payload.amc_vendor_id : existing.amc_vendor_id, + insurance_policy_no: + payload.insurance_policy_no !== undefined + ? payload.insurance_policy_no + : existing.insurance_policy_no, + insurance_expiry_date: + payload.insurance_expiry_date !== undefined + ? payload.insurance_expiry_date + : existing.insurance_expiry_date, + condition: payload.condition ?? existing.condition, + status: payload.status ?? existing.status, + qr_code_value: + payload.qr_code_value !== undefined ? payload.qr_code_value : existing.qr_code_value, + disposal_date: + payload.disposal_date !== undefined ? payload.disposal_date : existing.disposal_date, + disposal_reason: + payload.disposal_reason !== undefined ? payload.disposal_reason : existing.disposal_reason, + disposal_value: + payload.disposal_value !== undefined ? payload.disposal_value : existing.disposal_value, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + is_active: payload.is_active !== undefined ? payload.is_active : existing.is_active, + }; + + const normalized = await normalizeAssetPayload(merged); + const { category, ...data } = normalized; + void category; + + const updated = await prisma.assets.update({ + where: { id: BigInt(id) }, + data: { + ...data, + updated_by: userId ? BigInt(userId) : null, + }, + include: assetDetailInclude, + }); + + await auditLog({ + tableName: 'assets', + recordId: id, + action: 'UPDATE', + oldValue: sanitizeAsset(existing), + newValue: sanitizeAsset(updated), + userId, + requestId, + }); + + return sanitizeAsset(updated); +}; + +const deleteAsset = async (id, userId, requestId) => { + const existing = await getAssetOrThrow(id); + if (DISPOSAL_STATUSES.includes(existing.status)) { + throw new ApiError(409, 'Disposed or scrapped assets cannot be deleted'); + } + + const deleted = await prisma.assets.update({ + where: { id: BigInt(id) }, + data: { + deleted_at: new Date(), + is_active: false, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await auditLog({ + tableName: 'assets', + recordId: id, + action: 'DELETE', + oldValue: sanitizeAsset(existing), + newValue: deleted, + userId, + requestId, + }); +}; + +const transferAsset = async (id, payload, userId, requestId) => { + const existing = await getAssetOrThrow(id); + if (DISPOSAL_STATUSES.includes(existing.status)) { + throw new ApiError(409, 'Cannot transfer disposed or scrapped assets'); + } + + if (payload.to_plant_id) await assertReference('plants', payload.to_plant_id, 'to_plant_id'); + if (payload.to_department_id) { + await assertReference('departments', payload.to_department_id, 'to_department_id'); + } + if (payload.to_user_id) await assertReference('users', payload.to_user_id, 'to_user_id'); + if (payload.to_warehouse_id) { + await assertReference('warehouses', payload.to_warehouse_id, 'to_warehouse_id'); + } + + const transfer = { + transfer_date: toDateOnly(payload.transfer_date), + from_plant_id: existing.plant_id, + to_plant_id: payload.to_plant_id ? BigInt(payload.to_plant_id) : null, + from_department_id: existing.department_id, + to_department_id: payload.to_department_id ? BigInt(payload.to_department_id) : null, + from_user_id: existing.assigned_to_user_id, + to_user_id: payload.to_user_id ? BigInt(payload.to_user_id) : null, + from_warehouse_id: existing.warehouse_id, + to_warehouse_id: payload.to_warehouse_id ? BigInt(payload.to_warehouse_id) : null, + reason: payload.reason || null, + }; + + const updates = { + ...(payload.to_plant_id ? { plant_id: BigInt(payload.to_plant_id) } : {}), + ...(payload.to_department_id !== undefined + ? { department_id: payload.to_department_id ? BigInt(payload.to_department_id) : null } + : {}), + ...(payload.to_user_id !== undefined + ? { assigned_to_user_id: payload.to_user_id ? BigInt(payload.to_user_id) : null } + : {}), + ...(payload.to_warehouse_id !== undefined + ? { warehouse_id: payload.to_warehouse_id ? BigInt(payload.to_warehouse_id) : null } + : {}), + }; + + const { transferRow } = await repository.transferAsset({ + assetId: id, + transfer, + updates, + userId, + }); + + const detail = await prisma.asset_transfers.findFirst({ + where: { id: transferRow.id }, + include: transferInclude, + }); + + const updatedAsset = await getAssetOrThrow(id); + + await auditLog({ + tableName: 'asset_transfers', + recordId: transferRow.id, + action: 'TRANSFER', + oldValue: sanitizeAsset(existing), + newValue: { transfer: sanitizeTransfer(detail), asset: sanitizeAsset(updatedAsset) }, + userId, + requestId, + }); + + return { + asset: sanitizeAsset(updatedAsset), + transfer: sanitizeTransfer(detail), + }; +}; + +const getTransferHistory = async (id) => { + await getAssetOrThrow(id); + const rows = await prisma.asset_transfers.findMany({ + where: { asset_id: BigInt(id) }, + include: transferInclude, + orderBy: [{ transfer_date: 'desc' }, { created_at: 'desc' }], + }); + return rows.map(sanitizeTransfer); +}; + +module.exports = { + createAsset, + listAssets, + getAssetById, + updateAsset, + deleteAsset, + transferAsset, + getTransferHistory, +}; diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js new file mode 100644 index 0000000..5fb0858 --- /dev/null +++ b/src/modules/assets/assets.validation.js @@ -0,0 +1,117 @@ +const Joi = require('joi'); +const { ASSET_CONDITIONS, ASSET_STATUSES, DEPRECIATION_METHODS } = require('./assets.constants'); + +const assetFields = { + asset_name: Joi.string().max(200).required(), + asset_category_id: Joi.number().integer().positive().required(), + brand_model: Joi.string().max(200).allow(null, '').optional(), + manufacturer: Joi.string().max(200).allow(null, '').optional(), + serial_number: Joi.string().max(100).allow(null, '').optional(), + part_number: Joi.string().max(100).allow(null, '').optional(), + plant_id: Joi.number().integer().positive().required(), + department_id: Joi.number().integer().positive().allow(null).optional(), + warehouse_id: Joi.number().integer().positive().allow(null).optional(), + location_detail: Joi.string().max(200).allow(null, '').optional(), + assigned_to_user_id: Joi.number().integer().positive().allow(null).optional(), + vendor_id: Joi.number().integer().positive().allow(null).optional(), + po_id: Joi.number().integer().positive().allow(null).optional(), + grn_id: Joi.number().integer().positive().allow(null).optional(), + grn_item_id: Joi.number().integer().positive().allow(null).optional(), + purchase_date: Joi.date().iso().allow(null).optional(), + purchase_cost: Joi.number().min(0).default(0), + useful_life_years: Joi.number().integer().min(0).allow(null).optional(), + depreciation_method: Joi.string() + .valid(...DEPRECIATION_METHODS) + .allow(null) + .optional(), + salvage_value: Joi.number().min(0).allow(null).optional(), + warranty_expiry_date: Joi.date().iso().allow(null).optional(), + amc_start_date: Joi.date().iso().allow(null).optional(), + amc_end_date: Joi.date().iso().allow(null).optional(), + amc_vendor_id: Joi.number().integer().positive().allow(null).optional(), + insurance_policy_no: Joi.string().max(100).allow(null, '').optional(), + insurance_expiry_date: Joi.date().iso().allow(null).optional(), + condition: Joi.string() + .valid(...ASSET_CONDITIONS) + .default('NEW'), + status: Joi.string() + .valid(...ASSET_STATUSES) + .default('IN_USE'), + qr_code_value: Joi.string().max(100).allow(null, '').optional(), + disposal_date: Joi.date().iso().allow(null).optional(), + disposal_reason: Joi.string().allow(null, '').optional(), + disposal_value: Joi.number().min(0).allow(null).optional(), + remarks: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().default(true), +}; + +const createAssetSchema = Joi.object(assetFields); + +const updateAssetSchema = Joi.object({ + asset_name: assetFields.asset_name.optional(), + asset_category_id: assetFields.asset_category_id.optional(), + brand_model: assetFields.brand_model, + manufacturer: assetFields.manufacturer, + serial_number: assetFields.serial_number, + part_number: assetFields.part_number, + plant_id: assetFields.plant_id.optional(), + department_id: assetFields.department_id, + warehouse_id: assetFields.warehouse_id, + location_detail: assetFields.location_detail, + assigned_to_user_id: assetFields.assigned_to_user_id, + vendor_id: assetFields.vendor_id, + po_id: assetFields.po_id, + grn_id: assetFields.grn_id, + grn_item_id: assetFields.grn_item_id, + purchase_date: assetFields.purchase_date, + purchase_cost: assetFields.purchase_cost.optional(), + useful_life_years: assetFields.useful_life_years, + depreciation_method: assetFields.depreciation_method, + salvage_value: assetFields.salvage_value, + warranty_expiry_date: assetFields.warranty_expiry_date, + amc_start_date: assetFields.amc_start_date, + amc_end_date: assetFields.amc_end_date, + amc_vendor_id: assetFields.amc_vendor_id, + insurance_policy_no: assetFields.insurance_policy_no, + insurance_expiry_date: assetFields.insurance_expiry_date, + condition: assetFields.condition.optional(), + status: assetFields.status.optional(), + qr_code_value: assetFields.qr_code_value, + disposal_date: assetFields.disposal_date, + disposal_reason: assetFields.disposal_reason, + disposal_value: assetFields.disposal_value, + remarks: assetFields.remarks, + is_active: assetFields.is_active.optional(), +}).min(1); + +const listAssetsQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + search: Joi.string().allow('').optional(), + status: Joi.string() + .valid(...ASSET_STATUSES) + .optional(), + condition: Joi.string() + .valid(...ASSET_CONDITIONS) + .optional(), + asset_category_id: Joi.number().integer().positive().optional(), + plant_id: Joi.number().integer().positive().optional(), + department_id: Joi.number().integer().positive().optional(), + is_active: Joi.boolean().optional(), +}); + +const transferAssetSchema = Joi.object({ + transfer_date: Joi.date().iso().required(), + to_plant_id: Joi.number().integer().positive().allow(null).optional(), + to_department_id: Joi.number().integer().positive().allow(null).optional(), + to_user_id: Joi.number().integer().positive().allow(null).optional(), + to_warehouse_id: Joi.number().integer().positive().allow(null).optional(), + reason: Joi.string().allow(null, '').optional(), +}).or('to_plant_id', 'to_department_id', 'to_user_id', 'to_warehouse_id'); + +module.exports = { + createAssetSchema, + updateAssetSchema, + listAssetsQuerySchema, + transferAssetSchema, +}; diff --git a/src/modules/auth/auth.controller.js b/src/modules/auth/auth.controller.js index f3e81ee..5556676 100644 --- a/src/modules/auth/auth.controller.js +++ b/src/modules/auth/auth.controller.js @@ -17,4 +17,9 @@ const logout = asyncHandler(async (req, res) => { res.json(new ApiResponse(200, null, 'Logged out')); }); -module.exports = { login, refresh, logout }; +const me = asyncHandler(async (req, res) => { + const data = await authService.getMe(req.user.id); + res.json(new ApiResponse(200, data, 'Current user fetched')); +}); + +module.exports = { login, refresh, logout, me }; diff --git a/src/modules/auth/auth.routes.js b/src/modules/auth/auth.routes.js index 9e89421..d16221a 100644 --- a/src/modules/auth/auth.routes.js +++ b/src/modules/auth/auth.routes.js @@ -1,4 +1,5 @@ const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); const validate = require('../../middlewares/validate.middleware'); const { authLimiter } = require('../../middlewares/rateLimiter.middleware'); const { loginSchema, refreshSchema } = require('./auth.validation'); @@ -9,5 +10,6 @@ const router = express.Router(); router.post('/login', authLimiter, validate(loginSchema), controller.login); router.post('/refresh', validate(refreshSchema), controller.refresh); router.post('/logout', validate(refreshSchema), controller.logout); +router.get('/me', authenticate, controller.me); module.exports = router; diff --git a/src/modules/auth/auth.service.js b/src/modules/auth/auth.service.js index 3334f85..cf50155 100644 --- a/src/modules/auth/auth.service.js +++ b/src/modules/auth/auth.service.js @@ -4,6 +4,7 @@ const crypto = require('crypto'); const prisma = require('../../config/prisma'); const ApiError = require('../../utils/ApiError'); const env = require('../../config/env'); +const { decrypt } = require('../../utils/encryption'); const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex'); @@ -91,4 +92,61 @@ const logout = async (refreshToken) => { }); }; -module.exports = { login, refresh, logout }; +const buildPermissionList = (rolePermissions = []) => { + const codes = rolePermissions + .filter( + (rp) => + rp.permissions?.is_active && + rp.permissions?.modules?.is_active && + rp.permissions?.modules?.code && + rp.permissions?.action + ) + .map((rp) => `${rp.permissions.modules.code}:${rp.permissions.action}`); + + return [...new Set(codes)]; +}; + +const getMe = async (userId) => { + const user = await prisma.users.findFirst({ + where: { id: BigInt(userId), deleted_at: null }, + include: { + roles_users_role_idToroles: { + include: { + role_permissions: { + include: { + permissions: { include: { modules: true } }, + }, + }, + }, + }, + departments: { select: { id: true, name: true } }, + designations: { select: { id: true, name: true } }, + plants_users_plant_idToplants: { select: { id: true, code: true, name: true } }, + }, + }); + + if (!user || !user.is_active || user.status !== 'active') { + throw new ApiError(401, 'Invalid or inactive user'); + } + + const role = user.roles_users_role_idToroles; + const permissions = buildPermissionList(role?.role_permissions); + + return { + id: user.id, + employee_code: user.employee_code, + full_name: user.full_name, + email: user.email, + mobile: user.mobile ? decrypt(user.mobile) : null, + status: user.status, + is_active: user.is_active, + last_login_at: user.last_login_at, + role: role ? { id: role.id, name: role.name, description: role.description } : null, + department: user.departments, + designation: user.designations, + plant: user.plants_users_plant_idToplants, + permissions, + }; +}; + +module.exports = { login, refresh, logout, getMe }; diff --git a/src/modules/deployment/deployment.controller.js b/src/modules/deployment/deployment.controller.js new file mode 100644 index 0000000..72a2435 --- /dev/null +++ b/src/modules/deployment/deployment.controller.js @@ -0,0 +1,105 @@ +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const logger = require('../../config/logger'); +const env = require('../../config/env'); + +const getBranchNamesFromChanges = (changes = []) => { + const names = []; + for (const ch of changes) { + if (!ch || typeof ch !== 'object') continue; + if (ch.new && ch.new.name) names.push(ch.new.name); + if (ch.old && ch.old.name) names.push(ch.old.name); + } + return names; +}; + +const deployment = async (req, res) => { + try { + const providedKey = (req.query.apikey || '').toString(); + const bitbucketApiKey = env.BITBUCKET_API_KEY || ''; + const expectedRepo = env.EXPECTED_REPO; + const expectedBranch = env.EXPECTED_BRANCH; + const deployScript = env.DEPLOY_SCRIPT; + + if (!bitbucketApiKey) { + logger.info('Server misconfigured - API_KEY'); + return res.status(500).json({ ok: false, message: 'Server misconfigured - API_KEY' }); + } + + if (!expectedRepo) { + logger.info('Server misconfigured - REPO'); + return res.status(500).json({ ok: false, message: 'Server misconfigured - REPO ' }); + } + + if (!expectedBranch) { + logger.info('Server misconfigured - BRANCH'); + return res.status(500).json({ ok: false, message: 'Server misconfigured - BRANCH ' }); + } + + if (!deployScript) { + logger.info('Server misconfigured - Script'); + return res.status(500).json({ ok: false, message: 'Server misconfigured - Script ' }); + } + + if (!providedKey) { + logger.info('API key missing'); + return res.status(401).json({ ok: false, message: 'API key missing' }); + } + + if (providedKey !== bitbucketApiKey) { + logger.info('Invalid API key'); + return res.status(401).json({ ok: false, message: 'Invalid API key' }); + } + + const payload = req.body; + if (!payload || typeof payload !== 'object') { + logger.info('BAD_PAYLOAD: empty or invalid JSON body'); + return res.status(400).json({ ok: false, message: 'Invalid payload' }); + } + + const repoFullName = payload.repository && payload.repository.full_name; + if (repoFullName !== expectedRepo) { + logger.info('IGNORED: repo mismatch', { expected: expectedRepo, got: repoFullName }); + return res.status(200).json({ ok: true, action: 'ignored', reason: 'repository mismatch' }); + } + + const changes = (payload.push && payload.push.changes) || []; + const branchNames = getBranchNamesFromChanges(changes); + const hasExpectedBranch = branchNames.includes(expectedBranch); + + if (!hasExpectedBranch) { + logger.info('IGNORED: branch not expected', { + branches: branchNames, + expected: expectedBranch, + }); + return res.status(200).json({ ok: true, action: 'ignored', reason: 'branch not as exp' }); + } + + if (!fs.existsSync(deployScript)) { + logger.info('ERROR: deploy script not found', { deployScript }); + return res.status(500).json({ ok: false, message: 'Deploy script missing' }); + } + + logger.info('DEPLOY_TRIGGER', { + repo: repoFullName, + branch: expectedBranch, + script: deployScript, + }); + + const child = spawn(deployScript, [], { + cwd: path.dirname(deployScript), + env: process.env, + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + }); + child.unref(); + + return res.status(200).json({ ok: true, action: 'deploy_started', pid: child.pid }); + } catch (err) { + logger.error(err.message, { stack: err.stack }); + return res.status(500).send({ status: 'failed', message: 'Internal server error' }); + } +}; + +module.exports = { deployment }; diff --git a/src/modules/grn/grn.constants.js b/src/modules/grn/grn.constants.js new file mode 100644 index 0000000..e5c7d8c --- /dev/null +++ b/src/modules/grn/grn.constants.js @@ -0,0 +1,17 @@ +const GRN_STATUSES = ['POSTED', 'CANCELLED']; + +const RECEIVABLE_PO_STATUSES = ['APPROVED', 'SENT_TO_VENDOR', 'PARTIALLY_RECEIVED']; + +const PO_STATUSES_FROZEN_FOR_RECEIPT = [ + 'DRAFT', + 'PENDING_APPROVAL', + 'REJECTED', + 'CANCELLED', + 'CLOSED', +]; + +module.exports = { + GRN_STATUSES, + RECEIVABLE_PO_STATUSES, + PO_STATUSES_FROZEN_FOR_RECEIPT, +}; diff --git a/src/modules/grn/grn.controller.js b/src/modules/grn/grn.controller.js new file mode 100644 index 0000000..2cb5e16 --- /dev/null +++ b/src/modules/grn/grn.controller.js @@ -0,0 +1,37 @@ +const asyncHandler = require('../../utils/asyncHandler'); +const ApiResponse = require('../../utils/ApiResponse'); +const service = require('./grn.service'); + +const create = asyncHandler(async (req, res) => { + const data = await service.createGrn(req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'GRN created successfully')); +}); + +const list = asyncHandler(async (req, res) => { + const result = await service.listGrns(req.query); + res.json(new ApiResponse(200, result.data, 'GRNs fetched', result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getGrnById(req.params.id); + res.json(new ApiResponse(200, data, 'GRN fetched')); +}); + +const update = asyncHandler(async (req, res) => { + const data = await service.updateGrn(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'GRN updated successfully')); +}); + +const cancel = asyncHandler(async (req, res) => { + const data = await service.cancelGrn(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'GRN cancelled successfully')); +}); + +const pdf = asyncHandler(async (req, res) => { + const { filename, buffer } = await service.getGrnPdf(req.params.id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); +}); + +module.exports = { create, list, getOne, update, cancel, pdf }; diff --git a/src/modules/grn/grn.poStatus.js b/src/modules/grn/grn.poStatus.js new file mode 100644 index 0000000..ababe90 --- /dev/null +++ b/src/modules/grn/grn.poStatus.js @@ -0,0 +1,40 @@ +const { PO_STATUSES_FROZEN_FOR_RECEIPT } = require('./grn.constants'); + +const toNum = (value) => Number(value ?? 0); + +const recalculatePoStatus = async (tx, poId) => { + const po = await tx.purchase_orders.findFirst({ + where: { id: BigInt(poId), deleted_at: null }, + include: { purchase_order_items: true }, + }); + + if (!po || PO_STATUSES_FROZEN_FOR_RECEIPT.includes(po.status)) return po?.status; + + const items = po.purchase_order_items || []; + if (!items.length) return po.status; + + const allFullyReceived = items.every( + (line) => toNum(line.received_qty) >= toNum(line.ordered_qty) + ); + const anyReceived = items.some((line) => toNum(line.received_qty) > 0); + + let newStatus = po.status; + if (allFullyReceived) { + newStatus = 'FULLY_RECEIVED'; + } else if (anyReceived) { + newStatus = 'PARTIALLY_RECEIVED'; + } else if (['PARTIALLY_RECEIVED', 'FULLY_RECEIVED'].includes(po.status)) { + newStatus = 'APPROVED'; + } + + if (newStatus !== po.status) { + await tx.purchase_orders.update({ + where: { id: po.id }, + data: { status: newStatus }, + }); + } + + return newStatus; +}; + +module.exports = { recalculatePoStatus }; diff --git a/src/modules/grn/grn.repository.js b/src/modules/grn/grn.repository.js new file mode 100644 index 0000000..e0e25b1 --- /dev/null +++ b/src/modules/grn/grn.repository.js @@ -0,0 +1,125 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const { recalculatePoStatus } = require('./grn.poStatus'); + +const createAssetsForLine = async ( + tx, + { grn, grnItem, item, assetCategory, plantId, assetCodes, userId } +) => { + const createdAssets = []; + + for (let index = 0; index < assetCodes.length; index += 1) { + const asset = await tx.assets.create({ + data: { + asset_code: assetCodes[index], + asset_name: assetCodes.length > 1 ? `${item.item_name} #${index + 1}` : item.item_name, + asset_category_id: assetCategory.id, + plant_id: BigInt(plantId), + warehouse_id: grn.warehouse_id, + vendor_id: grn.vendor_id, + po_id: grn.po_id, + grn_id: grn.id, + grn_item_id: grnItem.id, + purchase_date: grn.grn_date, + purchase_cost: grnItem.rate, + useful_life_years: assetCategory.default_useful_life_years, + depreciation_method: assetCategory.default_depreciation_method, + condition: 'NEW', + status: 'IN_USE', + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + createdAssets.push(asset); + } + + return createdAssets; +}; + +const createGrnWithReceipt = async ({ grnNumber, header, items, assetPlans, userId }) => + prisma.$transaction(async (tx) => { + const grn = await tx.grn.create({ + data: { + ...header, + grn_number: grnNumber, + status: 'POSTED', + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + for (const item of items) { + const grnItem = await tx.grn_items.create({ + data: { ...item, grn_id: grn.id }, + }); + + await tx.purchase_order_items.update({ + where: { id: item.po_item_id }, + data: { received_qty: { increment: item.accepted_qty } }, + }); + + const plan = assetPlans.find( + (row) => row.po_item_id.toString() === item.po_item_id.toString() + ); + if (plan?.assetCodes?.length) { + await createAssetsForLine(tx, { + grn, + grnItem, + item: plan.item, + assetCategory: plan.assetCategory, + plantId: plan.plantId, + assetCodes: plan.assetCodes, + userId, + }); + } + } + + await recalculatePoStatus(tx, header.po_id); + return grn; + }); + +const cancelGrnWithReversal = async ({ grnId, cancellationReason, userId }) => + prisma.$transaction(async (tx) => { + const grn = await tx.grn.findFirst({ + where: { id: BigInt(grnId), deleted_at: null }, + include: { grn_items: true }, + }); + + if (!grn) throw new ApiError(404, 'GRN not found'); + if (grn.status !== 'POSTED') throw new ApiError(409, 'Only POSTED GRN can be cancelled'); + + for (const line of grn.grn_items) { + await tx.purchase_order_items.update({ + where: { id: line.po_item_id }, + data: { received_qty: { decrement: line.accepted_qty } }, + }); + } + + await tx.assets.updateMany({ + where: { grn_id: grn.id, deleted_at: null }, + data: { + deleted_at: new Date(), + is_active: false, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + const cancelled = await tx.grn.update({ + where: { id: grn.id }, + data: { + status: 'CANCELLED', + cancellation_reason: cancellationReason, + cancelled_by: userId ? BigInt(userId) : null, + cancelled_at: new Date(), + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await recalculatePoStatus(tx, grn.po_id); + return cancelled; + }); + +module.exports = { + createGrnWithReceipt, + cancelGrnWithReversal, +}; diff --git a/src/modules/grn/grn.routes.js b/src/modules/grn/grn.routes.js new file mode 100644 index 0000000..ce60b2b --- /dev/null +++ b/src/modules/grn/grn.routes.js @@ -0,0 +1,26 @@ +const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); +const authorize = require('../../middlewares/rbac.middleware'); +const validate = require('../../middlewares/validate.middleware'); +const controller = require('./grn.controller'); +const { + createGrnSchema, + updateGrnSchema, + listGrnQuerySchema, + cancelGrnSchema, +} = require('./grn.validation'); + +const router = express.Router(); + +router.use(authenticate); + +router.get('/', authorize('GRN', 'view'), validate(listGrnQuerySchema, 'query'), controller.list); +router.post('/', authorize('GRN', 'create'), validate(createGrnSchema), controller.create); + +router.post('/:id/cancel', authorize('GRN', 'edit'), validate(cancelGrnSchema), controller.cancel); +router.get('/:id/pdf', authorize('GRN', 'view'), controller.pdf); + +router.get('/:id', authorize('GRN', 'view'), controller.getOne); +router.put('/:id', authorize('GRN', 'edit'), validate(updateGrnSchema), controller.update); + +module.exports = router; diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js new file mode 100644 index 0000000..5b57f6a --- /dev/null +++ b/src/modules/grn/grn.service.js @@ -0,0 +1,432 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { getPagination } = require('../../utils/pagination'); +const { nextDocumentNumber } = require('../../utils/generateCode'); +const { buildSimplePdf } = require('../../utils/simplePdf'); +const repository = require('./grn.repository'); + +const grnListInclude = { + purchase_orders: { select: { id: true, po_number: true, status: true } }, + vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, + warehouses: { select: { id: true, code: true, name: true } }, + users_grn_received_byTousers: { select: { id: true, full_name: true } }, + users_grn_created_byTousers: { select: { id: true, full_name: true } }, +}; + +const grnDetailInclude = { + ...grnListInclude, + users_grn_quality_checked_byTousers: { select: { id: true, full_name: true } }, + users_grn_updated_byTousers: { select: { id: true, full_name: true } }, + users_grn_cancelled_byTousers: { select: { id: true, full_name: true } }, + grn_items: { + orderBy: { line_no: 'asc' }, + include: { + items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true } }, + uom: { select: { id: true, code: true, name: true } }, + purchase_order_items: { + select: { id: true, line_no: true, ordered_qty: true, received_qty: true }, + }, + }, + }, +}; + +const toDateOnly = (value) => { + if (!value) return null; + const date = new Date(value); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +}; + +const toNum = (value) => Number(value ?? 0); + +const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`; + +const sanitizeGrn = (row) => { + if (!row) return null; + const { + purchase_orders, + vendors, + warehouses, + users_grn_received_byTousers, + users_grn_created_byTousers, + users_grn_quality_checked_byTousers, + users_grn_updated_byTousers, + users_grn_cancelled_byTousers, + grn_items, + ...rest + } = row; + + return { + ...rest, + purchase_order: purchase_orders || null, + vendor: vendors || null, + warehouse: warehouses || null, + received_by_user: users_grn_received_byTousers || null, + created_by_user: users_grn_created_byTousers || null, + quality_checked_by_user: users_grn_quality_checked_byTousers || null, + updated_by_user: users_grn_updated_byTousers || null, + cancelled_by_user: users_grn_cancelled_byTousers || null, + items: (grn_items || []).map((line) => ({ + ...line, + item: line.items || null, + uom: line.uom || null, + po_item: line.purchase_order_items || null, + items: undefined, + purchase_order_items: undefined, + })), + grn_items: undefined, + purchase_orders: undefined, + vendors: undefined, + warehouses: undefined, + users_grn_received_byTousers: undefined, + users_grn_created_byTousers: undefined, + users_grn_quality_checked_byTousers: undefined, + users_grn_updated_byTousers: undefined, + users_grn_cancelled_byTousers: undefined, + }; +}; + +const getGrnOrThrow = async (id, { includeItems = false } = {}) => { + const row = await prisma.grn.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + include: includeItems ? grnDetailInclude : grnListInclude, + }); + if (!row) throw new ApiError(404, 'GRN not found'); + return row; +}; + +const getReceivablePoOrThrow = async (poId) => { + const po = await prisma.purchase_orders.findFirst({ + where: { id: BigInt(poId), deleted_at: null }, + include: { + purchase_order_items: { + include: { + items: { + select: { + id: true, + item_code: true, + item_name: true, + is_asset_item: true, + }, + }, + }, + }, + }, + }); + + if (!po) throw new ApiError(404, 'Purchase order not found'); + if (['DRAFT', 'PENDING_APPROVAL', 'REJECTED', 'CANCELLED', 'CLOSED'].includes(po.status)) { + throw new ApiError(409, `PO status ${po.status} is not open for receipt`); + } + + const hasPending = po.purchase_order_items.some( + (line) => toNum(line.received_qty) < toNum(line.ordered_qty) + ); + if (!hasPending) { + throw new ApiError(409, 'PO has no pending quantity to receive'); + } + + return po; +}; + +const validateAndBuildItems = async (po, payloadItems) => { + const poItemMap = new Map(po.purchase_order_items.map((line) => [line.id.toString(), line])); + const lineNos = payloadItems.map((row) => row.line_no); + if (new Set(lineNos).size !== lineNos.length) { + throw new ApiError(422, 'Duplicate line_no in GRN items'); + } + + const builtItems = []; + const assetPlans = []; + + for (const row of payloadItems) { + const poItem = poItemMap.get(String(row.po_item_id)); + if (!poItem) throw new ApiError(422, `po_item_id ${row.po_item_id} does not belong to this PO`); + + const currentQty = toNum(row.current_qty); + const acceptedQty = toNum(row.accepted_qty); + const rejectedQty = toNum(row.rejected_qty); + const previouslyReceived = toNum(poItem.received_qty); + const orderedQty = toNum(poItem.ordered_qty); + const pendingQty = orderedQty - previouslyReceived; + + if (Math.abs(acceptedQty + rejectedQty - currentQty) > 0.0001) { + throw new ApiError( + 422, + `Line ${row.line_no}: accepted_qty + rejected_qty must equal current_qty` + ); + } + if (rejectedQty > currentQty) { + throw new ApiError(422, `Line ${row.line_no}: rejected_qty cannot exceed current_qty`); + } + if (currentQty > pendingQty + 0.0001) { + throw new ApiError( + 422, + `Line ${row.line_no}: current_qty exceeds pending PO quantity (${pendingQty})` + ); + } + if (rejectedQty > 0 && !row.rejection_reason) { + throw new ApiError( + 422, + `Line ${row.line_no}: rejection_reason is required when rejected_qty > 0` + ); + } + + const item = poItem.items; + if (item.is_asset_item && acceptedQty > 0) { + if (!row.asset_category_id) { + throw new ApiError( + 422, + `Line ${row.line_no}: asset_category_id is required for asset items` + ); + } + + const assetCategory = await prisma.asset_categories.findFirst({ + where: { id: BigInt(row.asset_category_id), deleted_at: null, is_active: true }, + }); + if (!assetCategory) throw new ApiError(422, `Line ${row.line_no}: invalid asset_category_id`); + + const units = Math.floor(acceptedQty); + const assetCodes = []; + for (let unit = 0; unit < units; unit += 1) { + assetCodes.push(await nextDocumentNumber(assetSeriesCode(assetCategory.code))); + } + + assetPlans.push({ + po_item_id: poItem.id, + item, + assetCategory, + plantId: po.plant_id, + assetCodes, + }); + } + + builtItems.push({ + po_item_id: poItem.id, + item_id: poItem.item_id, + line_no: row.line_no, + ordered_qty: poItem.ordered_qty, + previously_received_qty: previouslyReceived, + current_qty: currentQty, + accepted_qty: acceptedQty, + rejected_qty: rejectedQty, + rejection_reason: row.rejection_reason || null, + uom_id: poItem.uom_id, + rate: row.rate !== undefined ? row.rate : poItem.rate, + batch_no: row.batch_no || null, + mfg_date: row.mfg_date ? toDateOnly(row.mfg_date) : null, + expiry_date: row.expiry_date ? toDateOnly(row.expiry_date) : null, + storage_location: row.storage_location || null, + remarks: row.remarks || null, + }); + } + + return { builtItems, assetPlans }; +}; + +const buildHeaderData = (payload, po, userId) => ({ + grn_date: toDateOnly(payload.grn_date), + po_id: po.id, + vendor_id: po.vendor_id, + warehouse_id: BigInt(payload.warehouse_id), + vendor_invoice_no: payload.vendor_invoice_no || null, + vendor_invoice_date: payload.vendor_invoice_date ? toDateOnly(payload.vendor_invoice_date) : null, + vendor_invoice_amount: payload.vendor_invoice_amount ?? null, + vehicle_no: payload.vehicle_no || null, + lr_no: payload.lr_no || null, + lr_date: payload.lr_date ? toDateOnly(payload.lr_date) : null, + received_by: payload.received_by ? BigInt(payload.received_by) : userId ? BigInt(userId) : null, + quality_checked_by: payload.quality_checked_by ? BigInt(payload.quality_checked_by) : null, + remarks: payload.remarks || null, +}); + +const createGrn = async (payload, userId, requestId) => { + const po = await getReceivablePoOrThrow(payload.po_id); + const warehouse = await prisma.warehouses.findFirst({ + where: { id: BigInt(payload.warehouse_id), deleted_at: null, is_active: true }, + }); + if (!warehouse) throw new ApiError(422, 'Invalid warehouse_id'); + + const { builtItems, assetPlans } = await validateAndBuildItems(po, payload.items); + const header = buildHeaderData(payload, po, userId); + const grnNumber = await nextDocumentNumber('GRN'); + + const created = await repository.createGrnWithReceipt({ + grnNumber, + header, + items: builtItems, + assetPlans, + userId, + }); + + const detail = await getGrnOrThrow(created.id, { includeItems: true }); + + await auditLog({ + tableName: 'grn', + recordId: detail.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizeGrn(detail), + userId, + requestId, + }); + + return sanitizeGrn(detail); +}; + +const listGrns = async (query) => { + const { page, limit, skip } = getPagination(query); + const where = { + deleted_at: null, + ...(query.status ? { status: query.status } : {}), + ...(query.po_id ? { po_id: BigInt(query.po_id) } : {}), + ...(query.vendor_id ? { vendor_id: BigInt(query.vendor_id) } : {}), + ...(query.warehouse_id ? { warehouse_id: BigInt(query.warehouse_id) } : {}), + ...(query.search ? { grn_number: { contains: query.search, mode: 'insensitive' } } : {}), + ...(query.date_from || query.date_to + ? { + grn_date: { + ...(query.date_from ? { gte: toDateOnly(query.date_from) } : {}), + ...(query.date_to ? { lte: toDateOnly(query.date_to) } : {}), + }, + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.grn.findMany({ + where, + include: grnListInclude, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.grn.count({ where }), + ]); + + return { data: rows.map(sanitizeGrn), meta: { page, limit, total } }; +}; + +const getGrnById = async (id) => sanitizeGrn(await getGrnOrThrow(id, { includeItems: true })); + +const updateGrn = async (id, payload, userId, requestId) => { + const existing = await getGrnOrThrow(id, { includeItems: true }); + if (existing.status !== 'POSTED') { + throw new ApiError(409, 'Only POSTED GRN can be updated'); + } + + if (payload.warehouse_id) { + const warehouse = await prisma.warehouses.findFirst({ + where: { id: BigInt(payload.warehouse_id), deleted_at: null, is_active: true }, + }); + if (!warehouse) throw new ApiError(422, 'Invalid warehouse_id'); + } + + const data = { + ...(payload.grn_date !== undefined ? { grn_date: toDateOnly(payload.grn_date) } : {}), + ...(payload.warehouse_id !== undefined ? { warehouse_id: BigInt(payload.warehouse_id) } : {}), + ...(payload.vendor_invoice_no !== undefined + ? { vendor_invoice_no: payload.vendor_invoice_no || null } + : {}), + ...(payload.vendor_invoice_date !== undefined + ? { + vendor_invoice_date: payload.vendor_invoice_date + ? toDateOnly(payload.vendor_invoice_date) + : null, + } + : {}), + ...(payload.vendor_invoice_amount !== undefined + ? { vendor_invoice_amount: payload.vendor_invoice_amount } + : {}), + ...(payload.vehicle_no !== undefined ? { vehicle_no: payload.vehicle_no || null } : {}), + ...(payload.lr_no !== undefined ? { lr_no: payload.lr_no || null } : {}), + ...(payload.lr_date !== undefined + ? { lr_date: payload.lr_date ? toDateOnly(payload.lr_date) : null } + : {}), + ...(payload.received_by !== undefined + ? { received_by: payload.received_by ? BigInt(payload.received_by) : null } + : {}), + ...(payload.quality_checked_by !== undefined + ? { + quality_checked_by: payload.quality_checked_by + ? BigInt(payload.quality_checked_by) + : null, + } + : {}), + ...(payload.remarks !== undefined ? { remarks: payload.remarks || null } : {}), + updated_by: userId ? BigInt(userId) : null, + }; + + const updated = await prisma.grn.update({ + where: { id: BigInt(id) }, + data, + include: grnDetailInclude, + }); + + await auditLog({ + tableName: 'grn', + recordId: id, + action: 'UPDATE', + oldValue: sanitizeGrn(existing), + newValue: sanitizeGrn(updated), + userId, + requestId, + }); + + return sanitizeGrn(updated); +}; + +const cancelGrn = async (id, payload, userId, requestId) => { + const existing = await getGrnOrThrow(id, { includeItems: true }); + await repository.cancelGrnWithReversal({ + grnId: id, + cancellationReason: payload.cancellation_reason, + userId, + }); + + const cancelled = await getGrnOrThrow(id, { includeItems: true }); + + await auditLog({ + tableName: 'grn', + recordId: id, + action: 'CANCEL', + oldValue: sanitizeGrn(existing), + newValue: sanitizeGrn(cancelled), + userId, + requestId, + }); + + return sanitizeGrn(cancelled); +}; + +const getGrnPdf = async (id) => { + const grn = sanitizeGrn(await getGrnOrThrow(id, { includeItems: true })); + const lines = [ + `GRN: ${grn.grn_number}`, + `Date: ${grn.grn_date ? new Date(grn.grn_date).toISOString().slice(0, 10) : '-'}`, + `Status: ${grn.status}`, + `PO: ${grn.purchase_order?.po_number || '-'}`, + `Vendor: ${grn.vendor?.vendor_name || '-'}`, + `Warehouse: ${grn.warehouse?.name || '-'}`, + '', + 'Line Items:', + ...grn.items.map( + (line) => + `${line.line_no}. ${line.item?.item_name || line.item_id} | Accepted ${line.accepted_qty} / Current ${line.current_qty}` + ), + ]; + + return { + filename: `${grn.grn_number.replace(/\//g, '-')}.pdf`, + buffer: buildSimplePdf(lines), + }; +}; + +module.exports = { + createGrn, + listGrns, + getGrnById, + updateGrn, + cancelGrn, + getGrnPdf, +}; diff --git a/src/modules/grn/grn.validation.js b/src/modules/grn/grn.validation.js new file mode 100644 index 0000000..51fe203 --- /dev/null +++ b/src/modules/grn/grn.validation.js @@ -0,0 +1,73 @@ +const Joi = require('joi'); +const { GRN_STATUSES } = require('./grn.constants'); + +const grnItemSchema = Joi.object({ + po_item_id: Joi.number().integer().positive().required(), + line_no: Joi.number().integer().min(1).required(), + current_qty: Joi.number().positive().required(), + accepted_qty: Joi.number().min(0).required(), + rejected_qty: Joi.number().min(0).default(0), + rejection_reason: Joi.string().allow(null, '').optional(), + rate: Joi.number().min(0).optional(), + batch_no: Joi.string().max(100).allow(null, '').optional(), + mfg_date: Joi.date().iso().allow(null).optional(), + expiry_date: Joi.date().iso().allow(null).optional(), + storage_location: Joi.string().max(100).allow(null, '').optional(), + remarks: Joi.string().allow(null, '').optional(), + asset_category_id: Joi.number().integer().positive().allow(null).optional(), +}); + +const createGrnSchema = Joi.object({ + grn_date: Joi.date().iso().required(), + po_id: Joi.number().integer().positive().required(), + warehouse_id: Joi.number().integer().positive().required(), + vendor_invoice_no: Joi.string().max(100).allow(null, '').optional(), + vendor_invoice_date: Joi.date().iso().allow(null).optional(), + vendor_invoice_amount: Joi.number().min(0).allow(null).optional(), + vehicle_no: Joi.string().max(30).allow(null, '').optional(), + lr_no: Joi.string().max(50).allow(null, '').optional(), + lr_date: Joi.date().iso().allow(null).optional(), + received_by: Joi.number().integer().positive().allow(null).optional(), + quality_checked_by: Joi.number().integer().positive().allow(null).optional(), + remarks: Joi.string().allow(null, '').optional(), + items: Joi.array().items(grnItemSchema).min(1).required(), +}); + +const updateGrnSchema = Joi.object({ + grn_date: Joi.date().iso().optional(), + warehouse_id: Joi.number().integer().positive().optional(), + vendor_invoice_no: Joi.string().max(100).allow(null, '').optional(), + vendor_invoice_date: Joi.date().iso().allow(null).optional(), + vendor_invoice_amount: Joi.number().min(0).allow(null).optional(), + vehicle_no: Joi.string().max(30).allow(null, '').optional(), + lr_no: Joi.string().max(50).allow(null, '').optional(), + lr_date: Joi.date().iso().allow(null).optional(), + received_by: Joi.number().integer().positive().allow(null).optional(), + quality_checked_by: Joi.number().integer().positive().allow(null).optional(), + remarks: Joi.string().allow(null, '').optional(), +}).min(1); + +const listGrnQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + search: Joi.string().allow('').optional(), + status: Joi.string() + .valid(...GRN_STATUSES) + .optional(), + po_id: Joi.number().integer().positive().optional(), + vendor_id: Joi.number().integer().positive().optional(), + warehouse_id: Joi.number().integer().positive().optional(), + date_from: Joi.date().iso().optional(), + date_to: Joi.date().iso().optional(), +}); + +const cancelGrnSchema = Joi.object({ + cancellation_reason: Joi.string().trim().min(1).required(), +}); + +module.exports = { + createGrnSchema, + updateGrnSchema, + listGrnQuerySchema, + cancelGrnSchema, +}; diff --git a/src/modules/purchase-orders/purchase-orders.calculations.js b/src/modules/purchase-orders/purchase-orders.calculations.js new file mode 100644 index 0000000..d26d7c8 --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.calculations.js @@ -0,0 +1,47 @@ +const toNum = (value) => Number(value ?? 0); +const round4 = (value) => Math.round(toNum(value) * 10000) / 10000; + +const computeLineAmounts = (line, gstRatePct = 0) => { + const orderedQty = toNum(line.ordered_qty); + const rate = toNum(line.rate); + const gross = round4(orderedQty * rate); + + let discountAmount = toNum(line.discount_amount); + const discountPct = toNum(line.discount_pct); + if (discountPct > 0) { + discountAmount = round4((gross * discountPct) / 100); + } + + const taxableAmount = round4(Math.max(gross - discountAmount, 0)); + const taxAmount = round4((taxableAmount * toNum(gstRatePct)) / 100); + const lineTotal = round4(taxableAmount + taxAmount); + + return { + discount_amount: discountAmount, + taxable_amount: taxableAmount, + tax_amount: taxAmount, + line_total: lineTotal, + }; +}; + +const computeHeaderTotals = (lines, headerDiscount = 0, freight = 0, other = 0) => { + const subTotal = round4(lines.reduce((sum, line) => sum + toNum(line.taxable_amount), 0)); + const taxTotal = round4(lines.reduce((sum, line) => sum + toNum(line.tax_amount), 0)); + const headerDiscountAmount = round4(headerDiscount); + const freightCharges = round4(freight); + const otherCharges = round4(other); + const grandTotal = round4( + subTotal + taxTotal + freightCharges + otherCharges - headerDiscountAmount + ); + + return { + sub_total: subTotal, + tax_total: taxTotal, + discount_amount: headerDiscountAmount, + freight_charges: freightCharges, + other_charges: otherCharges, + grand_total: grandTotal, + }; +}; + +module.exports = { computeLineAmounts, computeHeaderTotals, round4, toNum }; diff --git a/src/modules/purchase-orders/purchase-orders.constants.js b/src/modules/purchase-orders/purchase-orders.constants.js new file mode 100644 index 0000000..49cb6fd --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.constants.js @@ -0,0 +1,37 @@ +const PO_TYPES = ['RAW_MATERIAL', 'PACKING_MATERIAL', 'ASSET_CAPITAL', 'SERVICE', 'GENERAL']; + +const PO_STATUSES = [ + 'DRAFT', + 'PENDING_APPROVAL', + 'APPROVED', + 'REJECTED', + 'SENT_TO_VENDOR', + 'PARTIALLY_RECEIVED', + 'FULLY_RECEIVED', + 'CLOSED', + 'CANCELLED', +]; + +const EDITABLE_STATUSES = ['DRAFT', 'REJECTED']; +const SUBMITTABLE_STATUSES = ['DRAFT', 'REJECTED']; +const APPROVABLE_STATUSES = ['PENDING_APPROVAL']; +const CANCELLABLE_STATUSES = [ + 'DRAFT', + 'PENDING_APPROVAL', + 'APPROVED', + 'REJECTED', + 'SENT_TO_VENDOR', +]; +const AMENDABLE_STATUSES = ['APPROVED', 'SENT_TO_VENDOR', 'PARTIALLY_RECEIVED']; +const DELETABLE_STATUSES = ['DRAFT', 'REJECTED']; + +module.exports = { + PO_TYPES, + PO_STATUSES, + EDITABLE_STATUSES, + SUBMITTABLE_STATUSES, + APPROVABLE_STATUSES, + CANCELLABLE_STATUSES, + AMENDABLE_STATUSES, + DELETABLE_STATUSES, +}; diff --git a/src/modules/purchase-orders/purchase-orders.controller.js b/src/modules/purchase-orders/purchase-orders.controller.js new file mode 100644 index 0000000..cc786a0 --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.controller.js @@ -0,0 +1,74 @@ +const asyncHandler = require('../../utils/asyncHandler'); +const ApiResponse = require('../../utils/ApiResponse'); +const service = require('./purchase-orders.service'); + +const create = asyncHandler(async (req, res) => { + const data = await service.createPurchaseOrder(req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Purchase order created successfully')); +}); + +const list = asyncHandler(async (req, res) => { + const result = await service.listPurchaseOrders(req.query); + res.json(new ApiResponse(200, result.data, 'Purchase orders fetched', result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getPurchaseOrderById(req.params.id); + res.json(new ApiResponse(200, data, 'Purchase order fetched')); +}); + +const update = asyncHandler(async (req, res) => { + const data = await service.updatePurchaseOrder(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Purchase order updated successfully')); +}); + +const remove = asyncHandler(async (req, res) => { + await service.deletePurchaseOrder(req.params.id, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'Purchase order deleted successfully')); +}); + +const submit = asyncHandler(async (req, res) => { + const data = await service.submitPurchaseOrder(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Purchase order submitted for approval')); +}); + +const approve = asyncHandler(async (req, res) => { + const data = await service.approvePurchaseOrder(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Purchase order approved')); +}); + +const reject = asyncHandler(async (req, res) => { + const data = await service.rejectPurchaseOrder(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Purchase order rejected')); +}); + +const amend = asyncHandler(async (req, res) => { + const data = await service.amendPurchaseOrder(req.params.id, req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Purchase order amendment created')); +}); + +const cancel = asyncHandler(async (req, res) => { + const data = await service.cancelPurchaseOrder(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Purchase order cancelled')); +}); + +const pdf = asyncHandler(async (req, res) => { + const { filename, buffer } = await service.getPurchaseOrderPdf(req.params.id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); +}); + +module.exports = { + create, + list, + getOne, + update, + remove, + submit, + approve, + reject, + amend, + cancel, + pdf, +}; diff --git a/src/modules/purchase-orders/purchase-orders.repository.js b/src/modules/purchase-orders/purchase-orders.repository.js new file mode 100644 index 0000000..2d38e86 --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.repository.js @@ -0,0 +1,46 @@ +const prisma = require('../../config/prisma'); +const { nextDocumentNumber } = require('../../utils/generateCode'); + +const createPurchaseOrderWithItems = async ({ header, items }) => + prisma.$transaction(async (tx) => { + const po = await tx.purchase_orders.create({ data: header }); + + if (items.length) { + await tx.purchase_order_items.createMany({ + data: items.map((item) => ({ ...item, po_id: po.id })), + }); + } + + return po; + }); + +const replacePurchaseOrderItems = async (poId, items) => + prisma.$transaction(async (tx) => { + await tx.purchase_order_items.deleteMany({ where: { po_id: BigInt(poId) } }); + if (items.length) { + await tx.purchase_order_items.createMany({ data: items }); + } + }); + +const createAmendedPurchaseOrder = async ({ header, items }) => { + const poNumber = await nextDocumentNumber('PO'); + return prisma.$transaction(async (tx) => { + const po = await tx.purchase_orders.create({ + data: { ...header, po_number: poNumber }, + }); + + if (items.length) { + await tx.purchase_order_items.createMany({ + data: items.map((item) => ({ ...item, po_id: po.id })), + }); + } + + return po; + }); +}; + +module.exports = { + createPurchaseOrderWithItems, + replacePurchaseOrderItems, + createAmendedPurchaseOrder, +}; diff --git a/src/modules/purchase-orders/purchase-orders.routes.js b/src/modules/purchase-orders/purchase-orders.routes.js new file mode 100644 index 0000000..a0ccea4 --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.routes.js @@ -0,0 +1,73 @@ +const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); +const authorize = require('../../middlewares/rbac.middleware'); +const validate = require('../../middlewares/validate.middleware'); +const controller = require('./purchase-orders.controller'); +const { + createPurchaseOrderSchema, + updatePurchaseOrderSchema, + amendPurchaseOrderSchema, + listPurchaseOrdersQuerySchema, + workflowRemarksSchema, + rejectPurchaseOrderSchema, +} = require('./purchase-orders.validation'); + +const router = express.Router(); + +router.use(authenticate); + +router.get( + '/', + authorize('PURCHASE_ORDER', 'view'), + validate(listPurchaseOrdersQuerySchema, 'query'), + controller.list +); +router.post( + '/', + authorize('PURCHASE_ORDER', 'create'), + validate(createPurchaseOrderSchema), + controller.create +); + +router.post( + '/:id/submit', + authorize('PURCHASE_ORDER', 'edit'), + validate(workflowRemarksSchema), + controller.submit +); +router.post( + '/:id/approve', + authorize('PURCHASE_ORDER', 'approve'), + validate(workflowRemarksSchema), + controller.approve +); +router.post( + '/:id/reject', + authorize('PURCHASE_ORDER', 'approve'), + validate(rejectPurchaseOrderSchema), + controller.reject +); +router.post( + '/:id/amend', + authorize('PURCHASE_ORDER', 'edit'), + validate(amendPurchaseOrderSchema), + controller.amend +); +router.post( + '/:id/cancel', + authorize('PURCHASE_ORDER', 'edit'), + validate(workflowRemarksSchema), + controller.cancel +); +router.get('/:id/pdf', authorize('PURCHASE_ORDER', 'view'), controller.pdf); + +router.get('/:id', authorize('PURCHASE_ORDER', 'view'), controller.getOne); +router.put( + '/:id', + authorize('PURCHASE_ORDER', 'edit'), + validate(updatePurchaseOrderSchema), + controller.update +); +router.delete('/:id', authorize('PURCHASE_ORDER', 'delete'), controller.remove); + +module.exports = router; diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js new file mode 100644 index 0000000..9fdcdc5 --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -0,0 +1,682 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { getPagination } = require('../../utils/pagination'); +const { nextDocumentNumber } = require('../../utils/generateCode'); +const { buildSimplePdf } = require('../../utils/simplePdf'); +const { + EDITABLE_STATUSES, + SUBMITTABLE_STATUSES, + APPROVABLE_STATUSES, + CANCELLABLE_STATUSES, + AMENDABLE_STATUSES, + DELETABLE_STATUSES, +} = require('./purchase-orders.constants'); +const { computeLineAmounts, computeHeaderTotals } = require('./purchase-orders.calculations'); +const repository = require('./purchase-orders.repository'); + +const poListInclude = { + vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, + plants: { select: { id: true, code: true, name: true } }, + warehouses: { select: { id: true, code: true, name: true } }, + brands: { select: { id: true, code: true, name: true } }, + users_purchase_orders_created_byTousers: { select: { id: true, full_name: true } }, +}; + +const poDetailInclude = { + ...poListInclude, + payment_terms: { select: { id: true, code: true, name: true } }, + delivery_terms: { select: { id: true, code: true, name: true } }, + users_purchase_orders_updated_byTousers: { select: { id: true, full_name: true } }, + purchase_orders: { select: { id: true, po_number: true, revision_no: true } }, + purchase_order_items: { + orderBy: { line_no: 'asc' }, + include: { + items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true } }, + uom: { select: { id: true, code: true, name: true } }, + gst_rates: { select: { id: true, rate_pct: true, description: true } }, + hsn_codes: { select: { id: true, code: true, description: true } }, + }, + }, + po_approvals: { + orderBy: { approval_level: 'asc' }, + include: { + roles: { select: { id: true, name: true } }, + users: { select: { id: true, full_name: true } }, + }, + }, +}; + +const toDateOnly = (value) => { + if (!value) return null; + const date = new Date(value); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +}; + +const sanitizePo = (po) => { + if (!po) return null; + const { + vendors, + plants, + warehouses, + brands, + payment_terms, + delivery_terms, + users_purchase_orders_created_byTousers, + users_purchase_orders_updated_byTousers, + purchase_orders, + purchase_order_items, + po_approvals, + ...rest + } = po; + + return { + ...rest, + vendor: vendors || null, + plant: plants || null, + warehouse: warehouses || null, + brand: brands || null, + payment_term: payment_terms || null, + delivery_term: delivery_terms || null, + created_by_user: users_purchase_orders_created_byTousers || null, + updated_by_user: users_purchase_orders_updated_byTousers || null, + parent_po: purchase_orders || null, + items: (purchase_order_items || []).map((line) => ({ + ...line, + item: line.items || null, + uom: line.uom || null, + gst_rate: line.gst_rates || null, + hsn_code: line.hsn_codes || null, + items: undefined, + gst_rates: undefined, + hsn_codes: undefined, + })), + approvals: (po_approvals || []).map((row) => ({ + ...row, + approver_role: row.roles || null, + approver_user: row.users || null, + roles: undefined, + users: undefined, + })), + purchase_order_items: undefined, + po_approvals: undefined, + vendors: undefined, + plants: undefined, + warehouses: undefined, + brands: undefined, + payment_terms: undefined, + delivery_terms: undefined, + users_purchase_orders_created_byTousers: undefined, + users_purchase_orders_updated_byTousers: undefined, + purchase_orders: undefined, + }; +}; + +const assertStatus = (po, allowedStatuses, action) => { + if (!allowedStatuses.includes(po.status)) { + throw new ApiError(409, `Cannot ${action} PO in status ${po.status}`); + } +}; + +const SOFT_DELETE_TABLES = new Set([ + 'vendors', + 'plants', + 'warehouses', + 'payment_terms', + 'delivery_terms', + 'items', + 'uom', +]); + +const assertReference = async (table, id, label, { requireActive = true } = {}) => { + if (!id) return null; + const row = await prisma[table].findFirst({ + where: { + id: BigInt(id), + ...(SOFT_DELETE_TABLES.has(table) ? { deleted_at: null } : {}), + }, + }); + if (!row) throw new ApiError(422, `Invalid ${label}`); + if (requireActive && row.is_active === false) throw new ApiError(422, `${label} is inactive`); + return row; +}; + +const loadGstRateMap = async (itemRows) => { + const gstIds = [...new Set(itemRows.map((row) => row.gst_rate_id).filter(Boolean))]; + if (!gstIds.length) return new Map(); + + const rates = await prisma.gst_rates.findMany({ + where: { id: { in: gstIds.map((id) => BigInt(id)) }, is_active: true }, + }); + return new Map(rates.map((rate) => [rate.id.toString(), Number(rate.rate_pct)])); +}; + +const validateAndBuildItems = async (items) => { + const lineNos = items.map((row) => row.line_no); + if (new Set(lineNos).size !== lineNos.length) { + throw new ApiError(422, 'Duplicate line_no in items'); + } + + const gstRateMap = await loadGstRateMap(items); + const builtItems = []; + + for (const row of items) { + const item = await assertReference('items', row.item_id, 'item_id'); + await assertReference('uom', row.uom_id, 'uom_id'); + + if (row.hsn_code_id) + await assertReference('hsn_codes', row.hsn_code_id, 'hsn_code_id', { requireActive: false }); + + let gstRatePct = 0; + if (row.gst_rate_id) { + const gst = await prisma.gst_rates.findFirst({ + where: { id: BigInt(row.gst_rate_id), is_active: true }, + }); + if (!gst) throw new ApiError(422, 'Invalid gst_rate_id'); + gstRatePct = Number(gst.rate_pct); + } else if (item.gst_rate_id) { + const gst = await prisma.gst_rates.findFirst({ + where: { id: item.gst_rate_id, is_active: true }, + }); + gstRatePct = gst ? Number(gst.rate_pct) : 0; + } else if (gstRateMap.has(String(row.gst_rate_id))) { + gstRatePct = gstRateMap.get(String(row.gst_rate_id)); + } + + const amounts = computeLineAmounts(row, gstRatePct); + builtItems.push({ + item_id: BigInt(row.item_id), + line_no: row.line_no, + ordered_qty: row.ordered_qty, + uom_id: BigInt(row.uom_id), + rate: row.rate, + discount_pct: row.discount_pct ?? 0, + discount_amount: amounts.discount_amount, + gst_rate_id: row.gst_rate_id ? BigInt(row.gst_rate_id) : item.gst_rate_id || null, + hsn_code_id: row.hsn_code_id ? BigInt(row.hsn_code_id) : item.hsn_code_id || null, + taxable_amount: amounts.taxable_amount, + tax_amount: amounts.tax_amount, + line_total: amounts.line_total, + remarks: row.remarks || null, + }); + } + + return builtItems; +}; + +const buildHeaderData = async (payload, builtItems, userId) => { + await assertReference('vendors', payload.vendor_id, 'vendor_id'); + await assertReference('plants', payload.plant_id, 'plant_id'); + if (payload.warehouse_id) + await assertReference('warehouses', payload.warehouse_id, 'warehouse_id'); + if (payload.brand_id) + await assertReference('brands', payload.brand_id, 'brand_id', { requireActive: false }); + if (payload.payment_term_id) + await assertReference('payment_terms', payload.payment_term_id, 'payment_term_id'); + if (payload.delivery_term_id) + await assertReference('delivery_terms', payload.delivery_term_id, 'delivery_term_id'); + + const totals = computeHeaderTotals( + builtItems, + payload.discount_amount, + payload.freight_charges, + payload.other_charges + ); + + return { + po_date: toDateOnly(payload.po_date), + po_type: payload.po_type, + vendor_id: BigInt(payload.vendor_id), + plant_id: BigInt(payload.plant_id), + warehouse_id: payload.warehouse_id ? BigInt(payload.warehouse_id) : null, + brand_id: payload.brand_id ? BigInt(payload.brand_id) : null, + payment_term_id: payload.payment_term_id ? BigInt(payload.payment_term_id) : null, + delivery_term_id: payload.delivery_term_id ? BigInt(payload.delivery_term_id) : null, + expected_delivery_date: payload.expected_delivery_date + ? toDateOnly(payload.expected_delivery_date) + : null, + terms_and_conditions: payload.terms_and_conditions || null, + remarks: payload.remarks || null, + ...totals, + updated_by: userId ? BigInt(userId) : null, + }; +}; + +const getPoOrThrow = async (id, { includeItems = false } = {}) => { + const po = await prisma.purchase_orders.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + include: includeItems ? poDetailInclude : poListInclude, + }); + if (!po) throw new ApiError(404, 'Purchase order not found'); + return po; +}; + +const hasReceipts = (po) => + (po.purchase_order_items || []).some((line) => Number(line.received_qty) > 0); + +const createPurchaseOrder = async (payload, userId, requestId) => { + const builtItems = await validateAndBuildItems(payload.items); + const header = await buildHeaderData(payload, builtItems, userId); + const poNumber = await nextDocumentNumber('PO'); + + header.po_number = poNumber; + header.status = 'DRAFT'; + header.created_by = userId ? BigInt(userId) : null; + + const itemsWithPo = builtItems.map((item) => ({ ...item })); + const createdPo = await repository.createPurchaseOrderWithItems({ header, items: itemsWithPo }); + const created = await getPoOrThrow(createdPo.id, { includeItems: true }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: sanitizePo(created), + userId, + requestId, + }); + + return sanitizePo(created); +}; + +const listPurchaseOrders = async (query) => { + const { page, limit, skip } = getPagination(query); + const where = { + deleted_at: null, + ...(query.status ? { status: query.status } : {}), + ...(query.po_type ? { po_type: query.po_type } : {}), + ...(query.vendor_id ? { vendor_id: BigInt(query.vendor_id) } : {}), + ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), + ...(query.search ? { po_number: { contains: query.search, mode: 'insensitive' } } : {}), + ...(query.date_from || query.date_to + ? { + po_date: { + ...(query.date_from ? { gte: toDateOnly(query.date_from) } : {}), + ...(query.date_to ? { lte: toDateOnly(query.date_to) } : {}), + }, + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.purchase_orders.findMany({ + where, + include: poListInclude, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.purchase_orders.count({ where }), + ]); + + return { data: rows.map(sanitizePo), meta: { page, limit, total } }; +}; + +const getPurchaseOrderById = async (id) => + sanitizePo(await getPoOrThrow(id, { includeItems: true })); + +const updatePurchaseOrder = async (id, payload, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, EDITABLE_STATUSES, 'update'); + + const merged = { + po_date: payload.po_date ?? existing.po_date, + po_type: payload.po_type ?? existing.po_type, + vendor_id: payload.vendor_id ?? existing.vendor_id, + plant_id: payload.plant_id ?? existing.plant_id, + warehouse_id: payload.warehouse_id !== undefined ? payload.warehouse_id : existing.warehouse_id, + brand_id: payload.brand_id !== undefined ? payload.brand_id : existing.brand_id, + payment_term_id: + payload.payment_term_id !== undefined ? payload.payment_term_id : existing.payment_term_id, + delivery_term_id: + payload.delivery_term_id !== undefined ? payload.delivery_term_id : existing.delivery_term_id, + expected_delivery_date: + payload.expected_delivery_date !== undefined + ? payload.expected_delivery_date + : existing.expected_delivery_date, + discount_amount: payload.discount_amount ?? Number(existing.discount_amount), + freight_charges: payload.freight_charges ?? Number(existing.freight_charges), + other_charges: payload.other_charges ?? Number(existing.other_charges), + terms_and_conditions: + payload.terms_and_conditions !== undefined + ? payload.terms_and_conditions + : existing.terms_and_conditions, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + items: payload.items, + }; + + const sourceItems = + payload.items || + existing.purchase_order_items.map((line) => ({ + item_id: line.item_id, + line_no: line.line_no, + ordered_qty: line.ordered_qty, + uom_id: line.uom_id, + rate: line.rate, + discount_pct: line.discount_pct, + discount_amount: line.discount_amount, + gst_rate_id: line.gst_rate_id, + hsn_code_id: line.hsn_code_id, + remarks: line.remarks, + })); + + const builtItems = await validateAndBuildItems(sourceItems); + const header = await buildHeaderData(merged, builtItems, userId); + + if (payload.items) { + await repository.replacePurchaseOrderItems( + id, + builtItems.map((item) => ({ ...item, po_id: BigInt(id) })) + ); + } + + const updated = await prisma.purchase_orders.update({ + where: { id: BigInt(id) }, + data: header, + include: poDetailInclude, + }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'UPDATE', + oldValue: sanitizePo(existing), + newValue: sanitizePo(updated), + userId, + requestId, + }); + + return sanitizePo(updated); +}; + +const deletePurchaseOrder = async (id, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, DELETABLE_STATUSES, 'delete'); + if (hasReceipts(existing)) throw new ApiError(409, 'Cannot delete PO with received quantities'); + + const deleted = await prisma.purchase_orders.update({ + where: { id: BigInt(id) }, + data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null }, + }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'DELETE', + oldValue: sanitizePo(existing), + newValue: deleted, + userId, + requestId, + }); +}; + +const submitPurchaseOrder = async (id, payload, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, SUBMITTABLE_STATUSES, 'submit'); + if (!existing.purchase_order_items?.length) { + throw new ApiError(422, 'PO must have at least one line item before submit'); + } + + const updated = await prisma.$transaction(async (tx) => { + await tx.po_approvals.deleteMany({ where: { po_id: BigInt(id) } }); + await tx.po_approvals.create({ + data: { + po_id: BigInt(id), + approval_level: 1, + status: 'PENDING', + }, + }); + + return tx.purchase_orders.update({ + where: { id: BigInt(id) }, + data: { + status: 'PENDING_APPROVAL', + remarks: payload.remarks ?? existing.remarks, + updated_by: userId ? BigInt(userId) : null, + }, + include: poDetailInclude, + }); + }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'SUBMIT', + oldValue: sanitizePo(existing), + newValue: sanitizePo(updated), + userId, + requestId, + }); + + return sanitizePo(updated); +}; + +const approvePurchaseOrder = async (id, payload, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, APPROVABLE_STATUSES, 'approve'); + + const pendingApproval = existing.po_approvals.find((row) => row.status === 'PENDING'); + if (!pendingApproval) throw new ApiError(409, 'No pending approval step found'); + + const updated = await prisma.$transaction(async (tx) => { + await tx.po_approvals.update({ + where: { id: pendingApproval.id }, + data: { + status: 'APPROVED', + remarks: payload.remarks || null, + approver_user_id: userId ? BigInt(userId) : null, + acted_at: new Date(), + }, + }); + + return tx.purchase_orders.update({ + where: { id: BigInt(id) }, + data: { + status: 'APPROVED', + updated_by: userId ? BigInt(userId) : null, + }, + include: poDetailInclude, + }); + }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'APPROVE', + oldValue: sanitizePo(existing), + newValue: sanitizePo(updated), + userId, + requestId, + }); + + return sanitizePo(updated); +}; + +const rejectPurchaseOrder = async (id, payload, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, APPROVABLE_STATUSES, 'reject'); + + const pendingApproval = existing.po_approvals.find((row) => row.status === 'PENDING'); + if (!pendingApproval) throw new ApiError(409, 'No pending approval step found'); + + const updated = await prisma.$transaction(async (tx) => { + await tx.po_approvals.update({ + where: { id: pendingApproval.id }, + data: { + status: 'REJECTED', + remarks: payload.remarks, + approver_user_id: userId ? BigInt(userId) : null, + acted_at: new Date(), + }, + }); + + return tx.purchase_orders.update({ + where: { id: BigInt(id) }, + data: { + status: 'REJECTED', + updated_by: userId ? BigInt(userId) : null, + }, + include: poDetailInclude, + }); + }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'REJECT', + oldValue: sanitizePo(existing), + newValue: sanitizePo(updated), + userId, + requestId, + }); + + return sanitizePo(updated); +}; + +const cancelPurchaseOrder = async (id, payload, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, CANCELLABLE_STATUSES, 'cancel'); + if (hasReceipts(existing)) throw new ApiError(409, 'Cannot cancel PO with received quantities'); + + const updated = await prisma.purchase_orders.update({ + where: { id: BigInt(id) }, + data: { + status: 'CANCELLED', + remarks: payload.remarks ?? existing.remarks, + updated_by: userId ? BigInt(userId) : null, + }, + include: poDetailInclude, + }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'CANCEL', + oldValue: sanitizePo(existing), + newValue: sanitizePo(updated), + userId, + requestId, + }); + + return sanitizePo(updated); +}; + +const amendPurchaseOrder = async (id, payload, userId, requestId) => { + const existing = await getPoOrThrow(id, { includeItems: true }); + assertStatus(existing, AMENDABLE_STATUSES, 'amend'); + + const sourceItems = payload.items + ? payload.items + : existing.purchase_order_items.map((line) => ({ + item_id: line.item_id, + line_no: line.line_no, + ordered_qty: line.ordered_qty, + uom_id: line.uom_id, + rate: line.rate, + discount_pct: line.discount_pct, + discount_amount: line.discount_amount, + gst_rate_id: line.gst_rate_id, + hsn_code_id: line.hsn_code_id, + remarks: line.remarks, + })); + + const merged = { + po_date: payload.po_date ?? existing.po_date, + po_type: payload.po_type ?? existing.po_type, + vendor_id: payload.vendor_id ?? existing.vendor_id, + plant_id: payload.plant_id ?? existing.plant_id, + warehouse_id: payload.warehouse_id !== undefined ? payload.warehouse_id : existing.warehouse_id, + brand_id: payload.brand_id !== undefined ? payload.brand_id : existing.brand_id, + payment_term_id: + payload.payment_term_id !== undefined ? payload.payment_term_id : existing.payment_term_id, + delivery_term_id: + payload.delivery_term_id !== undefined ? payload.delivery_term_id : existing.delivery_term_id, + expected_delivery_date: + payload.expected_delivery_date !== undefined + ? payload.expected_delivery_date + : existing.expected_delivery_date, + discount_amount: payload.discount_amount ?? Number(existing.discount_amount), + freight_charges: payload.freight_charges ?? Number(existing.freight_charges), + other_charges: payload.other_charges ?? Number(existing.other_charges), + terms_and_conditions: + payload.terms_and_conditions !== undefined + ? payload.terms_and_conditions + : existing.terms_and_conditions, + remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks, + items: sourceItems, + }; + + const builtItems = await validateAndBuildItems(sourceItems); + const header = await buildHeaderData(merged, builtItems, userId); + + const amended = await repository.createAmendedPurchaseOrder({ + header: { + ...header, + status: 'DRAFT', + revision_no: existing.revision_no + 1, + parent_po_id: BigInt(id), + created_by: userId ? BigInt(userId) : null, + }, + items: builtItems, + }); + + await prisma.purchase_orders.update({ + where: { id: BigInt(id) }, + data: { + status: 'CLOSED', + updated_by: userId ? BigInt(userId) : null, + }, + }); + + const created = await getPoOrThrow(amended.id, { includeItems: true }); + + await auditLog({ + tableName: 'purchase_orders', + recordId: id, + action: 'AMEND', + oldValue: sanitizePo(existing), + newValue: sanitizePo(created), + userId, + requestId, + }); + + return sanitizePo(created); +}; + +const getPurchaseOrderPdf = async (id) => { + const po = sanitizePo(await getPoOrThrow(id, { includeItems: true })); + const lines = [ + `Purchase Order: ${po.po_number}`, + `Date: ${po.po_date ? new Date(po.po_date).toISOString().slice(0, 10) : '-'}`, + `Status: ${po.status}`, + `Vendor: ${po.vendor?.vendor_name || '-'}`, + `Plant: ${po.plant?.name || '-'}`, + `Grand Total: ${po.grand_total}`, + '', + 'Line Items:', + ...po.items.map( + (line) => + `${line.line_no}. ${line.item?.item_name || line.item_id} | Qty ${line.ordered_qty} @ ${line.rate} = ${line.line_total}` + ), + ]; + + return { + filename: `${po.po_number.replace(/\//g, '-')}.pdf`, + buffer: buildSimplePdf(lines), + }; +}; + +module.exports = { + createPurchaseOrder, + listPurchaseOrders, + getPurchaseOrderById, + updatePurchaseOrder, + deletePurchaseOrder, + submitPurchaseOrder, + approvePurchaseOrder, + rejectPurchaseOrder, + cancelPurchaseOrder, + amendPurchaseOrder, + getPurchaseOrderPdf, +}; diff --git a/src/modules/purchase-orders/purchase-orders.validation.js b/src/modules/purchase-orders/purchase-orders.validation.js new file mode 100644 index 0000000..2406e5e --- /dev/null +++ b/src/modules/purchase-orders/purchase-orders.validation.js @@ -0,0 +1,108 @@ +const Joi = require('joi'); +const { PO_TYPES, PO_STATUSES } = require('./purchase-orders.constants'); + +const poItemSchema = Joi.object({ + item_id: Joi.number().integer().positive().required(), + line_no: Joi.number().integer().min(1).required(), + ordered_qty: Joi.number().positive().required(), + uom_id: Joi.number().integer().positive().required(), + rate: Joi.number().min(0).required(), + discount_pct: Joi.number().min(0).max(100).default(0), + discount_amount: Joi.number().min(0).default(0), + gst_rate_id: Joi.number().integer().positive().allow(null).optional(), + hsn_code_id: Joi.number().integer().positive().allow(null).optional(), + remarks: Joi.string().allow(null, '').optional(), +}); + +const poHeaderFields = { + po_date: Joi.date().iso().required(), + po_type: Joi.string() + .valid(...PO_TYPES) + .required(), + vendor_id: Joi.number().integer().positive().required(), + plant_id: Joi.number().integer().positive().required(), + warehouse_id: Joi.number().integer().positive().allow(null).optional(), + brand_id: Joi.number().integer().positive().allow(null).optional(), + payment_term_id: Joi.number().integer().positive().allow(null).optional(), + delivery_term_id: Joi.number().integer().positive().allow(null).optional(), + expected_delivery_date: Joi.date().iso().allow(null).optional(), + discount_amount: Joi.number().min(0).default(0), + freight_charges: Joi.number().min(0).default(0), + other_charges: Joi.number().min(0).default(0), + terms_and_conditions: Joi.string().allow(null, '').optional(), + remarks: Joi.string().allow(null, '').optional(), +}; + +const createPurchaseOrderSchema = Joi.object({ + ...poHeaderFields, + items: Joi.array().items(poItemSchema).min(1).required(), +}); + +const updatePurchaseOrderSchema = Joi.object({ + po_date: poHeaderFields.po_date.optional(), + po_type: poHeaderFields.po_type.optional(), + vendor_id: poHeaderFields.vendor_id.optional(), + plant_id: poHeaderFields.plant_id.optional(), + warehouse_id: poHeaderFields.warehouse_id.optional(), + brand_id: poHeaderFields.brand_id.optional(), + payment_term_id: poHeaderFields.payment_term_id.optional(), + delivery_term_id: poHeaderFields.delivery_term_id.optional(), + expected_delivery_date: poHeaderFields.expected_delivery_date.optional(), + discount_amount: poHeaderFields.discount_amount.optional(), + freight_charges: poHeaderFields.freight_charges.optional(), + other_charges: poHeaderFields.other_charges.optional(), + terms_and_conditions: poHeaderFields.terms_and_conditions.optional(), + remarks: poHeaderFields.remarks.optional(), + items: Joi.array().items(poItemSchema).min(1).optional(), +}).min(1); + +const amendPurchaseOrderSchema = Joi.object({ + po_date: poHeaderFields.po_date.optional(), + po_type: poHeaderFields.po_type.optional(), + vendor_id: poHeaderFields.vendor_id.optional(), + plant_id: poHeaderFields.plant_id.optional(), + warehouse_id: poHeaderFields.warehouse_id.optional(), + brand_id: poHeaderFields.brand_id.optional(), + payment_term_id: poHeaderFields.payment_term_id.optional(), + delivery_term_id: poHeaderFields.delivery_term_id.optional(), + expected_delivery_date: poHeaderFields.expected_delivery_date.optional(), + discount_amount: poHeaderFields.discount_amount.optional(), + freight_charges: poHeaderFields.freight_charges.optional(), + other_charges: poHeaderFields.other_charges.optional(), + terms_and_conditions: poHeaderFields.terms_and_conditions.optional(), + remarks: poHeaderFields.remarks.optional(), + items: Joi.array().items(poItemSchema).min(1).optional(), +}); + +const listPurchaseOrdersQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + search: Joi.string().allow('').optional(), + status: Joi.string() + .valid(...PO_STATUSES) + .optional(), + po_type: Joi.string() + .valid(...PO_TYPES) + .optional(), + vendor_id: Joi.number().integer().positive().optional(), + plant_id: Joi.number().integer().positive().optional(), + date_from: Joi.date().iso().optional(), + date_to: Joi.date().iso().optional(), +}); + +const workflowRemarksSchema = Joi.object({ + remarks: Joi.string().allow(null, '').optional(), +}); + +const rejectPurchaseOrderSchema = Joi.object({ + remarks: Joi.string().trim().min(1).required(), +}); + +module.exports = { + createPurchaseOrderSchema, + updatePurchaseOrderSchema, + amendPurchaseOrderSchema, + listPurchaseOrdersQuerySchema, + workflowRemarksSchema, + rejectPurchaseOrderSchema, +}; diff --git a/src/modules/roles/roles.routes.js b/src/modules/roles/roles.routes.js index e752002..2c6a25f 100644 --- a/src/modules/roles/roles.routes.js +++ b/src/modules/roles/roles.routes.js @@ -23,7 +23,7 @@ router.get( controller.list ); router.get('/:id', authorize('ROLES', 'view'), controller.getOne); -router.post('/', authorize('ROLES', 'edit'), validate(createRoleSchema), controller.create); +router.post('/', authorize('ROLES', 'create'), validate(createRoleSchema), controller.create); router.put('/:id', authorize('ROLES', 'edit'), validate(updateRoleSchema), controller.update); router.put( '/:id/permissions', @@ -38,6 +38,6 @@ router.put( validate(permissionMatrixSchema), controller.savePermissionMatrix ); -router.delete('/:id', authorize('ROLES', 'edit'), controller.remove); +router.delete('/:id', authorize('ROLES', 'delete'), controller.remove); module.exports = router; diff --git a/src/modules/roles/roles.service.js b/src/modules/roles/roles.service.js index 497bb36..d82f5c1 100644 --- a/src/modules/roles/roles.service.js +++ b/src/modules/roles/roles.service.js @@ -3,8 +3,7 @@ const ApiError = require('../../utils/ApiError'); const auditLog = require('../../utils/auditLog'); const { getPagination } = require('../../utils/pagination'); -const MATRIX_ACTIONS = ['view', 'edit', 'approve', 'export']; -const MERGED_EDIT_ACTIONS = ['create', 'edit']; +const PERMISSION_ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export']; const permissionSelect = { permissions: { @@ -294,19 +293,7 @@ const getPermissionMatrix = async (id) => { const matrix = modules.map((mod) => { const permissions = {}; - for (const action of MATRIX_ACTIONS) { - if (action === 'edit') { - const createPerm = mod.permissions.find((p) => p.action === 'create'); - const editPerm = mod.permissions.find((p) => p.action === 'edit'); - const merged = [createPerm, editPerm].filter(Boolean); - permissions.edit = { - permission_id: editPerm?.id ?? createPerm?.id ?? null, - permission_ids: merged.map((p) => p.id), - granted: merged.some((p) => granted.has(p.id.toString())), - }; - continue; - } - + for (const action of PERMISSION_ACTIONS) { const perm = mod.permissions.find((p) => p.action === action); permissions[action] = { permission_id: perm?.id ?? null, @@ -323,7 +310,7 @@ const getPermissionMatrix = async (id) => { return { role: { id: role.id, name: role.name }, - actions: MATRIX_ACTIONS, + actions: PERMISSION_ACTIONS, modules: matrix, }; }; @@ -340,22 +327,9 @@ const savePermissionMatrix = async (id, matrix, userId, requestId) => { const permissionIds = []; for (const row of matrix) { - for (const action of MATRIX_ACTIONS) { + for (const action of PERMISSION_ACTIONS) { if (!row.actions?.[action]) continue; - if (action === 'edit') { - for (const dbAction of MERGED_EDIT_ACTIONS) { - const perm = catalog.find( - (p) => p.module_id.toString() === String(row.module_id) && p.action === dbAction - ); - if (!perm) { - throw new ApiError(422, `Permission not found for module ${row.module_id}:${dbAction}`); - } - permissionIds.push(perm.id); - } - continue; - } - const perm = catalog.find( (p) => p.module_id.toString() === String(row.module_id) && p.action === action ); diff --git a/src/modules/roles/roles.validation.js b/src/modules/roles/roles.validation.js index 30a941c..1e8588b 100644 --- a/src/modules/roles/roles.validation.js +++ b/src/modules/roles/roles.validation.js @@ -23,7 +23,9 @@ const permissionMatrixSchema = Joi.object({ module_id: Joi.number().integer().positive().required(), actions: Joi.object({ view: Joi.boolean().required(), + create: Joi.boolean().required(), edit: Joi.boolean().required(), + delete: Joi.boolean().required(), approve: Joi.boolean().required(), export: Joi.boolean().required(), }).required(), diff --git a/src/modules/vendors/vendors.controller.js b/src/modules/vendors/vendors.controller.js new file mode 100644 index 0000000..faabb5c --- /dev/null +++ b/src/modules/vendors/vendors.controller.js @@ -0,0 +1,155 @@ +const asyncHandler = require('../../utils/asyncHandler'); +const ApiResponse = require('../../utils/ApiResponse'); +const service = require('./vendors.service'); + +const create = asyncHandler(async (req, res) => { + const data = await service.createVendor(req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Vendor created successfully')); +}); + +const list = asyncHandler(async (req, res) => { + const result = await service.listVendors(req.query); + res.json(new ApiResponse(200, result.data, 'Vendors fetched', result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getVendorById(req.params.id); + res.json(new ApiResponse(200, data, 'Vendor fetched')); +}); + +const update = asyncHandler(async (req, res) => { + const data = await service.updateVendor(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Vendor updated successfully')); +}); + +const changeStatus = asyncHandler(async (req, res) => { + const data = await service.setVendorStatus(req.params.id, req.body.status, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Vendor status updated')); +}); + +const remove = asyncHandler(async (req, res) => { + await service.deleteVendor(req.params.id, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'Vendor deleted successfully')); +}); + +const listAddresses = asyncHandler(async (req, res) => { + const data = await service.listAddresses(req.params.vendorId); + res.json(new ApiResponse(200, data, 'Vendor addresses fetched')); +}); + +const createAddress = asyncHandler(async (req, res) => { + const data = await service.createAddress(req.params.vendorId, req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Vendor address created successfully')); +}); + +const getAddress = asyncHandler(async (req, res) => { + const data = await service.getAddress(req.params.vendorId, req.params.addressId); + res.json(new ApiResponse(200, data, 'Vendor address fetched')); +}); + +const updateAddress = asyncHandler(async (req, res) => { + const data = await service.updateAddress( + req.params.vendorId, + req.params.addressId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'Vendor address updated successfully')); +}); + +const removeAddress = asyncHandler(async (req, res) => { + await service.deleteAddress(req.params.vendorId, req.params.addressId, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'Vendor address deleted successfully')); +}); + +const listContacts = asyncHandler(async (req, res) => { + const data = await service.listContacts(req.params.vendorId); + res.json(new ApiResponse(200, data, 'Vendor contacts fetched')); +}); + +const createContact = asyncHandler(async (req, res) => { + const data = await service.createContact(req.params.vendorId, req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Vendor contact created successfully')); +}); + +const getContact = asyncHandler(async (req, res) => { + const data = await service.getContact(req.params.vendorId, req.params.contactId); + res.json(new ApiResponse(200, data, 'Vendor contact fetched')); +}); + +const updateContact = asyncHandler(async (req, res) => { + const data = await service.updateContact( + req.params.vendorId, + req.params.contactId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'Vendor contact updated successfully')); +}); + +const removeContact = asyncHandler(async (req, res) => { + await service.deleteContact(req.params.vendorId, req.params.contactId, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'Vendor contact deleted successfully')); +}); + +const listBankDetails = asyncHandler(async (req, res) => { + const data = await service.listBankDetails(req.params.vendorId); + res.json(new ApiResponse(200, data, 'Vendor bank details fetched')); +}); + +const createBankDetail = asyncHandler(async (req, res) => { + const data = await service.createBankDetail(req.params.vendorId, req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Vendor bank detail created successfully')); +}); + +const getBankDetail = asyncHandler(async (req, res) => { + const data = await service.getBankDetail(req.params.vendorId, req.params.bankDetailId); + res.json(new ApiResponse(200, data, 'Vendor bank detail fetched')); +}); + +const updateBankDetail = asyncHandler(async (req, res) => { + const data = await service.updateBankDetail( + req.params.vendorId, + req.params.bankDetailId, + req.body, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, data, 'Vendor bank detail updated successfully')); +}); + +const removeBankDetail = asyncHandler(async (req, res) => { + await service.deleteBankDetail( + req.params.vendorId, + req.params.bankDetailId, + req.user?.id, + req.id + ); + res.json(new ApiResponse(200, null, 'Vendor bank detail deleted successfully')); +}); + +module.exports = { + create, + list, + getOne, + update, + changeStatus, + remove, + listAddresses, + createAddress, + getAddress, + updateAddress, + removeAddress, + listContacts, + createContact, + getContact, + updateContact, + removeContact, + listBankDetails, + createBankDetail, + getBankDetail, + updateBankDetail, + removeBankDetail, +}; diff --git a/src/modules/vendors/vendors.routes.js b/src/modules/vendors/vendors.routes.js new file mode 100644 index 0000000..4172918 --- /dev/null +++ b/src/modules/vendors/vendors.routes.js @@ -0,0 +1,105 @@ +const express = require('express'); +const authenticate = require('../../middlewares/auth.middleware'); +const authorize = require('../../middlewares/rbac.middleware'); +const validate = require('../../middlewares/validate.middleware'); +const controller = require('./vendors.controller'); +const { + createVendorSchema, + updateVendorSchema, + vendorStatusSchema, + listVendorsQuerySchema, + createAddressSchema, + updateAddressSchema, + createContactSchema, + updateContactSchema, + createBankDetailSchema, + updateBankDetailSchema, +} = require('./vendors.validation'); + +const router = express.Router(); + +router.use(authenticate); + +router.get( + '/', + authorize('VENDOR', 'view'), + validate(listVendorsQuerySchema, 'query'), + controller.list +); +router.post('/', authorize('VENDOR', 'create'), validate(createVendorSchema), controller.create); + +router.get('/:vendorId/addresses', authorize('VENDOR', 'view'), controller.listAddresses); +router.post( + '/:vendorId/addresses', + authorize('VENDOR', 'create'), + validate(createAddressSchema), + controller.createAddress +); +router.get('/:vendorId/addresses/:addressId', authorize('VENDOR', 'view'), controller.getAddress); +router.put( + '/:vendorId/addresses/:addressId', + authorize('VENDOR', 'edit'), + validate(updateAddressSchema), + controller.updateAddress +); +router.delete( + '/:vendorId/addresses/:addressId', + authorize('VENDOR', 'delete'), + controller.removeAddress +); + +router.get('/:vendorId/contacts', authorize('VENDOR', 'view'), controller.listContacts); +router.post( + '/:vendorId/contacts', + authorize('VENDOR', 'create'), + validate(createContactSchema), + controller.createContact +); +router.get('/:vendorId/contacts/:contactId', authorize('VENDOR', 'view'), controller.getContact); +router.put( + '/:vendorId/contacts/:contactId', + authorize('VENDOR', 'edit'), + validate(updateContactSchema), + controller.updateContact +); +router.delete( + '/:vendorId/contacts/:contactId', + authorize('VENDOR', 'delete'), + controller.removeContact +); + +router.get('/:vendorId/bank-details', authorize('VENDOR', 'view'), controller.listBankDetails); +router.post( + '/:vendorId/bank-details', + authorize('VENDOR', 'create'), + validate(createBankDetailSchema), + controller.createBankDetail +); +router.get( + '/:vendorId/bank-details/:bankDetailId', + authorize('VENDOR', 'view'), + controller.getBankDetail +); +router.put( + '/:vendorId/bank-details/:bankDetailId', + authorize('VENDOR', 'edit'), + validate(updateBankDetailSchema), + controller.updateBankDetail +); +router.delete( + '/:vendorId/bank-details/:bankDetailId', + authorize('VENDOR', 'delete'), + controller.removeBankDetail +); + +router.get('/:id', authorize('VENDOR', 'view'), controller.getOne); +router.put('/:id', authorize('VENDOR', 'edit'), validate(updateVendorSchema), controller.update); +router.patch( + '/:id/status', + authorize('VENDOR', 'edit'), + validate(vendorStatusSchema), + controller.changeStatus +); +router.delete('/:id', authorize('VENDOR', 'delete'), controller.remove); + +module.exports = router; diff --git a/src/modules/vendors/vendors.service.js b/src/modules/vendors/vendors.service.js new file mode 100644 index 0000000..d13db1d --- /dev/null +++ b/src/modules/vendors/vendors.service.js @@ -0,0 +1,494 @@ +const prisma = require('../../config/prisma'); +const ApiError = require('../../utils/ApiError'); +const auditLog = require('../../utils/auditLog'); +const { getPagination } = require('../../utils/pagination'); +const { nextDocumentNumber } = require('../../utils/generateCode'); +const { encrypt, decrypt, blindIndex } = require('../../utils/encryption'); + +const vendorInclude = { + payment_terms: { select: { id: true, code: true, name: true } }, +}; + +const vendorDetailInclude = { + ...vendorInclude, + vendor_addresses: { where: { is_active: true }, orderBy: { created_at: 'asc' } }, + vendor_contacts: { where: { is_active: true }, orderBy: { created_at: 'asc' } }, + vendor_bank_details: { where: { is_active: true }, orderBy: { created_at: 'asc' } }, +}; + +const sanitizeBankDetail = (row) => { + if (!row) return null; + const { account_number_index, ...rest } = row; + void account_number_index; + return { + ...rest, + account_number: rest.account_number ? decrypt(rest.account_number) : null, + }; +}; + +const sanitizeVendor = (vendor, { includeChildren = false } = {}) => { + if (!vendor) return null; + const result = { ...vendor }; + if (includeChildren) { + result.addresses = (vendor.vendor_addresses || []).map((row) => ({ ...row })); + result.contacts = (vendor.vendor_contacts || []).map((row) => ({ ...row })); + result.bank_details = (vendor.vendor_bank_details || []).map(sanitizeBankDetail); + delete result.vendor_addresses; + delete result.vendor_contacts; + delete result.vendor_bank_details; + } + return result; +}; + +const getVendorOrThrow = async (id, { includeChildren = false } = {}) => { + const vendor = await prisma.vendors.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + include: includeChildren ? vendorDetailInclude : vendorInclude, + }); + if (!vendor) throw new ApiError(404, 'Vendor not found'); + return vendor; +}; + +const assertPaymentTerm = async (paymentTermId) => { + if (!paymentTermId) return; + const term = await prisma.payment_terms.findFirst({ + where: { id: BigInt(paymentTermId), deleted_at: null }, + }); + if (!term) throw new ApiError(422, 'Invalid payment_term_id'); +}; + +const normalizeVendorPayload = (payload) => { + const data = { ...payload }; + if (data.gstin !== undefined) data.gstin = data.gstin || null; + if (data.pan !== undefined) data.pan = data.pan || null; + if (data.remarks !== undefined) data.remarks = data.remarks || null; + if (data.payment_term_id !== undefined && data.payment_term_id !== null) { + data.payment_term_id = BigInt(data.payment_term_id); + } + return data; +}; + +const buildVendorsWhere = (query) => ({ + deleted_at: null, + ...(query.status ? { status: query.status } : {}), + ...(query.vendor_type ? { vendor_type: query.vendor_type } : {}), + ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), + ...(query.search + ? { + OR: [ + { vendor_name: { contains: query.search, mode: 'insensitive' } }, + { vendor_code: { contains: query.search, mode: 'insensitive' } }, + { gstin: { contains: query.search, mode: 'insensitive' } }, + ], + } + : {}), +}); + +const createVendor = async (payload, userId, requestId) => { + await assertPaymentTerm(payload.payment_term_id); + + const vendorCode = await nextDocumentNumber('VENDOR'); + const data = normalizeVendorPayload(payload); + data.vendor_code = vendorCode; + data.created_by = userId ? BigInt(userId) : null; + data.updated_by = userId ? BigInt(userId) : null; + + const created = await prisma.vendors.create({ data, include: vendorInclude }); + + await auditLog({ + tableName: 'vendors', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: created, + userId, + requestId, + }); + + return sanitizeVendor(created); +}; + +const listVendors = async (query) => { + const { page, limit, skip } = getPagination(query); + const where = buildVendorsWhere(query); + + const [rows, total] = await Promise.all([ + prisma.vendors.findMany({ + where, + include: vendorInclude, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.vendors.count({ where }), + ]); + + return { data: rows.map((row) => sanitizeVendor(row)), meta: { page, limit, total } }; +}; + +const getVendorById = async (id) => { + const vendor = await getVendorOrThrow(id, { includeChildren: true }); + return sanitizeVendor(vendor, { includeChildren: true }); +}; + +const updateVendor = async (id, payload, userId, requestId) => { + const existing = await getVendorOrThrow(id); + await assertPaymentTerm(payload.payment_term_id); + + const data = normalizeVendorPayload(payload); + data.updated_by = userId ? BigInt(userId) : null; + + const updated = await prisma.vendors.update({ + where: { id: BigInt(id) }, + data, + include: vendorInclude, + }); + + await auditLog({ + tableName: 'vendors', + recordId: id, + action: 'UPDATE', + oldValue: existing, + newValue: updated, + userId, + requestId, + }); + + return sanitizeVendor(updated); +}; + +const setVendorStatus = async (id, status, userId, requestId) => { + const existing = await getVendorOrThrow(id); + + const updated = await prisma.vendors.update({ + where: { id: BigInt(id) }, + data: { status, updated_by: userId ? BigInt(userId) : null }, + include: vendorInclude, + }); + + await auditLog({ + tableName: 'vendors', + recordId: id, + action: 'STATUS_CHANGE', + oldValue: { status: existing.status }, + newValue: { status }, + userId, + requestId, + }); + + return sanitizeVendor(updated); +}; + +const deleteVendor = async (id, userId, requestId) => { + const existing = await getVendorOrThrow(id); + + await prisma.vendors.update({ + where: { id: BigInt(id) }, + data: { + deleted_at: new Date(), + is_active: false, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await auditLog({ + tableName: 'vendors', + recordId: id, + action: 'DELETE', + oldValue: existing, + newValue: { deleted_at: new Date() }, + userId, + requestId, + }); +}; + +const listAddresses = async (vendorId) => { + await getVendorOrThrow(vendorId); + return prisma.vendor_addresses.findMany({ + where: { vendor_id: BigInt(vendorId), is_active: true }, + orderBy: { created_at: 'asc' }, + }); +}; + +const createAddress = async (vendorId, payload, userId, requestId) => { + await getVendorOrThrow(vendorId); + + const created = await prisma.vendor_addresses.create({ + data: { + ...payload, + vendor_id: BigInt(vendorId), + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await auditLog({ + tableName: 'vendor_addresses', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: created, + userId, + requestId, + }); + + return created; +}; + +const getAddress = async (vendorId, addressId) => { + await getVendorOrThrow(vendorId); + const row = await prisma.vendor_addresses.findFirst({ + where: { id: BigInt(addressId), vendor_id: BigInt(vendorId), is_active: true }, + }); + if (!row) throw new ApiError(404, 'Vendor address not found'); + return row; +}; + +const updateAddress = async (vendorId, addressId, payload, userId, requestId) => { + const existing = await getAddress(vendorId, addressId); + + const updated = await prisma.vendor_addresses.update({ + where: { id: BigInt(addressId) }, + data: { ...payload, updated_by: userId ? BigInt(userId) : null }, + }); + + await auditLog({ + tableName: 'vendor_addresses', + recordId: addressId, + action: 'UPDATE', + oldValue: existing, + newValue: updated, + userId, + requestId, + }); + + return updated; +}; + +const deleteAddress = async (vendorId, addressId, userId, requestId) => { + const existing = await getAddress(vendorId, addressId); + + await prisma.vendor_addresses.update({ + where: { id: BigInt(addressId) }, + data: { is_active: false, updated_by: userId ? BigInt(userId) : null }, + }); + + await auditLog({ + tableName: 'vendor_addresses', + recordId: addressId, + action: 'DELETE', + oldValue: existing, + newValue: { is_active: false }, + userId, + requestId, + }); +}; + +const listContacts = async (vendorId) => { + await getVendorOrThrow(vendorId); + return prisma.vendor_contacts.findMany({ + where: { vendor_id: BigInt(vendorId), is_active: true }, + orderBy: { created_at: 'asc' }, + }); +}; + +const createContact = async (vendorId, payload, userId, requestId) => { + await getVendorOrThrow(vendorId); + + const created = await prisma.vendor_contacts.create({ + data: { + ...payload, + vendor_id: BigInt(vendorId), + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await auditLog({ + tableName: 'vendor_contacts', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: created, + userId, + requestId, + }); + + return created; +}; + +const getContact = async (vendorId, contactId) => { + await getVendorOrThrow(vendorId); + const row = await prisma.vendor_contacts.findFirst({ + where: { id: BigInt(contactId), vendor_id: BigInt(vendorId), is_active: true }, + }); + if (!row) throw new ApiError(404, 'Vendor contact not found'); + return row; +}; + +const updateContact = async (vendorId, contactId, payload, userId, requestId) => { + const existing = await getContact(vendorId, contactId); + + const updated = await prisma.vendor_contacts.update({ + where: { id: BigInt(contactId) }, + data: { ...payload, updated_by: userId ? BigInt(userId) : null }, + }); + + await auditLog({ + tableName: 'vendor_contacts', + recordId: contactId, + action: 'UPDATE', + oldValue: existing, + newValue: updated, + userId, + requestId, + }); + + return updated; +}; + +const deleteContact = async (vendorId, contactId, userId, requestId) => { + const existing = await getContact(vendorId, contactId); + + await prisma.vendor_contacts.update({ + where: { id: BigInt(contactId) }, + data: { is_active: false, updated_by: userId ? BigInt(userId) : null }, + }); + + await auditLog({ + tableName: 'vendor_contacts', + recordId: contactId, + action: 'DELETE', + oldValue: existing, + newValue: { is_active: false }, + userId, + requestId, + }); +}; + +const listBankDetails = async (vendorId) => { + await getVendorOrThrow(vendorId); + const rows = await prisma.vendor_bank_details.findMany({ + where: { vendor_id: BigInt(vendorId), is_active: true }, + orderBy: { created_at: 'asc' }, + }); + return rows.map(sanitizeBankDetail); +}; + +const createBankDetail = async (vendorId, payload, userId, requestId) => { + await getVendorOrThrow(vendorId); + + const created = await prisma.vendor_bank_details.create({ + data: { + bank_name: payload.bank_name, + branch: payload.branch ?? null, + account_number: encrypt(payload.account_number), + account_number_index: blindIndex(payload.account_number), + ifsc: payload.ifsc, + account_holder_name: payload.account_holder_name, + account_type: payload.account_type ?? 'CURRENT', + is_primary: payload.is_primary ?? false, + is_active: payload.is_active ?? true, + vendor_id: BigInt(vendorId), + created_by: userId ? BigInt(userId) : null, + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await auditLog({ + tableName: 'vendor_bank_details', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: { ...created, account_number: '[REDACTED]' }, + userId, + requestId, + }); + + return sanitizeBankDetail(created); +}; + +const getBankDetail = async (vendorId, bankDetailId) => { + await getVendorOrThrow(vendorId); + const row = await prisma.vendor_bank_details.findFirst({ + where: { id: BigInt(bankDetailId), vendor_id: BigInt(vendorId), is_active: true }, + }); + if (!row) throw new ApiError(404, 'Vendor bank detail not found'); + return sanitizeBankDetail(row); +}; + +const updateBankDetail = async (vendorId, bankDetailId, payload, userId, requestId) => { + const existing = await prisma.vendor_bank_details.findFirst({ + where: { id: BigInt(bankDetailId), vendor_id: BigInt(vendorId), is_active: true }, + }); + if (!existing) throw new ApiError(404, 'Vendor bank detail not found'); + + const data = { ...payload, updated_by: userId ? BigInt(userId) : null }; + if (payload.account_number !== undefined) { + data.account_number = encrypt(payload.account_number); + data.account_number_index = blindIndex(payload.account_number); + } + + const updated = await prisma.vendor_bank_details.update({ + where: { id: BigInt(bankDetailId) }, + data, + }); + + await auditLog({ + tableName: 'vendor_bank_details', + recordId: bankDetailId, + action: 'UPDATE', + oldValue: { ...existing, account_number: '[REDACTED]' }, + newValue: { ...updated, account_number: '[REDACTED]' }, + userId, + requestId, + }); + + return sanitizeBankDetail(updated); +}; + +const deleteBankDetail = async (vendorId, bankDetailId, userId, requestId) => { + const existing = await prisma.vendor_bank_details.findFirst({ + where: { id: BigInt(bankDetailId), vendor_id: BigInt(vendorId), is_active: true }, + }); + if (!existing) throw new ApiError(404, 'Vendor bank detail not found'); + + await prisma.vendor_bank_details.update({ + where: { id: BigInt(bankDetailId) }, + data: { is_active: false, updated_by: userId ? BigInt(userId) : null }, + }); + + await auditLog({ + tableName: 'vendor_bank_details', + recordId: bankDetailId, + action: 'DELETE', + oldValue: { ...existing, account_number: '[REDACTED]' }, + newValue: { is_active: false }, + userId, + requestId, + }); +}; + +module.exports = { + createVendor, + listVendors, + getVendorById, + updateVendor, + setVendorStatus, + deleteVendor, + listAddresses, + createAddress, + getAddress, + updateAddress, + deleteAddress, + listContacts, + createContact, + getContact, + updateContact, + deleteContact, + listBankDetails, + createBankDetail, + getBankDetail, + updateBankDetail, + deleteBankDetail, +}; diff --git a/src/modules/vendors/vendors.validation.js b/src/modules/vendors/vendors.validation.js new file mode 100644 index 0000000..22d001d --- /dev/null +++ b/src/modules/vendors/vendors.validation.js @@ -0,0 +1,121 @@ +const Joi = require('joi'); + +const vendorTypes = ['RAW_MATERIAL', 'PACKING_MATERIAL', 'ASSET_CAPITAL', 'SERVICE', 'GENERAL']; +const vendorStatuses = ['active', 'inactive', 'blacklisted']; +const addressTypes = ['REGISTERED', 'BILLING', 'DISPATCH']; +const accountTypes = ['CURRENT', 'SAVINGS', 'OVERDRAFT']; + +const createVendorSchema = Joi.object({ + vendor_name: Joi.string().max(200).required(), + vendor_type: Joi.string() + .valid(...vendorTypes) + .required(), + gstin: Joi.string().length(15).allow(null, '').optional(), + pan: Joi.string().length(10).allow(null, '').optional(), + payment_term_id: Joi.number().integer().positive().allow(null).optional(), + credit_period_days: Joi.number().integer().min(0).default(0), + remarks: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().default(true), +}); + +const updateVendorSchema = Joi.object({ + vendor_name: Joi.string().max(200).optional(), + vendor_type: Joi.string() + .valid(...vendorTypes) + .optional(), + gstin: Joi.string().length(15).allow(null, '').optional(), + pan: Joi.string().length(10).allow(null, '').optional(), + payment_term_id: Joi.number().integer().positive().allow(null).optional(), + credit_period_days: Joi.number().integer().min(0).optional(), + remarks: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().optional(), +}).min(1); + +const vendorStatusSchema = Joi.object({ + status: Joi.string() + .valid(...vendorStatuses) + .required(), +}); + +const listVendorsQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + search: Joi.string().allow('').optional(), + status: Joi.string() + .valid(...vendorStatuses) + .optional(), + vendor_type: Joi.string() + .valid(...vendorTypes) + .optional(), + is_active: Joi.boolean().optional(), +}); + +const createAddressSchema = Joi.object({ + address_type: Joi.string() + .valid(...addressTypes) + .required(), + address_line1: Joi.string().max(200).allow(null, '').optional(), + address_line2: Joi.string().max(200).allow(null, '').optional(), + city: Joi.string().max(100).allow(null, '').optional(), + state: Joi.string().max(100).allow(null, '').optional(), + pincode: Joi.string().max(10).allow(null, '').optional(), + country: Joi.string().max(100).default('India'), + gstin: Joi.string().length(15).allow(null, '').optional(), + is_active: Joi.boolean().default(true), +}); + +const updateAddressSchema = createAddressSchema + .fork(Object.keys(createAddressSchema.describe().keys), (schema) => schema.optional()) + .min(1); + +const createContactSchema = Joi.object({ + contact_name: Joi.string().max(150).required(), + designation: Joi.string().max(100).allow(null, '').optional(), + phone: Joi.string().max(15).allow(null, '').optional(), + email: Joi.string().email().max(150).allow(null, '').optional(), + is_primary: Joi.boolean().default(false), + is_active: Joi.boolean().default(true), +}); + +const updateContactSchema = createContactSchema + .fork(Object.keys(createContactSchema.describe().keys), (schema) => schema.optional()) + .min(1); + +const createBankDetailSchema = Joi.object({ + bank_name: Joi.string().max(150).required(), + branch: Joi.string().max(150).allow(null, '').optional(), + account_number: Joi.string().max(30).required(), + ifsc: Joi.string().max(15).required(), + account_holder_name: Joi.string().max(200).required(), + account_type: Joi.string() + .valid(...accountTypes) + .default('CURRENT'), + is_primary: Joi.boolean().default(false), + is_active: Joi.boolean().default(true), +}); + +const updateBankDetailSchema = Joi.object({ + bank_name: Joi.string().max(150).optional(), + branch: Joi.string().max(150).allow(null, '').optional(), + account_number: Joi.string().max(30).optional(), + ifsc: Joi.string().max(15).optional(), + account_holder_name: Joi.string().max(200).optional(), + account_type: Joi.string() + .valid(...accountTypes) + .optional(), + is_primary: Joi.boolean().optional(), + is_active: Joi.boolean().optional(), +}).min(1); + +module.exports = { + createVendorSchema, + updateVendorSchema, + vendorStatusSchema, + listVendorsQuerySchema, + createAddressSchema, + updateAddressSchema, + createContactSchema, + updateContactSchema, + createBankDetailSchema, + updateBankDetailSchema, +}; diff --git a/src/routes/v1/index.js b/src/routes/v1/index.js index 648dcee..6288159 100644 --- a/src/routes/v1/index.js +++ b/src/routes/v1/index.js @@ -5,6 +5,10 @@ const router = express.Router(); router.use('/auth', require('../../modules/auth/auth.routes')); router.use('/users', require('../../modules/users/users.routes')); router.use('/roles', require('../../modules/roles/roles.routes')); +router.use('/vendors', require('../../modules/vendors/vendors.routes')); +router.use('/purchase-orders', require('../../modules/purchase-orders/purchase-orders.routes')); +router.use('/grn', require('../../modules/grn/grn.routes')); +router.use('/assets', require('../../modules/assets/assets.routes')); router.use('/masters', require('../../modules/masters')); router.get('/healthz', (req, res) => { diff --git a/src/utils/simplePdf.js b/src/utils/simplePdf.js new file mode 100644 index 0000000..5e53f77 --- /dev/null +++ b/src/utils/simplePdf.js @@ -0,0 +1,38 @@ +const buildSimplePdf = (lines = []) => { + const escapePdfText = (text) => + String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + + const content = lines + .map((line, index) => `1 0 0 1 50 ${780 - index * 16} Tm (${escapePdfText(line)}) Tj`) + .join('\n'); + const stream = `BT\n/F1 11 Tf\n${content}\nET`; + const streamLength = Buffer.byteLength(stream, 'utf8'); + + const objects = [ + '1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj', + '2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj', + '3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>endobj', + '4 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj', + `5 0 obj<< /Length ${streamLength} >>stream\n${stream}\nendstream endobj`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets = [0]; + + objects.forEach((object) => { + offsets.push(Buffer.byteLength(pdf, 'utf8')); + pdf += `${object}\n`; + }); + + const xrefOffset = Buffer.byteLength(pdf, 'utf8'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + offsets.slice(1).forEach((offset) => { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + }); + pdf += `trailer<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + + return Buffer.from(pdf, 'utf8'); +}; + +module.exports = { buildSimplePdf };