GWM : Next set of work

This commit is contained in:
Gowtham M 2026-06-18 18:29:46 +05:30
parent 69588c1889
commit 944238d39a
42 changed files with 4932 additions and 69 deletions

View File

@ -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

View File

@ -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 <accessToken>` (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

View File

@ -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);

View File

@ -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);

195
src/docs/assets-routes.yaml Normal file
View File

@ -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' }

View File

@ -38,6 +38,31 @@ components:
required: [refresh_token]
properties:
refresh_token: { type: string, example: "<refresh-token>" }
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

155
src/docs/grn-routes.yaml Normal file
View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
};

View File

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

View File

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

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
};

View File

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

View File

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

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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,
};

View File

@ -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,
};

View File

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

View File

@ -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
);

View File

@ -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(),

View File

@ -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,
};

105
src/modules/vendors/vendors.routes.js vendored Normal file
View File

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

494
src/modules/vendors/vendors.service.js vendored Normal file
View File

@ -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,
};

View File

@ -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,
};

View File

@ -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) => {

38
src/utils/simplePdf.js Normal file
View File

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