GWM : Next set of work
This commit is contained in:
parent
69588c1889
commit
944238d39a
@ -40,3 +40,9 @@ MAX_FILE_SIZE_MB=5
|
|||||||
# Account lockout
|
# Account lockout
|
||||||
MAX_LOGIN_ATTEMPTS=5
|
MAX_LOGIN_ATTEMPTS=5
|
||||||
LOCKOUT_DURATION_MINUTES=30
|
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
|
||||||
|
|||||||
231
BACKEND_TASKS.md
231
BACKEND_TASKS.md
@ -26,7 +26,8 @@ Use this checklist when building or extending the backend. Follow the build orde
|
|||||||
- [ ] Add common columns on every business table: `is_active`, `created_by`, `updated_by`, `created_at`, `updated_at`, `deleted_at`
|
- [ ] 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`
|
- [ ] Run `npx prisma migrate dev --name init`
|
||||||
- [x] Run `npx prisma generate`
|
- [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
|
### 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/logger.js` — Winston + daily rotate; redact sensitive keys
|
||||||
- [x] `src/config/morgan.js` — HTTP logs into Winston with request ID + user
|
- [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/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/utils/` — ApiError, ApiResponse, asyncHandler, encryption, auditLog, pagination, generateCode
|
||||||
- [x] `src/middlewares/` — requestId, error, validate, rateLimiter, auth, rbac, upload
|
- [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/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
|
- [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).
|
Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.service.js` → `*.validation.js` (+ `*.repository.js` for complex modules).
|
||||||
|
|
||||||
| # | Module | Notes |
|
| # | Module | Status | Notes |
|
||||||
|---|--------|-------|
|
|---|--------|--------|-------|
|
||||||
| 8 | **auth** | login, refresh, logout; rotating hashed refresh tokens; account lockout |
|
| 8 | **auth** | [x] Done | login, refresh, logout, me; rotating hashed refresh tokens; account lockout |
|
||||||
| 9 | **masters** | UOM first as template, then replicate 13 sub-masters under `masters/index.js` |
|
| 9 | **masters** | [x] Done | 14 sub-masters under `masters/index.js` (UOM template replicated) |
|
||||||
| 10 | **vendors** | CRUD + addresses/contacts/bank-details; encrypt bank account numbers |
|
| — | **users** | [x] Done | CRUD + summary/filters/export; RBAC: `USERS` |
|
||||||
| 11 | **purchase-orders** | Header + line items; repository for transactions; submit/approve/reject/amend/cancel |
|
| — | **roles** | [x] Done | CRUD + permission assignment + matrix; RBAC: `ROLES` |
|
||||||
| 12 | **grn** | Receipt + PO status recalc + asset auto-creation in one transaction |
|
| 10 | **vendors** | [x] Done | CRUD + addresses/contacts/bank-details; encrypt bank accounts |
|
||||||
| 13 | **assets** | CRUD + transfer + transfer history |
|
| 11 | **purchase-orders** | [x] Done | Header + line items; submit/approve/reject/amend/cancel; PDF export |
|
||||||
| — | **users** | CRUD, RBAC: `USERS` |
|
| 12 | **grn** | [x] Done | Receipt + PO status recalc + asset auto-creation |
|
||||||
| — | **roles** | CRUD + permission assignment, RBAC: `ROLES` |
|
| 13 | **assets** | [x] Done | CRUD + transfer + transfer history |
|
||||||
|
|
||||||
Current progress:
|
Current progress:
|
||||||
|
|
||||||
- [x] `auth` module scaffold (`login`, `refresh`, `logout`) implemented
|
- [x] `auth` module (`login`, `refresh`, `logout`)
|
||||||
- [x] `masters/uom` module implemented as template (CRUD + RBAC + validation + audit log)
|
- [x] `users` module (screen APIs: summary, filters, export, CRUD)
|
||||||
- [x] Replicated masters CRUD modules: `item-categories`, `brands`, `gst-rates`, `payment-terms`, `delivery-terms`, `asset-categories`, `departments`, `designations`, `document-series`
|
- [x] `roles` module (cards, permission catalog, matrix, CRUD)
|
||||||
- [x] Pending masters now completed: `item-subcategories`, `items`, `warehouses`, `plants`
|
- [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
|
### Layer rules
|
||||||
|
|
||||||
@ -88,18 +95,177 @@ Current progress:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. API Routes (Phase 1)
|
## 5. Module API List (Phase 1)
|
||||||
|
|
||||||
```
|
Base path: `/api/v1` · Auth: `Authorization: Bearer <accessToken>` (except public auth routes)
|
||||||
/api/v1/auth/{login,refresh,logout}
|
|
||||||
/api/v1/users
|
### System
|
||||||
/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}
|
| Status | Method | Endpoint | RBAC | Notes |
|
||||||
/api/v1/vendors (+ /addresses, /contacts, /bank-details)
|
|--------|--------|----------|------|-------|
|
||||||
/api/v1/purchase-orders (+ /submit, /approve, /reject, /amend, /cancel, /pdf)
|
| [x] | GET | `/health` | — | App health (root, not under v1) |
|
||||||
/api/v1/grn (+ /cancel, /pdf)
|
| [x] | GET | `/healthz` | — | API v1 health |
|
||||||
/api/v1/assets (+ /transfer, /transfer-history)
|
| [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] Rate limiting: global on `/api`, stricter on `/auth/login` and `/auth/forgot-password`
|
||||||
- [x] Helmet, CORS (no `*` in production), HPP, compression
|
- [x] Helmet, CORS (no `*` in production), HPP, compression
|
||||||
- [x] File uploads: MIME allow-list, random filenames, RBAC-protected download (not direct web serve)
|
- [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
|
- [x] `X-Request-Id` on every request for log correlation
|
||||||
|
|
||||||
### RBAC permission actions
|
### RBAC permission actions
|
||||||
@ -147,14 +313,15 @@ Current progress:
|
|||||||
- [ ] Auth: login, refresh, lockout
|
- [ ] Auth: login, refresh, lockout
|
||||||
- [ ] RBAC: allowed vs forbidden
|
- [ ] RBAC: allowed vs forbidden
|
||||||
- [ ] Vendor: CRUD + status transitions
|
- [ ] Vendor: CRUD + status transitions
|
||||||
- [ ] PO: status lifecycle
|
- [x] PO: status lifecycle
|
||||||
- [ ] GRN: partial receipt → PO status recalculation
|
- [x] GRN: partial receipt → PO status recalculation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Documentation & DevOps
|
## 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)
|
- [ ] `Dockerfile` (Node 20 Alpine)
|
||||||
- [ ] `docker-compose.yml` (API + Postgres 15)
|
- [ ] `docker-compose.yml` (API + Postgres 15)
|
||||||
- [ ] Verify `docker-compose up` boots API + Postgres
|
- [ ] Verify `docker-compose up` boots API + Postgres
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const { generalLimiter } = require('./middlewares/rateLimiter.middleware');
|
|||||||
const errorMiddleware = require('./middlewares/error.middleware');
|
const errorMiddleware = require('./middlewares/error.middleware');
|
||||||
const ApiError = require('./utils/ApiError');
|
const ApiError = require('./utils/ApiError');
|
||||||
const routesV1 = require('./routes/v1');
|
const routesV1 = require('./routes/v1');
|
||||||
|
const deploymentController = require('./modules/deployment/deployment.controller');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -28,6 +29,8 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ success: true, message: 'OK', uptime: process.uptime() });
|
res.json({ success: true, message: 'OK', uptime: process.uptime() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/deploy', deploymentController.deployment);
|
||||||
|
|
||||||
app.use('/api', generalLimiter);
|
app.use('/api', generalLimiter);
|
||||||
app.use('/api/v1', routesV1);
|
app.use('/api/v1', routesV1);
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,11 @@ const envSchema = Joi.object({
|
|||||||
|
|
||||||
MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
|
MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
|
||||||
LOCKOUT_DURATION_MINUTES: Joi.number().default(30),
|
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();
|
}).unknown();
|
||||||
|
|
||||||
const { error, value: env } = envSchema.validate(process.env);
|
const { error, value: env } = envSchema.validate(process.env);
|
||||||
|
|||||||
195
src/docs/assets-routes.yaml
Normal file
195
src/docs/assets-routes.yaml
Normal 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' }
|
||||||
@ -38,6 +38,31 @@ components:
|
|||||||
required: [refresh_token]
|
required: [refresh_token]
|
||||||
properties:
|
properties:
|
||||||
refresh_token: { type: string, example: "<refresh-token>" }
|
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:
|
UomCreateBody:
|
||||||
type: object
|
type: object
|
||||||
required: [code, name]
|
required: [code, name]
|
||||||
@ -363,10 +388,12 @@ components:
|
|||||||
module_id: { type: integer, example: 1 }
|
module_id: { type: integer, example: 1 }
|
||||||
actions:
|
actions:
|
||||||
type: object
|
type: object
|
||||||
required: [view, edit, approve, export]
|
required: [view, create, edit, delete, approve, export]
|
||||||
properties:
|
properties:
|
||||||
view: { type: boolean, example: true }
|
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 }
|
approve: { type: boolean, example: false }
|
||||||
export: { type: boolean, example: true }
|
export: { type: boolean, example: true }
|
||||||
|
|
||||||
@ -416,6 +443,23 @@ paths:
|
|||||||
description: Success
|
description: Success
|
||||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||||
"401": { description: Invalid or expired token }
|
"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:
|
/masters/uom:
|
||||||
get:
|
get:
|
||||||
tags: [UOM]
|
tags: [UOM]
|
||||||
@ -1617,7 +1661,7 @@ paths:
|
|||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
get:
|
get:
|
||||||
tags: [Roles]
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Matrix fetched
|
description: Matrix fetched
|
||||||
|
|||||||
155
src/docs/grn-routes.yaml
Normal file
155
src/docs/grn-routes.yaml
Normal 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 }
|
||||||
243
src/docs/purchase-orders-routes.yaml
Normal file
243
src/docs/purchase-orders-routes.yaml
Normal 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 }
|
||||||
411
src/docs/vendors-routes.yaml
Normal file
411
src/docs/vendors-routes.yaml
Normal 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 }
|
||||||
11
src/modules/assets/assets.constants.js
Normal file
11
src/modules/assets/assets.constants.js
Normal 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,
|
||||||
|
};
|
||||||
40
src/modules/assets/assets.controller.js
Normal file
40
src/modules/assets/assets.controller.js
Normal 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 };
|
||||||
33
src/modules/assets/assets.repository.js
Normal file
33
src/modules/assets/assets.repository.js
Normal 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 };
|
||||||
37
src/modules/assets/assets.routes.js
Normal file
37
src/modules/assets/assets.routes.js
Normal 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;
|
||||||
516
src/modules/assets/assets.service.js
Normal file
516
src/modules/assets/assets.service.js
Normal 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,
|
||||||
|
};
|
||||||
117
src/modules/assets/assets.validation.js
Normal file
117
src/modules/assets/assets.validation.js
Normal 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,
|
||||||
|
};
|
||||||
@ -17,4 +17,9 @@ const logout = asyncHandler(async (req, res) => {
|
|||||||
res.json(new ApiResponse(200, null, 'Logged out'));
|
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 };
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const authenticate = require('../../middlewares/auth.middleware');
|
||||||
const validate = require('../../middlewares/validate.middleware');
|
const validate = require('../../middlewares/validate.middleware');
|
||||||
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
|
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
|
||||||
const { loginSchema, refreshSchema } = require('./auth.validation');
|
const { loginSchema, refreshSchema } = require('./auth.validation');
|
||||||
@ -9,5 +10,6 @@ const router = express.Router();
|
|||||||
router.post('/login', authLimiter, validate(loginSchema), controller.login);
|
router.post('/login', authLimiter, validate(loginSchema), controller.login);
|
||||||
router.post('/refresh', validate(refreshSchema), controller.refresh);
|
router.post('/refresh', validate(refreshSchema), controller.refresh);
|
||||||
router.post('/logout', validate(refreshSchema), controller.logout);
|
router.post('/logout', validate(refreshSchema), controller.logout);
|
||||||
|
router.get('/me', authenticate, controller.me);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const crypto = require('crypto');
|
|||||||
const prisma = require('../../config/prisma');
|
const prisma = require('../../config/prisma');
|
||||||
const ApiError = require('../../utils/ApiError');
|
const ApiError = require('../../utils/ApiError');
|
||||||
const env = require('../../config/env');
|
const env = require('../../config/env');
|
||||||
|
const { decrypt } = require('../../utils/encryption');
|
||||||
|
|
||||||
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
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 };
|
||||||
|
|||||||
105
src/modules/deployment/deployment.controller.js
Normal file
105
src/modules/deployment/deployment.controller.js
Normal 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 };
|
||||||
17
src/modules/grn/grn.constants.js
Normal file
17
src/modules/grn/grn.constants.js
Normal 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,
|
||||||
|
};
|
||||||
37
src/modules/grn/grn.controller.js
Normal file
37
src/modules/grn/grn.controller.js
Normal 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 };
|
||||||
40
src/modules/grn/grn.poStatus.js
Normal file
40
src/modules/grn/grn.poStatus.js
Normal 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 };
|
||||||
125
src/modules/grn/grn.repository.js
Normal file
125
src/modules/grn/grn.repository.js
Normal 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,
|
||||||
|
};
|
||||||
26
src/modules/grn/grn.routes.js
Normal file
26
src/modules/grn/grn.routes.js
Normal 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;
|
||||||
432
src/modules/grn/grn.service.js
Normal file
432
src/modules/grn/grn.service.js
Normal 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,
|
||||||
|
};
|
||||||
73
src/modules/grn/grn.validation.js
Normal file
73
src/modules/grn/grn.validation.js
Normal 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,
|
||||||
|
};
|
||||||
47
src/modules/purchase-orders/purchase-orders.calculations.js
Normal file
47
src/modules/purchase-orders/purchase-orders.calculations.js
Normal 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 };
|
||||||
37
src/modules/purchase-orders/purchase-orders.constants.js
Normal file
37
src/modules/purchase-orders/purchase-orders.constants.js
Normal 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,
|
||||||
|
};
|
||||||
74
src/modules/purchase-orders/purchase-orders.controller.js
Normal file
74
src/modules/purchase-orders/purchase-orders.controller.js
Normal 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,
|
||||||
|
};
|
||||||
46
src/modules/purchase-orders/purchase-orders.repository.js
Normal file
46
src/modules/purchase-orders/purchase-orders.repository.js
Normal 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,
|
||||||
|
};
|
||||||
73
src/modules/purchase-orders/purchase-orders.routes.js
Normal file
73
src/modules/purchase-orders/purchase-orders.routes.js
Normal 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;
|
||||||
682
src/modules/purchase-orders/purchase-orders.service.js
Normal file
682
src/modules/purchase-orders/purchase-orders.service.js
Normal 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,
|
||||||
|
};
|
||||||
108
src/modules/purchase-orders/purchase-orders.validation.js
Normal file
108
src/modules/purchase-orders/purchase-orders.validation.js
Normal 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,
|
||||||
|
};
|
||||||
@ -23,7 +23,7 @@ router.get(
|
|||||||
controller.list
|
controller.list
|
||||||
);
|
);
|
||||||
router.get('/:id', authorize('ROLES', 'view'), controller.getOne);
|
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', authorize('ROLES', 'edit'), validate(updateRoleSchema), controller.update);
|
||||||
router.put(
|
router.put(
|
||||||
'/:id/permissions',
|
'/:id/permissions',
|
||||||
@ -38,6 +38,6 @@ router.put(
|
|||||||
validate(permissionMatrixSchema),
|
validate(permissionMatrixSchema),
|
||||||
controller.savePermissionMatrix
|
controller.savePermissionMatrix
|
||||||
);
|
);
|
||||||
router.delete('/:id', authorize('ROLES', 'edit'), controller.remove);
|
router.delete('/:id', authorize('ROLES', 'delete'), controller.remove);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -3,8 +3,7 @@ const ApiError = require('../../utils/ApiError');
|
|||||||
const auditLog = require('../../utils/auditLog');
|
const auditLog = require('../../utils/auditLog');
|
||||||
const { getPagination } = require('../../utils/pagination');
|
const { getPagination } = require('../../utils/pagination');
|
||||||
|
|
||||||
const MATRIX_ACTIONS = ['view', 'edit', 'approve', 'export'];
|
const PERMISSION_ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export'];
|
||||||
const MERGED_EDIT_ACTIONS = ['create', 'edit'];
|
|
||||||
|
|
||||||
const permissionSelect = {
|
const permissionSelect = {
|
||||||
permissions: {
|
permissions: {
|
||||||
@ -294,19 +293,7 @@ const getPermissionMatrix = async (id) => {
|
|||||||
|
|
||||||
const matrix = modules.map((mod) => {
|
const matrix = modules.map((mod) => {
|
||||||
const permissions = {};
|
const permissions = {};
|
||||||
for (const action of MATRIX_ACTIONS) {
|
for (const action of PERMISSION_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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const perm = mod.permissions.find((p) => p.action === action);
|
const perm = mod.permissions.find((p) => p.action === action);
|
||||||
permissions[action] = {
|
permissions[action] = {
|
||||||
permission_id: perm?.id ?? null,
|
permission_id: perm?.id ?? null,
|
||||||
@ -323,7 +310,7 @@ const getPermissionMatrix = async (id) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
role: { id: role.id, name: role.name },
|
role: { id: role.id, name: role.name },
|
||||||
actions: MATRIX_ACTIONS,
|
actions: PERMISSION_ACTIONS,
|
||||||
modules: matrix,
|
modules: matrix,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -340,22 +327,9 @@ const savePermissionMatrix = async (id, matrix, userId, requestId) => {
|
|||||||
|
|
||||||
const permissionIds = [];
|
const permissionIds = [];
|
||||||
for (const row of matrix) {
|
for (const row of matrix) {
|
||||||
for (const action of MATRIX_ACTIONS) {
|
for (const action of PERMISSION_ACTIONS) {
|
||||||
if (!row.actions?.[action]) continue;
|
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(
|
const perm = catalog.find(
|
||||||
(p) => p.module_id.toString() === String(row.module_id) && p.action === action
|
(p) => p.module_id.toString() === String(row.module_id) && p.action === action
|
||||||
);
|
);
|
||||||
|
|||||||
@ -23,7 +23,9 @@ const permissionMatrixSchema = Joi.object({
|
|||||||
module_id: Joi.number().integer().positive().required(),
|
module_id: Joi.number().integer().positive().required(),
|
||||||
actions: Joi.object({
|
actions: Joi.object({
|
||||||
view: Joi.boolean().required(),
|
view: Joi.boolean().required(),
|
||||||
|
create: Joi.boolean().required(),
|
||||||
edit: Joi.boolean().required(),
|
edit: Joi.boolean().required(),
|
||||||
|
delete: Joi.boolean().required(),
|
||||||
approve: Joi.boolean().required(),
|
approve: Joi.boolean().required(),
|
||||||
export: Joi.boolean().required(),
|
export: Joi.boolean().required(),
|
||||||
}).required(),
|
}).required(),
|
||||||
|
|||||||
155
src/modules/vendors/vendors.controller.js
vendored
Normal file
155
src/modules/vendors/vendors.controller.js
vendored
Normal 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
105
src/modules/vendors/vendors.routes.js
vendored
Normal 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
494
src/modules/vendors/vendors.service.js
vendored
Normal 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,
|
||||||
|
};
|
||||||
121
src/modules/vendors/vendors.validation.js
vendored
Normal file
121
src/modules/vendors/vendors.validation.js
vendored
Normal 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,
|
||||||
|
};
|
||||||
@ -5,6 +5,10 @@ const router = express.Router();
|
|||||||
router.use('/auth', require('../../modules/auth/auth.routes'));
|
router.use('/auth', require('../../modules/auth/auth.routes'));
|
||||||
router.use('/users', require('../../modules/users/users.routes'));
|
router.use('/users', require('../../modules/users/users.routes'));
|
||||||
router.use('/roles', require('../../modules/roles/roles.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.use('/masters', require('../../modules/masters'));
|
||||||
|
|
||||||
router.get('/healthz', (req, res) => {
|
router.get('/healthz', (req, res) => {
|
||||||
|
|||||||
38
src/utils/simplePdf.js
Normal file
38
src/utils/simplePdf.js
Normal 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 };
|
||||||
Loading…
Reference in New Issue
Block a user