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
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOCKOUT_DURATION_MINUTES=30
|
||||
|
||||
# Bitbucket deploy webhook (POST /deploy?apikey=...)
|
||||
BITBUCKET_API_KEY=
|
||||
EXPECTED_REPO=your-org/your-repo
|
||||
EXPECTED_BRANCH=main
|
||||
DEPLOY_SCRIPT=/path/to/deploy.sh
|
||||
|
||||
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`
|
||||
- [ ] Run `npx prisma migrate dev --name init`
|
||||
- [x] Run `npx prisma generate`
|
||||
- [ ] Implement idempotent `prisma/seed.js` (modules → permissions → roles → role_permissions → Super Admin user)
|
||||
- [x] Implement `prisma/seed.js` — bootstrap Super Admin user (modules/permissions/roles from DDL)
|
||||
- [ ] Full idempotent seed (modules → permissions → roles → role_permissions) if not using DDL
|
||||
|
||||
### Seed requirements
|
||||
|
||||
@ -45,12 +46,12 @@ Use this checklist when building or extending the backend. Follow the build orde
|
||||
- [x] `src/config/logger.js` — Winston + daily rotate; redact sensitive keys
|
||||
- [x] `src/config/morgan.js` — HTTP logs into Winston with request ID + user
|
||||
- [x] `src/config/prisma.js` — singleton client with query/error logging
|
||||
- [x] `src/config/swagger.js` — OpenAPI 3.0 from route JSDoc
|
||||
- [x] `src/config/swagger.js` — OpenAPI 3.0 from `src/docs/completed-routes.yaml`
|
||||
- [x] `src/utils/` — ApiError, ApiResponse, asyncHandler, encryption, auditLog, pagination, generateCode
|
||||
- [x] `src/middlewares/` — requestId, error, validate, rateLimiter, auth, rbac, upload
|
||||
- [x] `src/app.js` — Helmet, CORS, HPP, compression, rate limit, routes, Swagger, 404, error handler
|
||||
- [x] `src/server.js` — graceful shutdown, BigInt JSON serializer, unhandled rejection/exception handlers
|
||||
- [ ] Verify `GET /health` responds
|
||||
- [x] Verify `GET /health` responds
|
||||
|
||||
---
|
||||
|
||||
@ -58,23 +59,29 @@ Use this checklist when building or extending the backend. Follow the build orde
|
||||
|
||||
Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.service.js` → `*.validation.js` (+ `*.repository.js` for complex modules).
|
||||
|
||||
| # | Module | Notes |
|
||||
|---|--------|-------|
|
||||
| 8 | **auth** | login, refresh, logout; rotating hashed refresh tokens; account lockout |
|
||||
| 9 | **masters** | UOM first as template, then replicate 13 sub-masters under `masters/index.js` |
|
||||
| 10 | **vendors** | CRUD + addresses/contacts/bank-details; encrypt bank account numbers |
|
||||
| 11 | **purchase-orders** | Header + line items; repository for transactions; submit/approve/reject/amend/cancel |
|
||||
| 12 | **grn** | Receipt + PO status recalc + asset auto-creation in one transaction |
|
||||
| 13 | **assets** | CRUD + transfer + transfer history |
|
||||
| — | **users** | CRUD, RBAC: `USERS` |
|
||||
| — | **roles** | CRUD + permission assignment, RBAC: `ROLES` |
|
||||
| # | Module | Status | Notes |
|
||||
|---|--------|--------|-------|
|
||||
| 8 | **auth** | [x] Done | login, refresh, logout, me; rotating hashed refresh tokens; account lockout |
|
||||
| 9 | **masters** | [x] Done | 14 sub-masters under `masters/index.js` (UOM template replicated) |
|
||||
| — | **users** | [x] Done | CRUD + summary/filters/export; RBAC: `USERS` |
|
||||
| — | **roles** | [x] Done | CRUD + permission assignment + matrix; RBAC: `ROLES` |
|
||||
| 10 | **vendors** | [x] Done | CRUD + addresses/contacts/bank-details; encrypt bank accounts |
|
||||
| 11 | **purchase-orders** | [x] Done | Header + line items; submit/approve/reject/amend/cancel; PDF export |
|
||||
| 12 | **grn** | [x] Done | Receipt + PO status recalc + asset auto-creation |
|
||||
| 13 | **assets** | [x] Done | CRUD + transfer + transfer history |
|
||||
|
||||
Current progress:
|
||||
|
||||
- [x] `auth` module scaffold (`login`, `refresh`, `logout`) implemented
|
||||
- [x] `masters/uom` module implemented as template (CRUD + RBAC + validation + audit log)
|
||||
- [x] Replicated masters CRUD modules: `item-categories`, `brands`, `gst-rates`, `payment-terms`, `delivery-terms`, `asset-categories`, `departments`, `designations`, `document-series`
|
||||
- [x] Pending masters now completed: `item-subcategories`, `items`, `warehouses`, `plants`
|
||||
- [x] `auth` module (`login`, `refresh`, `logout`)
|
||||
- [x] `users` module (screen APIs: summary, filters, export, CRUD)
|
||||
- [x] `roles` module (cards, permission catalog, matrix, CRUD)
|
||||
- [x] `masters/uom` template + 13 replicated sub-masters
|
||||
- [x] Swagger docs for all completed routes (`src/docs/completed-routes.yaml`)
|
||||
- [x] `vendors` module (CRUD, status, addresses, contacts, bank-details)
|
||||
- [x] `purchase-orders` module (CRUD, workflow, PDF)
|
||||
- [x] `grn` module (transactional receipt, PO status recalc, asset auto-creation)
|
||||
- [x] `assets` module (CRUD, transfer, transfer history)
|
||||
- [x] `GET /auth/me` (current user + permissions for FE)
|
||||
|
||||
### Layer rules
|
||||
|
||||
@ -88,18 +95,177 @@ Current progress:
|
||||
|
||||
---
|
||||
|
||||
## 5. API Routes (Phase 1)
|
||||
## 5. Module API List (Phase 1)
|
||||
|
||||
```
|
||||
/api/v1/auth/{login,refresh,logout}
|
||||
/api/v1/users
|
||||
/api/v1/roles
|
||||
/api/v1/masters/{uom,item-categories,item-subcategories,items,brands,gst-rates,warehouses,payment-terms,delivery-terms,asset-categories,departments,designations,plants,document-series}
|
||||
/api/v1/vendors (+ /addresses, /contacts, /bank-details)
|
||||
/api/v1/purchase-orders (+ /submit, /approve, /reject, /amend, /cancel, /pdf)
|
||||
/api/v1/grn (+ /cancel, /pdf)
|
||||
/api/v1/assets (+ /transfer, /transfer-history)
|
||||
```
|
||||
Base path: `/api/v1` · Auth: `Authorization: Bearer <accessToken>` (except public auth routes)
|
||||
|
||||
### System
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/health` | — | App health (root, not under v1) |
|
||||
| [x] | GET | `/healthz` | — | API v1 health |
|
||||
| [x] | GET | `/api-docs` | — | Swagger UI |
|
||||
| [x] | GET | `/api-docs.json` | — | OpenAPI spec |
|
||||
|
||||
---
|
||||
|
||||
### Auth (`/auth`)
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | POST | `/auth/login` | Public | Returns `accessToken`, `refreshToken` |
|
||||
| [x] | POST | `/auth/refresh` | Public | Rotating refresh token |
|
||||
| [x] | POST | `/auth/logout` | Public | Revoke refresh token |
|
||||
| [ ] | POST | `/auth/forgot-password` | Public | Not implemented |
|
||||
| [x] | GET | `/auth/me` | Authenticated | Current user + permissions (for FE) |
|
||||
|
||||
---
|
||||
|
||||
### Users (`/users`) — module: `USERS`
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/users/summary` | view | Dashboard cards (total/active/inactive/locked, roles count) |
|
||||
| [x] | GET | `/users/filters` | view | Role, department, status dropdowns |
|
||||
| [x] | GET | `/users/export` | export | CSV export (same filters as list) |
|
||||
| [x] | GET | `/users` | view | Paginated list (`search`, `status`, `role_id`, `department_id`) |
|
||||
| [x] | GET | `/users/:id` | view | User detail |
|
||||
| [x] | POST | `/users` | create | Create user (bcrypt password, mobile encrypted) |
|
||||
| [x] | PUT | `/users/:id` | edit | Update user |
|
||||
| [x] | DELETE | `/users/:id` | delete | Soft delete |
|
||||
|
||||
---
|
||||
|
||||
### Roles (`/roles`) — module: `ROLES`
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/roles/permissions` | view | Permission catalog (modules × actions) |
|
||||
| [x] | GET | `/roles/cards` | view | Role cards (name, description, user/permission counts) |
|
||||
| [x] | GET | `/roles` | view | Paginated role list |
|
||||
| [x] | GET | `/roles/:id` | view | Role detail with permissions |
|
||||
| [x] | POST | `/roles` | create | Create role |
|
||||
| [x] | PUT | `/roles/:id` | edit | Update role |
|
||||
| [x] | DELETE | `/roles/:id` | delete | Soft delete (blocked if users assigned) |
|
||||
| [x] | PUT | `/roles/:id/permissions` | edit | Assign by `permission_ids[]` (replaces all) |
|
||||
| [x] | GET | `/roles/:id/permission-matrix` | view | Matrix UI (view/create/edit/delete/approve/export) |
|
||||
| [x] | PUT | `/roles/:id/permission-matrix` | edit | Save matrix checkboxes |
|
||||
|
||||
---
|
||||
|
||||
### Masters (`/masters/*`) — module: `MASTERS`
|
||||
|
||||
Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DELETE /:id` with RBAC `view|create|edit|delete`.
|
||||
|
||||
| Status | Sub-master | Base path |
|
||||
|--------|------------|-----------|
|
||||
| [x] | UOM | `/masters/uom` |
|
||||
| [x] | Item Categories | `/masters/item-categories` |
|
||||
| [x] | Item Subcategories | `/masters/item-subcategories` |
|
||||
| [x] | Items | `/masters/items` |
|
||||
| [x] | Brands | `/masters/brands` |
|
||||
| [x] | GST Rates | `/masters/gst-rates` |
|
||||
| [x] | Payment Terms | `/masters/payment-terms` |
|
||||
| [x] | Delivery Terms | `/masters/delivery-terms` |
|
||||
| [x] | Asset Categories | `/masters/asset-categories` |
|
||||
| [x] | Departments | `/masters/departments` |
|
||||
| [x] | Designations | `/masters/designations` |
|
||||
| [x] | Plants | `/masters/plants` |
|
||||
| [x] | Warehouses | `/masters/warehouses` |
|
||||
| [x] | Document Series | `/masters/document-series` |
|
||||
|
||||
**Masters total:** 14 modules × 5 endpoints = **70 APIs** [x]
|
||||
|
||||
---
|
||||
|
||||
### Vendors (`/vendors`) — module: `VENDOR`
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/vendors` | view | List (`search`, `status`, `vendor_type`) |
|
||||
| [x] | POST | `/vendors` | create | Auto `vendor_code` via VENDOR series |
|
||||
| [x] | GET | `/vendors/:id` | view | Detail + addresses, contacts, bank details |
|
||||
| [x] | PUT | `/vendors/:id` | edit | Update vendor |
|
||||
| [x] | PATCH | `/vendors/:id/status` | edit | `active` / `inactive` / `blacklisted` |
|
||||
| [x] | DELETE | `/vendors/:id` | delete | Soft delete |
|
||||
| [x] | GET | `/vendors/:vendorId/addresses` | view | List addresses |
|
||||
| [x] | POST | `/vendors/:vendorId/addresses` | create | Add address |
|
||||
| [x] | GET | `/vendors/:vendorId/addresses/:addressId` | view | Get address |
|
||||
| [x] | PUT | `/vendors/:vendorId/addresses/:addressId` | edit | Update address |
|
||||
| [x] | DELETE | `/vendors/:vendorId/addresses/:addressId` | delete | Deactivate address |
|
||||
| [x] | GET | `/vendors/:vendorId/contacts` | view | List contacts |
|
||||
| [x] | POST | `/vendors/:vendorId/contacts` | create | Add contact |
|
||||
| [x] | GET | `/vendors/:vendorId/contacts/:contactId` | view | Get contact |
|
||||
| [x] | PUT | `/vendors/:vendorId/contacts/:contactId` | edit | Update contact |
|
||||
| [x] | DELETE | `/vendors/:vendorId/contacts/:contactId` | delete | Deactivate contact |
|
||||
| [x] | GET | `/vendors/:vendorId/bank-details` | view | List bank details (decrypted) |
|
||||
| [x] | POST | `/vendors/:vendorId/bank-details` | create | Add bank detail (encrypted) |
|
||||
| [x] | GET | `/vendors/:vendorId/bank-details/:bankDetailId` | view | Get bank detail |
|
||||
| [x] | PUT | `/vendors/:vendorId/bank-details/:bankDetailId` | edit | Update bank detail |
|
||||
| [x] | DELETE | `/vendors/:vendorId/bank-details/:bankDetailId` | delete | Deactivate bank detail |
|
||||
|
||||
---
|
||||
|
||||
### Purchase Orders (`/purchase-orders`) — module: `PURCHASE_ORDER`
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/purchase-orders` | view | List POs |
|
||||
| [x] | GET | `/purchase-orders/:id` | view | PO detail + line items |
|
||||
| [x] | POST | `/purchase-orders` | create | Create PO |
|
||||
| [x] | PUT | `/purchase-orders/:id` | edit | Update PO |
|
||||
| [x] | DELETE | `/purchase-orders/:id` | delete | Soft delete |
|
||||
| [x] | POST | `/purchase-orders/:id/submit` | edit | Submit for approval |
|
||||
| [x] | POST | `/purchase-orders/:id/approve` | approve | Approve PO |
|
||||
| [x] | POST | `/purchase-orders/:id/reject` | approve | Reject PO |
|
||||
| [x] | POST | `/purchase-orders/:id/amend` | edit | Amend PO |
|
||||
| [x] | POST | `/purchase-orders/:id/cancel` | edit | Cancel PO |
|
||||
| [x] | GET | `/purchase-orders/:id/pdf` | view | PDF export |
|
||||
|
||||
---
|
||||
|
||||
### GRN (`/grn`) — module: `GRN`
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/grn` | view | List GRNs |
|
||||
| [x] | GET | `/grn/:id` | view | GRN detail + line items |
|
||||
| [x] | POST | `/grn` | create | Create GRN (transactional) |
|
||||
| [x] | PUT | `/grn/:id` | edit | Update GRN |
|
||||
| [x] | POST | `/grn/:id/cancel` | edit | Cancel GRN |
|
||||
| [x] | GET | `/grn/:id/pdf` | view | PDF export |
|
||||
|
||||
---
|
||||
|
||||
### Assets (`/assets`) — module: `ASSET`
|
||||
|
||||
| Status | Method | Endpoint | RBAC | Notes |
|
||||
|--------|--------|----------|------|-------|
|
||||
| [x] | GET | `/assets` | view | List assets |
|
||||
| [x] | GET | `/assets/:id` | view | Asset detail |
|
||||
| [x] | POST | `/assets` | create | Create asset |
|
||||
| [x] | PUT | `/assets/:id` | edit | Update asset |
|
||||
| [x] | DELETE | `/assets/:id` | delete | Soft delete |
|
||||
| [x] | POST | `/assets/:id/transfer` | edit | Transfer asset |
|
||||
| [x] | GET | `/assets/:id/transfer-history` | view | Transfer history |
|
||||
|
||||
---
|
||||
|
||||
### API progress summary
|
||||
|
||||
| Module | Endpoints done | Endpoints total | Status |
|
||||
|--------|----------------|-----------------|--------|
|
||||
| System | 4 | 4 | [x] Done |
|
||||
| Auth | 3 | 5 | [ ] Partial |
|
||||
| Users | 8 | 8 | [x] Done |
|
||||
| Roles | 10 | 10 | [x] Done |
|
||||
| Masters | 70 | 70 | [x] Done |
|
||||
| Vendors | 20 | 20 | [x] Done |
|
||||
| Purchase Orders | 11 | 11 | [x] Done |
|
||||
| GRN | 6 | 6 | [x] Done |
|
||||
| Assets | 7 | 7 | [x] Done |
|
||||
| **Total** | **139** | **139** | **[x] Phase 1 APIs complete** |
|
||||
|
||||
---
|
||||
|
||||
@ -114,7 +280,7 @@ Current progress:
|
||||
- [x] Rate limiting: global on `/api`, stricter on `/auth/login` and `/auth/forgot-password`
|
||||
- [x] Helmet, CORS (no `*` in production), HPP, compression
|
||||
- [x] File uploads: MIME allow-list, random filenames, RBAC-protected download (not direct web serve)
|
||||
- [ ] `auditLog()` on every create/update/status-change/approve/reject
|
||||
- [x] `auditLog()` on every create/update/status-change/approve/reject (completed modules)
|
||||
- [x] `X-Request-Id` on every request for log correlation
|
||||
|
||||
### RBAC permission actions
|
||||
@ -147,14 +313,15 @@ Current progress:
|
||||
- [ ] Auth: login, refresh, lockout
|
||||
- [ ] RBAC: allowed vs forbidden
|
||||
- [ ] Vendor: CRUD + status transitions
|
||||
- [ ] PO: status lifecycle
|
||||
- [ ] GRN: partial receipt → PO status recalculation
|
||||
- [x] PO: status lifecycle
|
||||
- [x] GRN: partial receipt → PO status recalculation
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation & DevOps
|
||||
|
||||
- [ ] Add `@swagger` JSDoc blocks to every route file
|
||||
- [x] OpenAPI spec for completed routes (`src/docs/completed-routes.yaml` + `/api-docs`)
|
||||
- [ ] Add `@swagger` JSDoc blocks to route files (optional; YAML spec in use)
|
||||
- [ ] `Dockerfile` (Node 20 Alpine)
|
||||
- [ ] `docker-compose.yml` (API + Postgres 15)
|
||||
- [ ] Verify `docker-compose up` boots API + Postgres
|
||||
|
||||
@ -12,6 +12,7 @@ const { generalLimiter } = require('./middlewares/rateLimiter.middleware');
|
||||
const errorMiddleware = require('./middlewares/error.middleware');
|
||||
const ApiError = require('./utils/ApiError');
|
||||
const routesV1 = require('./routes/v1');
|
||||
const deploymentController = require('./modules/deployment/deployment.controller');
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -28,6 +29,8 @@ app.get('/health', (req, res) => {
|
||||
res.json({ success: true, message: 'OK', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
app.post('/deploy', deploymentController.deployment);
|
||||
|
||||
app.use('/api', generalLimiter);
|
||||
app.use('/api/v1', routesV1);
|
||||
|
||||
|
||||
@ -35,6 +35,11 @@ const envSchema = Joi.object({
|
||||
|
||||
MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
|
||||
LOCKOUT_DURATION_MINUTES: Joi.number().default(30),
|
||||
|
||||
BITBUCKET_API_KEY: Joi.string().allow('').optional(),
|
||||
EXPECTED_REPO: Joi.string().allow('').optional(),
|
||||
EXPECTED_BRANCH: Joi.string().allow('').optional(),
|
||||
DEPLOY_SCRIPT: Joi.string().allow('').optional(),
|
||||
}).unknown();
|
||||
|
||||
const { error, value: env } = envSchema.validate(process.env);
|
||||
|
||||
195
src/docs/assets-routes.yaml
Normal file
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]
|
||||
properties:
|
||||
refresh_token: { type: string, example: "<refresh-token>" }
|
||||
AuthMeData:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, example: "1" }
|
||||
employee_code: { type: string, example: EMP001 }
|
||||
full_name: { type: string, example: Super Admin }
|
||||
email: { type: string, format: email, example: "admin@bharaterp.com" }
|
||||
mobile: { type: string, nullable: true, example: "9876543210" }
|
||||
status: { type: string, example: active }
|
||||
is_active: { type: boolean, example: true }
|
||||
last_login_at: { type: string, format: date-time, nullable: true }
|
||||
role:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
description: { type: string, nullable: true }
|
||||
department: { type: object, nullable: true }
|
||||
designation: { type: object, nullable: true }
|
||||
plant: { type: object, nullable: true }
|
||||
permissions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
example: ["USERS:view", "ROLES:edit", "VENDOR:create"]
|
||||
UomCreateBody:
|
||||
type: object
|
||||
required: [code, name]
|
||||
@ -363,10 +388,12 @@ components:
|
||||
module_id: { type: integer, example: 1 }
|
||||
actions:
|
||||
type: object
|
||||
required: [view, edit, approve, export]
|
||||
required: [view, create, edit, delete, approve, export]
|
||||
properties:
|
||||
view: { type: boolean, example: true }
|
||||
edit: { type: boolean, example: true, description: 'Add & edit (grants create + edit)' }
|
||||
create: { type: boolean, example: true }
|
||||
edit: { type: boolean, example: true }
|
||||
delete: { type: boolean, example: false }
|
||||
approve: { type: boolean, example: false }
|
||||
export: { type: boolean, example: true }
|
||||
|
||||
@ -416,6 +443,23 @@ paths:
|
||||
description: Success
|
||||
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
|
||||
"401": { description: Invalid or expired token }
|
||||
/auth/me:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Get current user profile and permissions
|
||||
description: Returns logged-in user details and flat permission codes for FE gating (e.g. USERS:view)
|
||||
responses:
|
||||
"200":
|
||||
description: Current user fetched
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ApiResponse"
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: "#/components/schemas/AuthMeData" }
|
||||
"401": { description: Unauthorized }
|
||||
/masters/uom:
|
||||
get:
|
||||
tags: [UOM]
|
||||
@ -1617,7 +1661,7 @@ paths:
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [Roles]
|
||||
summary: Permission matrix for role (modules x view/edit/approve/export; edit = add + edit)
|
||||
summary: Permission matrix for role (modules x view/create/edit/delete/approve/export)
|
||||
responses:
|
||||
"200":
|
||||
description: Matrix fetched
|
||||
|
||||
155
src/docs/grn-routes.yaml
Normal file
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'));
|
||||
});
|
||||
|
||||
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 authenticate = require('../../middlewares/auth.middleware');
|
||||
const validate = require('../../middlewares/validate.middleware');
|
||||
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
|
||||
const { loginSchema, refreshSchema } = require('./auth.validation');
|
||||
@ -9,5 +10,6 @@ const router = express.Router();
|
||||
router.post('/login', authLimiter, validate(loginSchema), controller.login);
|
||||
router.post('/refresh', validate(refreshSchema), controller.refresh);
|
||||
router.post('/logout', validate(refreshSchema), controller.logout);
|
||||
router.get('/me', authenticate, controller.me);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||
const prisma = require('../../config/prisma');
|
||||
const ApiError = require('../../utils/ApiError');
|
||||
const env = require('../../config/env');
|
||||
const { decrypt } = require('../../utils/encryption');
|
||||
|
||||
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
@ -91,4 +92,61 @@ const logout = async (refreshToken) => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { login, refresh, logout };
|
||||
const buildPermissionList = (rolePermissions = []) => {
|
||||
const codes = rolePermissions
|
||||
.filter(
|
||||
(rp) =>
|
||||
rp.permissions?.is_active &&
|
||||
rp.permissions?.modules?.is_active &&
|
||||
rp.permissions?.modules?.code &&
|
||||
rp.permissions?.action
|
||||
)
|
||||
.map((rp) => `${rp.permissions.modules.code}:${rp.permissions.action}`);
|
||||
|
||||
return [...new Set(codes)];
|
||||
};
|
||||
|
||||
const getMe = async (userId) => {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: { id: BigInt(userId), deleted_at: null },
|
||||
include: {
|
||||
roles_users_role_idToroles: {
|
||||
include: {
|
||||
role_permissions: {
|
||||
include: {
|
||||
permissions: { include: { modules: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
departments: { select: { id: true, name: true } },
|
||||
designations: { select: { id: true, name: true } },
|
||||
plants_users_plant_idToplants: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.is_active || user.status !== 'active') {
|
||||
throw new ApiError(401, 'Invalid or inactive user');
|
||||
}
|
||||
|
||||
const role = user.roles_users_role_idToroles;
|
||||
const permissions = buildPermissionList(role?.role_permissions);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
employee_code: user.employee_code,
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
mobile: user.mobile ? decrypt(user.mobile) : null,
|
||||
status: user.status,
|
||||
is_active: user.is_active,
|
||||
last_login_at: user.last_login_at,
|
||||
role: role ? { id: role.id, name: role.name, description: role.description } : null,
|
||||
department: user.departments,
|
||||
designation: user.designations,
|
||||
plant: user.plants_users_plant_idToplants,
|
||||
permissions,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { login, refresh, logout, getMe };
|
||||
|
||||
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
|
||||
);
|
||||
router.get('/:id', authorize('ROLES', 'view'), controller.getOne);
|
||||
router.post('/', authorize('ROLES', 'edit'), validate(createRoleSchema), controller.create);
|
||||
router.post('/', authorize('ROLES', 'create'), validate(createRoleSchema), controller.create);
|
||||
router.put('/:id', authorize('ROLES', 'edit'), validate(updateRoleSchema), controller.update);
|
||||
router.put(
|
||||
'/:id/permissions',
|
||||
@ -38,6 +38,6 @@ router.put(
|
||||
validate(permissionMatrixSchema),
|
||||
controller.savePermissionMatrix
|
||||
);
|
||||
router.delete('/:id', authorize('ROLES', 'edit'), controller.remove);
|
||||
router.delete('/:id', authorize('ROLES', 'delete'), controller.remove);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -3,8 +3,7 @@ const ApiError = require('../../utils/ApiError');
|
||||
const auditLog = require('../../utils/auditLog');
|
||||
const { getPagination } = require('../../utils/pagination');
|
||||
|
||||
const MATRIX_ACTIONS = ['view', 'edit', 'approve', 'export'];
|
||||
const MERGED_EDIT_ACTIONS = ['create', 'edit'];
|
||||
const PERMISSION_ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export'];
|
||||
|
||||
const permissionSelect = {
|
||||
permissions: {
|
||||
@ -294,19 +293,7 @@ const getPermissionMatrix = async (id) => {
|
||||
|
||||
const matrix = modules.map((mod) => {
|
||||
const permissions = {};
|
||||
for (const action of MATRIX_ACTIONS) {
|
||||
if (action === 'edit') {
|
||||
const createPerm = mod.permissions.find((p) => p.action === 'create');
|
||||
const editPerm = mod.permissions.find((p) => p.action === 'edit');
|
||||
const merged = [createPerm, editPerm].filter(Boolean);
|
||||
permissions.edit = {
|
||||
permission_id: editPerm?.id ?? createPerm?.id ?? null,
|
||||
permission_ids: merged.map((p) => p.id),
|
||||
granted: merged.some((p) => granted.has(p.id.toString())),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const action of PERMISSION_ACTIONS) {
|
||||
const perm = mod.permissions.find((p) => p.action === action);
|
||||
permissions[action] = {
|
||||
permission_id: perm?.id ?? null,
|
||||
@ -323,7 +310,7 @@ const getPermissionMatrix = async (id) => {
|
||||
|
||||
return {
|
||||
role: { id: role.id, name: role.name },
|
||||
actions: MATRIX_ACTIONS,
|
||||
actions: PERMISSION_ACTIONS,
|
||||
modules: matrix,
|
||||
};
|
||||
};
|
||||
@ -340,22 +327,9 @@ const savePermissionMatrix = async (id, matrix, userId, requestId) => {
|
||||
|
||||
const permissionIds = [];
|
||||
for (const row of matrix) {
|
||||
for (const action of MATRIX_ACTIONS) {
|
||||
for (const action of PERMISSION_ACTIONS) {
|
||||
if (!row.actions?.[action]) continue;
|
||||
|
||||
if (action === 'edit') {
|
||||
for (const dbAction of MERGED_EDIT_ACTIONS) {
|
||||
const perm = catalog.find(
|
||||
(p) => p.module_id.toString() === String(row.module_id) && p.action === dbAction
|
||||
);
|
||||
if (!perm) {
|
||||
throw new ApiError(422, `Permission not found for module ${row.module_id}:${dbAction}`);
|
||||
}
|
||||
permissionIds.push(perm.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const perm = catalog.find(
|
||||
(p) => p.module_id.toString() === String(row.module_id) && p.action === action
|
||||
);
|
||||
|
||||
@ -23,7 +23,9 @@ const permissionMatrixSchema = Joi.object({
|
||||
module_id: Joi.number().integer().positive().required(),
|
||||
actions: Joi.object({
|
||||
view: Joi.boolean().required(),
|
||||
create: Joi.boolean().required(),
|
||||
edit: Joi.boolean().required(),
|
||||
delete: Joi.boolean().required(),
|
||||
approve: Joi.boolean().required(),
|
||||
export: Joi.boolean().required(),
|
||||
}).required(),
|
||||
|
||||
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('/users', require('../../modules/users/users.routes'));
|
||||
router.use('/roles', require('../../modules/roles/roles.routes'));
|
||||
router.use('/vendors', require('../../modules/vendors/vendors.routes'));
|
||||
router.use('/purchase-orders', require('../../modules/purchase-orders/purchase-orders.routes'));
|
||||
router.use('/grn', require('../../modules/grn/grn.routes'));
|
||||
router.use('/assets', require('../../modules/assets/assets.routes'));
|
||||
router.use('/masters', require('../../modules/masters'));
|
||||
|
||||
router.get('/healthz', (req, res) => {
|
||||
|
||||
38
src/utils/simplePdf.js
Normal file
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