GWM : added some of missing columns

This commit is contained in:
Gowtham M 2026-07-04 10:11:45 +05:30
parent 6562c49957
commit ab3e0f0e1b
63 changed files with 2551 additions and 282 deletions

View File

@ -62,13 +62,14 @@ Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.ser
| # | 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) |
| 9 | **masters** | [x] Done | 15 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 + AMC/service visits/insurance + expiry alerts |
| 14 | **settings** | [x] Done | Company profile + SMTP email settings; RBAC: `SETTINGS` |
Current progress:
@ -169,6 +170,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| [x] | Payment Terms | `/masters/payment-terms` |
| [x] | Delivery Terms | `/masters/delivery-terms` |
| [x] | Asset Categories | `/masters/asset-categories` |
| [x] | Asset Subcategories | `/masters/asset-subcategories` | Filter: `asset_category_id` |
| [x] | Departments | `/masters/departments` |
| [x] | Designations | `/masters/designations` |
| [x] | Locations | `/masters/locations` | Unified plants + warehouses (`type`: `plant` \| `warehouse`) |
@ -185,8 +187,10 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Status | Method | Endpoint | RBAC | Notes |
|--------|--------|----------|------|-------|
| [x] | GET | `/vendors` | view | List (`search`, `status`, `vendor_type`) |
| [x] | GET | `/vendors/gst-treatments` | view | GST treatment dropdown options |
| [x] | GET | `/vendors/source-of-supply` | view | Indian states/UTs dropdown options |
| [x] | POST | `/vendors` | create | Auto `vendor_code` via VENDOR series |
| [x] | GET | `/vendors/:id` | view | Detail + addresses, contacts, bank details |
| [x] | GET | `/vendors/:id` | view | Detail + addresses, contacts, bank details, item mappings |
| [x] | PUT | `/vendors/:id` | edit | Update vendor |
| [x] | PATCH | `/vendors/:id/status` | edit | `active` / `inactive` / `blacklisted` |
| [x] | DELETE | `/vendors/:id` | delete | Soft delete |
@ -205,6 +209,11 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| [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 |
| [x] | GET | `/vendors/:vendorId/items` | view | List vendoritem mappings |
| [x] | POST | `/vendors/:vendorId/items` | create | Map item to vendor |
| [x] | GET | `/vendors/:vendorId/items/:mappingId` | view | Get item mapping |
| [x] | PUT | `/vendors/:vendorId/items/:mappingId` | edit | Update rate / preferred flag |
| [x] | DELETE | `/vendors/:vendorId/items/:mappingId` | delete | Deactivate item mapping |
---
@ -288,6 +297,22 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| [x] | GET | `/assets/alerts/expiry` | view | AMC/Insurance/Warranty expiry (`?days=30/60/90&type=`) |
| [x] | GET | `/assets/alerts/service` | view | Overdue/upcoming service (`?status=OVERDUE`) |
---
### Settings (`/settings`) — module: `SETTINGS`
| Status | Method | Endpoint | RBAC | Notes |
|--------|--------|----------|------|-------|
| [x] | GET | `/settings/company` | view | Company profile (org name, contact, address, logo URL) |
| [x] | PUT | `/settings/company` | edit | Update company profile |
| [x] | POST | `/settings/company/logo` | edit | Upload logo (`multipart/form-data`, field `logo`) |
| [x] | GET | `/settings/email` | view | SMTP settings (`has_smtp_password` flag; password never returned) |
| [x] | PUT | `/settings/email` | edit | Update SMTP settings (password encrypted at rest) |
**DB patch:** `scripts/patch-company-email-settings.sql` — creates `company` + `email_settings` singleton tables and `SETTINGS` module permissions for Super Admin.
---
**DB patch:** run `scripts/patch-assets-amc-insurance.sql` then `scripts/patch-assets-views.sql` on deployed DB.
---
@ -301,11 +326,12 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Users | 8 | 8 | [x] Done |
| Roles | 10 | 10 | [x] Done |
| Masters | 70 | 70 | [x] Done |
| Vendors | 20 | 20 | [x] Done |
| Vendors | 27 | 27 | [x] Done |
| Purchase Orders | 11 | 11 | [x] Done |
| GRN | 6 | 6 | [x] Done |
| Assets | 24 | 24 | [x] Done |
| **Total** | **156** | **156** | **[x] Phase 1 APIs + Asset extensions** |
| Settings | 5 | 5 | [x] Done |
| **Total** | **168** | **168** | **[x] Phase 1 APIs + Asset extensions** |
---

View File

@ -151,9 +151,29 @@ model asset_categories {
deleted_at DateTime? @db.Timestamptz(6)
users_asset_categories_created_byTousers users? @relation("asset_categories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_asset_categories_updated_byTousers users? @relation("asset_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
asset_subcategories asset_subcategories[]
assets assets[]
}
model asset_subcategories {
id BigInt @id @default(autoincrement())
asset_category_id BigInt
code String @unique @db.VarChar(30)
name String @db.VarChar(150)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_asset_subcategories_created_byTousers users? @relation("asset_subcategories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
users_asset_subcategories_updated_byTousers users? @relation("asset_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
assets assets[]
@@index([asset_category_id], map: "idx_asset_subcategories_category_id")
}
model asset_transfers {
id BigInt @id @default(autoincrement())
asset_id BigInt
@ -189,6 +209,7 @@ model assets {
asset_code String @unique @db.VarChar(30)
asset_name String @db.VarChar(200)
asset_category_id BigInt
asset_subcategory_id BigInt?
brand_model String? @db.VarChar(200)
manufacturer String? @db.VarChar(200)
serial_number String? @db.VarChar(100)
@ -205,7 +226,8 @@ model assets {
purchase_date DateTime? @db.Date
purchase_cost Decimal @default(0) @db.Decimal(15, 4)
useful_life_years Int?
depreciation_method String? @db.VarChar(10)
depreciation_method String? @db.VarChar(20)
depreciation_rate Decimal? @db.Decimal(8, 4)
salvage_value Decimal? @default(0) @db.Decimal(15, 4)
warranty_expiry_date DateTime? @db.Date
condition String @default("NEW") @db.VarChar(20)
@ -227,6 +249,7 @@ model assets {
asset_service_visits asset_service_visits[]
asset_transfers asset_transfers[]
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
asset_subcategories asset_subcategories? @relation(fields: [asset_subcategory_id], references: [id], onUpdate: NoAction)
users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction)
users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction)
@ -239,6 +262,7 @@ model assets {
warehouse locations? @relation("AssetWarehouseLocation", fields: [warehouse_id], references: [id], onUpdate: NoAction)
@@index([asset_category_id], map: "idx_assets_category_id")
@@index([asset_subcategory_id], map: "idx_assets_subcategory_id")
@@index([department_id], map: "idx_assets_dept_id")
@@index([plant_id], map: "idx_assets_plant_id")
}
@ -695,6 +719,10 @@ model purchase_orders {
tax_total Decimal @default(0) @db.Decimal(15, 4)
freight_charges Decimal @default(0) @db.Decimal(15, 4)
other_charges Decimal @default(0) @db.Decimal(15, 4)
tds_applicable Boolean @default(false)
tds_section_pct Decimal? @db.Decimal(5, 2)
tds_amount Decimal @default(0) @db.Decimal(15, 4)
adjustment Decimal @default(0) @db.Decimal(15, 4)
grand_total Decimal @default(0) @db.Decimal(15, 4)
status String @default("DRAFT") @db.VarChar(30)
revision_no Int @default(0)
@ -830,6 +858,8 @@ model users {
asset_service_visits_asset_service_visits_updated_byTousers asset_service_visits[] @relation("asset_service_visits_updated_byTousers")
asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers")
asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers")
asset_subcategories_asset_subcategories_created_byTousers asset_subcategories[] @relation("asset_subcategories_created_byTousers")
asset_subcategories_asset_subcategories_updated_byTousers asset_subcategories[] @relation("asset_subcategories_updated_byTousers")
asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers")
asset_transfers_asset_transfers_to_user_idTousers asset_transfers[] @relation("asset_transfers_to_user_idTousers")
asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers")
@ -978,6 +1008,8 @@ model vendors {
vendor_code String @unique @db.VarChar(30)
vendor_name String @db.VarChar(200)
vendor_type String @db.VarChar(30)
gst_treatment String? @db.VarChar(30)
source_of_supply String? @db.VarChar(100)
gstin String? @db.VarChar(15)
pan String? @db.VarChar(10)
payment_term_id BigInt?
@ -1005,3 +1037,34 @@ model vendors {
@@index([vendor_name(ops: raw("gin_trgm_ops"))], map: "idx_vendors_name_trgm", type: Gin)
}
model company {
id BigInt @id @default(1)
org_name String? @db.VarChar(200)
mobile String? @db.VarChar(20)
email String? @db.VarChar(200)
website String? @db.VarChar(255)
logo_path String? @db.VarChar(500)
address String?
city String? @db.VarChar(100)
state String? @db.VarChar(100)
pincode String? @db.VarChar(10)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
}
model email_settings {
id BigInt @id @default(1)
smtp_host String? @db.VarChar(255)
smtp_port Int @default(587)
smtp_username String? @db.VarChar(200)
smtp_password String? @db.VarChar(500)
sender_email String? @db.VarChar(200)
sender_name String? @db.VarChar(200)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
}

View File

@ -0,0 +1,40 @@
-- Asset Sub-Categories master + assets.asset_subcategory_id
CREATE TABLE IF NOT EXISTS asset_subcategories (
id BIGSERIAL PRIMARY KEY,
asset_category_id BIGINT NOT NULL REFERENCES asset_categories (id),
code VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(150) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_by BIGINT,
updated_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_asset_subcategories_category_id
ON asset_subcategories (asset_category_id);
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS asset_subcategory_id BIGINT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'assets_asset_subcategory_id_fkey'
) THEN
ALTER TABLE assets
ADD CONSTRAINT assets_asset_subcategory_id_fkey
FOREIGN KEY (asset_subcategory_id)
REFERENCES asset_subcategories (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_assets_subcategory_id
ON assets (asset_subcategory_id);
COMMENT ON TABLE asset_subcategories IS 'Asset sub-categories under asset_categories';
COMMENT ON COLUMN assets.asset_subcategory_id IS 'Required for new assets via API; nullable for legacy rows';

View File

@ -0,0 +1,10 @@
-- Depreciation rate on assets + widen depreciation_method for OTHER
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS depreciation_rate NUMERIC(8, 4);
ALTER TABLE assets
ALTER COLUMN depreciation_method TYPE VARCHAR(20);
COMMENT ON COLUMN assets.depreciation_method IS 'SLM, WDV, OTHER';
COMMENT ON COLUMN assets.depreciation_rate IS 'Annual depreciation rate percentage';

View File

@ -0,0 +1,59 @@
-- Company profile + email (SMTP) settings + SETTINGS RBAC module
CREATE TABLE IF NOT EXISTS company (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
org_name VARCHAR(200),
mobile VARCHAR(20),
email VARCHAR(200),
website VARCHAR(255),
logo_path VARCHAR(500),
address TEXT,
city VARCHAR(100),
state VARCHAR(100),
pincode VARCHAR(10),
created_by BIGINT,
updated_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS email_settings (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
smtp_host VARCHAR(255),
smtp_port INTEGER NOT NULL DEFAULT 587,
smtp_username VARCHAR(200),
smtp_password VARCHAR(500),
sender_email VARCHAR(200),
sender_name VARCHAR(200),
created_by BIGINT,
updated_by BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO company (id, org_name)
VALUES (1, '')
ON CONFLICT (id) DO NOTHING;
INSERT INTO email_settings (id, smtp_port)
VALUES (1, 587)
ON CONFLICT (id) DO NOTHING;
INSERT INTO modules (code, name, sort_order, is_active)
VALUES ('SETTINGS', 'Settings', 90, true)
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, is_active = true;
INSERT INTO permissions (module_id, action, is_active)
SELECT m.id, a.action, true
FROM modules m
CROSS JOIN (VALUES ('view'), ('edit')) AS a(action)
WHERE m.code = 'SETTINGS'
ON CONFLICT (module_id, action) DO UPDATE SET is_active = true;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.action IN ('view', 'edit')
JOIN modules m ON m.id = p.module_id AND m.code = 'SETTINGS'
WHERE r.name = 'Super Admin' AND r.deleted_at IS NULL
ON CONFLICT (role_id, permission_id) DO NOTHING;

View File

@ -0,0 +1,12 @@
-- Add TDS and adjustment fields to purchase_orders
ALTER TABLE purchase_orders
ADD COLUMN IF NOT EXISTS tds_applicable BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS tds_section_pct NUMERIC(5, 2),
ADD COLUMN IF NOT EXISTS tds_amount NUMERIC(15, 4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS adjustment NUMERIC(15, 4) NOT NULL DEFAULT 0;
COMMENT ON COLUMN purchase_orders.tds_applicable IS 'Whether TDS is applicable on this PO';
COMMENT ON COLUMN purchase_orders.tds_section_pct IS 'TDS percentage when tds_applicable is true';
COMMENT ON COLUMN purchase_orders.tds_amount IS 'Computed TDS amount deducted from grand total';
COMMENT ON COLUMN purchase_orders.adjustment IS 'Manual +/- amount adjustment applied to grand total';

View File

@ -0,0 +1,8 @@
-- Add GST Treatment and Source of Supply columns to vendors
ALTER TABLE vendors
ADD COLUMN IF NOT EXISTS gst_treatment VARCHAR(30),
ADD COLUMN IF NOT EXISTS source_of_supply VARCHAR(100);
COMMENT ON COLUMN vendors.gst_treatment IS 'REGULAR, COMPOSITION, UNREGISTERED, CONSUMER, SEZ, OVERSEAS, DEEMED_EXPORT, OTHER';
COMMENT ON COLUMN vendors.source_of_supply IS 'Indian state or union territory name';

View File

@ -1,3 +1,4 @@
const path = require('path');
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
@ -25,6 +26,8 @@ app.use(express.urlencoded({ extended: true }));
app.use(requestId);
app.use(morganMiddleware);
app.use('/uploads', express.static(path.resolve(env.UPLOAD_DIR)));
app.get('/health', (req, res) => {
res.json({ success: true, message: 'OK', uptime: process.uptime() });
});

View File

@ -5,13 +5,96 @@ components:
schemas:
AssetsCreateBody:
type: object
required: [asset_name, asset_category_id, plant_id]
required: [asset_name, asset_category_id, asset_subcategory_id, plant_id]
properties:
asset_name: { type: string, example: 'CNC Lathe Machine' }
asset_category_id: { type: integer, example: 1 }
asset_subcategory_id: { type: integer, example: 1 }
plant_id: { type: integer, example: 1 }
warranty_expiry_date: { type: string, format: date, nullable: true }
brand_model: { type: string }
manufacturer: { type: string }
serial_number: { type: string }
part_number: { type: string }
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, example: 850000 }
useful_life_years: { type: integer, example: 10 }
depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: SLM }
depreciation_rate: { type: number, nullable: true, example: 10, description: 'Required for OTHER; auto-calculated for SLM/WDV when omitted' }
salvage_value: { type: number, example: 50000 }
warranty_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 }
remarks: { type: string }
is_active: { type: boolean }
AssetsUpdateBody:
type: object
minProperties: 1
properties:
asset_name: { type: string }
asset_category_id: { type: integer }
asset_subcategory_id: { type: integer }
plant_id: { type: integer }
brand_model: { type: string }
manufacturer: { type: string }
serial_number: { type: string }
part_number: { type: string }
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 }
depreciation_method: { type: string, enum: [SLM, WDV, OTHER] }
depreciation_rate: { type: number, nullable: true }
salvage_value: { type: number }
warranty_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 }
remarks: { type: string }
is_active: { type: boolean }
DepreciationCalculateBody:
type: object
required: [depreciation_method]
properties:
depreciation_method: { type: string, enum: [SLM, WDV, OTHER] }
depreciation_rate: { type: number, nullable: true }
purchase_cost: { type: number, example: 850000 }
salvage_value: { type: number, example: 50000 }
useful_life_years: { type: integer, example: 10 }
purchase_date: { type: string, format: date }
as_of_date: { type: string, format: date }
TransferAssetBody:
type: object
required: [transfer_date]
properties:
transfer_date: { type: string, format: date }
to_plant_id: { type: integer, nullable: true }
to_department_id: { type: integer, nullable: true }
to_user_id: { type: integer, nullable: true }
to_warehouse_id: { type: integer, nullable: true }
reason: { type: string }
description: At least one of to_plant_id, to_department_id, to_user_id, to_warehouse_id is required
AmcContractBody:
type: object
required: [vendor_id, start_date, end_date]
@ -21,29 +104,196 @@ components:
contract_type: { type: string, enum: [COMPREHENSIVE, LABOUR_ONLY, PARTS_ONLY, PREVENTIVE_ONLY] }
start_date: { type: string, format: date }
end_date: { type: string, format: date }
renewal_date: { type: string, format: date, nullable: true }
annual_cost: { type: number, example: 50000 }
payment_frequency: { type: string, enum: [MONTHLY, QUARTERLY, HALF_YEARLY, ANNUAL] }
service_frequency: { type: string, enum: [MONTHLY, QUARTERLY, HALF_YEARLY, ANNUAL], nullable: true }
visits_per_year: { type: integer, nullable: true, example: 4 }
contact_person: { type: string }
contact_phone: { type: string }
contact_email: { type: string, format: email }
scope_of_work: { type: string }
exclusions: { type: string }
remarks: { type: string }
is_active: { type: boolean }
AmcContractUpdateBody:
type: object
minProperties: 1
properties:
vendor_id: { type: integer }
contract_no: { type: string }
contract_type: { type: string, enum: [COMPREHENSIVE, LABOUR_ONLY, PARTS_ONLY, PREVENTIVE_ONLY] }
start_date: { type: string, format: date }
end_date: { type: string, format: date }
renewal_date: { type: string, format: date, nullable: true }
annual_cost: { type: number }
payment_frequency: { type: string, enum: [MONTHLY, QUARTERLY, HALF_YEARLY, ANNUAL] }
service_frequency: { type: string, enum: [MONTHLY, QUARTERLY, HALF_YEARLY, ANNUAL], nullable: true }
visits_per_year: { type: integer, nullable: true }
contact_person: { type: string }
contact_phone: { type: string }
contact_email: { type: string, format: email }
scope_of_work: { type: string }
exclusions: { type: string }
remarks: { type: string }
is_active: { type: boolean }
AmcRenewBody:
type: object
required: [start_date, end_date]
properties:
start_date: { type: string, format: date }
end_date: { type: string, format: date }
vendor_id: { type: integer }
contract_no: { type: string }
contract_type: { type: string, enum: [COMPREHENSIVE, LABOUR_ONLY, PARTS_ONLY, PREVENTIVE_ONLY] }
renewal_date: { type: string, format: date, nullable: true }
annual_cost: { type: number }
payment_frequency: { type: string, enum: [MONTHLY, QUARTERLY, HALF_YEARLY, ANNUAL] }
service_frequency: { type: string, enum: [MONTHLY, QUARTERLY, HALF_YEARLY, ANNUAL], nullable: true }
visits_per_year: { type: integer, nullable: true }
contact_person: { type: string }
contact_phone: { type: string }
contact_email: { type: string, format: email }
scope_of_work: { type: string }
exclusions: { type: string }
remarks: { type: string }
ServiceVisitBody:
type: object
required: [visit_type, visit_date]
properties:
visit_type: { type: string, enum: [PREVENTIVE, BREAKDOWN, INSPECTION, INSTALLATION, CALIBRATION, OTHER] }
visit_date: { type: string, format: date }
amc_contract_id: { type: integer, nullable: true }
visit_number: { type: integer, nullable: true }
complaint_no: { type: string, example: CMP-001 }
complaint_date: { type: string, format: date, nullable: true }
complaint_desc: { type: string }
engineer_name: { type: string }
engineer_phone: { type: string }
vendor_id: { type: integer, nullable: true }
work_done: { type: string }
parts_replaced: { type: string }
next_service_date: { type: string, format: date, nullable: true }
status: { type: string, enum: [SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, PENDING_PARTS] }
downtime_hours: { type: number, example: 2.5 }
service_cost: { type: number, example: 1500 }
is_under_amc: { type: boolean }
asset_condition_after: { type: string, enum: [GOOD, FAIR, POOR, NEEDS_REPLACEMENT], nullable: true }
remarks: { type: string }
ServiceVisitUpdateBody:
type: object
minProperties: 1
properties:
visit_type: { type: string, enum: [PREVENTIVE, BREAKDOWN, INSPECTION, INSTALLATION, CALIBRATION, OTHER] }
visit_date: { type: string, format: date }
amc_contract_id: { type: integer, nullable: true }
visit_number: { type: integer, nullable: true }
complaint_no: { type: string }
complaint_date: { type: string, format: date, nullable: true }
complaint_desc: { type: string }
engineer_name: { type: string }
engineer_phone: { type: string }
vendor_id: { type: integer, nullable: true }
work_done: { type: string }
parts_replaced: { type: string }
next_service_date: { type: string, format: date, nullable: true }
status: { type: string, enum: [SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, PENDING_PARTS] }
downtime_hours: { type: number }
service_cost: { type: number }
is_under_amc: { type: boolean }
asset_condition_after: { type: string, enum: [GOOD, FAIR, POOR, NEEDS_REPLACEMENT], nullable: true }
remarks: { type: string }
ServiceVisitStatusBody:
type: object
required: [status]
properties:
status: { type: string, enum: [SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED, PENDING_PARTS] }
remarks: { type: string }
InsurancePolicyBody:
type: object
required: [policy_no, insurer_name, policy_start_date, policy_end_date]
properties:
policy_no: { type: string, example: POL-2026-001 }
insurer_name: { type: string, example: 'ICICI Lombard' }
insurer_branch: { type: string }
insurer_contact: { type: string }
insurer_phone: { type: string }
insurer_email: { type: string, format: email }
policy_type: { type: string, enum: [FIRE_AND_ALLIED, MACHINERY_BREAKDOWN, COMPREHENSIVE, THIRD_PARTY, VEHICLE, OTHER] }
sum_insured: { type: number, example: 1000000 }
annual_premium: { type: number, example: 25000 }
policy_start_date: { type: string, format: date }
policy_end_date: { type: string, format: date }
renewal_date: { type: string, format: date, nullable: true }
is_auto_renewal: { type: boolean }
premium_paid: { type: boolean }
premium_paid_date: { type: string, format: date, nullable: true }
remarks: { type: string }
is_active: { type: boolean }
InsurancePolicyUpdateBody:
type: object
minProperties: 1
properties:
policy_no: { type: string }
insurer_name: { type: string }
insurer_branch: { type: string }
insurer_contact: { type: string }
insurer_phone: { type: string }
insurer_email: { type: string, format: email }
policy_type: { type: string, enum: [FIRE_AND_ALLIED, MACHINERY_BREAKDOWN, COMPREHENSIVE, THIRD_PARTY, VEHICLE, OTHER] }
sum_insured: { type: number }
annual_premium: { type: number }
policy_start_date: { type: string, format: date }
policy_end_date: { type: string, format: date }
renewal_date: { type: string, format: date, nullable: true }
is_auto_renewal: { type: boolean }
premium_paid: { type: boolean }
premium_paid_date: { type: string, format: date, nullable: true }
remarks: { type: string }
is_active: { type: boolean }
InsuranceRenewBody:
type: object
required: [policy_start_date, policy_end_date]
properties:
policy_start_date: { type: string, format: date }
policy_end_date: { type: string, format: date }
policy_no: { type: string }
insurer_name: { type: string }
insurer_branch: { type: string }
insurer_contact: { type: string }
insurer_phone: { type: string }
insurer_email: { type: string, format: email }
policy_type: { type: string, enum: [FIRE_AND_ALLIED, MACHINERY_BREAKDOWN, COMPREHENSIVE, THIRD_PARTY, VEHICLE, OTHER] }
sum_insured: { type: number }
annual_premium: { type: number }
renewal_date: { type: string, format: date, nullable: true }
is_auto_renewal: { type: boolean }
premium_paid: { type: boolean }
premium_paid_date: { type: string, format: date, nullable: true }
remarks: { type: string }
paths:
/assets/depreciation-methods:
get:
tags: [Assets]
summary: List depreciation method dropdown options (SLM, WDV, OTHER)
responses:
'200':
description: Depreciation methods fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/depreciation/calculate:
post:
tags: [Assets]
summary: Preview depreciation calculation for selected method/rate
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/DepreciationCalculateBody' }
responses:
'200':
description: Depreciation calculated
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/alerts/expiry:
get:
tags: [Assets]
@ -56,38 +306,117 @@ paths:
responses:
'200':
description: Expiry alerts fetched
content:
application/json:
schema: { $ref: '#/components/schemas/ApiResponse' }
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/alerts/service:
get:
tags: [Assets]
summary: Service due/overdue alerts
parameters:
- { name: status, in: query, schema: { type: string, enum: [OVERDUE, DUE_THIS_WEEK, DUE_THIS_MONTH, UPCOMING] } }
- { name: page, in: query, schema: { type: integer } }
- { name: limit, in: query, schema: { type: integer } }
responses:
'200':
description: Service alerts fetched
content:
application/json:
schema: { $ref: '#/components/schemas/ApiResponse' }
/assets/{id}/amc:
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets:
get:
tags: [Assets]
summary: List AMC contracts for asset
summary: List assets
parameters:
- { name: page, in: query, schema: { type: integer } }
- { name: limit, in: query, schema: { type: integer } }
- { name: search, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: condition, in: query, schema: { type: string } }
- { name: asset_category_id, in: query, schema: { type: integer } }
- { name: asset_subcategory_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}:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: Get asset (includes depreciation summary)
responses:
'200':
description: Asset fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
put:
tags: [Assets]
summary: Update asset
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
responses:
'200':
description: Asset deleted
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/transfer:
post:
tags: [Assets]
summary: Transfer asset
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TransferAssetBody' }
responses:
'200':
description: Asset transferred
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/transfer-history:
get:
tags: [Assets]
summary: Asset transfer history
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Transfer history fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/amc:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: List AMC contracts for asset
responses:
'200':
description: AMC contracts fetched
content:
application/json:
schema: { $ref: '#/components/schemas/ApiResponse' }
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
post:
tags: [Assets]
summary: Create AMC contract
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
@ -96,6 +425,30 @@ paths:
responses:
'201':
description: AMC contract created
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/amc/{contractId}:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: contractId, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: Get AMC contract
responses:
'200':
description: AMC contract fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
put:
tags: [Assets]
summary: Update AMC contract
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AmcContractUpdateBody' }
responses:
'200':
description: AMC contract updated
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/amc/{contractId}/renew:
patch:
tags: [Assets]
@ -107,29 +460,27 @@ paths:
required: true
content:
application/json:
schema:
type: object
required: [start_date, end_date]
properties:
start_date: { type: string, format: date }
end_date: { type: string, format: date }
schema: { $ref: '#/components/schemas/AmcRenewBody' }
responses:
'201':
'200':
description: AMC renewed
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/service-visits:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: List service visits
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: visit_type, in: query, schema: { type: string } }
responses:
'200':
description: Service visits fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
post:
tags: [Assets]
summary: Log service visit
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
@ -138,20 +489,59 @@ paths:
responses:
'201':
description: Service visit created
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/service-visits/{visitId}:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: visitId, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: Get service visit
responses:
'200':
description: Service visit fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
put:
tags: [Assets]
summary: Update service visit
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ServiceVisitUpdateBody' }
responses:
'200':
description: Service visit updated
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/service-visits/{visitId}/status:
patch:
tags: [Assets]
summary: Update service visit status
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: visitId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ServiceVisitStatusBody' }
responses:
'200':
description: Status updated
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/insurance:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: List insurance policies
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Insurance policies fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
post:
tags: [Assets]
summary: Create insurance policy
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
@ -160,6 +550,30 @@ paths:
responses:
'201':
description: Insurance policy created
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/insurance/{policyId}:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: policyId, in: path, required: true, schema: { type: string } }
get:
tags: [Assets]
summary: Get insurance policy
responses:
'200':
description: Insurance policy fetched
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
put:
tags: [Assets]
summary: Update insurance policy
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/InsurancePolicyUpdateBody' }
responses:
'200':
description: Insurance policy updated
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }
/assets/{id}/insurance/{policyId}/renew:
patch:
tags: [Assets]
@ -171,63 +585,8 @@ paths:
required: true
content:
application/json:
schema:
type: object
required: [policy_start_date, policy_end_date]
properties:
policy_start_date: { type: string, format: date }
policy_end_date: { type: string, format: date }
schema: { $ref: '#/components/schemas/InsuranceRenewBody' }
responses:
'201':
'200':
description: Insurance renewed
/assets:
get:
tags: [Assets]
summary: List assets
responses:
'200':
description: Assets fetched
post:
tags: [Assets]
summary: Create asset
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/AssetsCreateBody' }
responses:
'201':
description: Asset created
/assets/{id}:
get:
tags: [Assets]
summary: Get asset
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Asset fetched
put:
tags: [Assets]
summary: Update asset
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Asset updated
delete:
tags: [Assets]
summary: Soft delete asset
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Asset deleted
/assets/{id}/transfer:
post:
tags: [Assets]
summary: Transfer asset
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Asset transferred
content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } }

View File

@ -3,6 +3,7 @@ tags:
- name: UOM
- name: Item Categories
- name: Item Subcategories
- name: Asset Subcategories
- name: Items
- name: Brands
- name: GST Rates
@ -11,6 +12,7 @@ tags:
- name: Asset Categories
- name: Departments
- name: Designations
- name: Locations
- name: Warehouses
- name: Plants
- name: Document Series
@ -149,7 +151,7 @@ components:
properties:
code: { type: string, example: 'OWN01' }
name: { type: string, example: 'Bharat Clean' }
brand_type: { type: string, example: 'OWN' }
brand_type: { type: string, enum: [OWN, OEM, THIRD_PARTY], example: OWN }
contact_person: { type: string, example: 'Ravi' }
phone: { type: string, example: '9876543210' }
email: { type: string, example: 'ops@bharaterp.com' }
@ -160,7 +162,7 @@ components:
properties:
code: { type: string, example: 'OWN01' }
name: { type: string, example: 'Bharat Clean' }
brand_type: { type: string, example: 'OWN' }
brand_type: { type: string, enum: [OWN, OEM, THIRD_PARTY], example: OWN }
contact_person: { type: string, example: 'Ravi' }
phone: { type: string, example: '9876543210' }
email: { type: string, example: 'ops@bharaterp.com' }
@ -221,7 +223,7 @@ components:
name: { type: string, example: 'Plant & Machinery' }
code_prefix: { type: string, example: 'MCH' }
default_useful_life_years: { type: integer, example: 15 }
default_depreciation_method: { type: string, example: 'WDV' }
default_depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: WDV }
is_active: { type: boolean, example: true }
AssetCategoriesUpdateBody:
type: object
@ -231,7 +233,23 @@ components:
name: { type: string, example: 'Plant & Machinery' }
code_prefix: { type: string, example: 'MCH' }
default_useful_life_years: { type: integer, example: 15 }
default_depreciation_method: { type: string, example: 'WDV' }
default_depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: WDV }
is_active: { type: boolean, example: true }
AssetSubcategoriesCreateBody:
type: object
required: [asset_category_id, code, name]
properties:
asset_category_id: { type: integer, example: 1 }
code: { type: string, example: 'CNC' }
name: { type: string, example: 'CNC Machines' }
is_active: { type: boolean, example: true }
AssetSubcategoriesUpdateBody:
type: object
minProperties: 1
properties:
asset_category_id: { type: integer, example: 1 }
code: { type: string, example: 'CNC' }
name: { type: string, example: 'CNC Machines' }
is_active: { type: boolean, example: true }
DepartmentsCreateBody:
type: object
@ -257,6 +275,37 @@ components:
properties:
name: { type: string, example: 'Store Manager' }
is_active: { type: boolean, example: true }
LocationsCreateBody:
type: object
required: [type, code, name]
properties:
type: { type: string, enum: [plant, warehouse], example: plant }
code: { type: string, example: 'PLT01' }
name: { type: string, example: 'Bharat Plant 1' }
parent_id: { type: integer, nullable: true, example: 1, description: Required when type is warehouse (plant id) }
gstin: { type: string, example: '27ABCDE1234F1Z5' }
address: { type: string, example: 'MIDC, Nashik' }
city: { type: string, example: 'Nashik' }
state: { type: string, example: 'Maharashtra' }
pincode: { type: string, example: '422001' }
phone: { type: string, example: '0253-1234567' }
location: { type: string, example: 'Ground Floor', description: Used for warehouse }
is_active: { type: boolean, example: true }
LocationsUpdateBody:
type: object
minProperties: 1
properties:
code: { type: string, example: 'PLT01' }
name: { type: string, example: 'Bharat Plant 1' }
parent_id: { type: integer, nullable: true, example: 1 }
gstin: { type: string, example: '27ABCDE1234F1Z5' }
address: { type: string, example: 'MIDC, Nashik' }
city: { type: string, example: 'Nashik' }
state: { type: string, example: 'Maharashtra' }
pincode: { type: string, example: '422001' }
phone: { type: string, example: '0253-1234567' }
location: { type: string, example: 'Ground Floor' }
is_active: { type: boolean, example: true }
WarehousesCreateBody:
type: object
required: [code, name, plant_id]
@ -1099,6 +1148,79 @@ paths:
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/asset-subcategories:
get:
tags: [Asset Subcategories]
summary: List Asset Subcategories
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: is_active
schema: { type: boolean }
- in: query
name: asset_category_id
schema: { type: integer }
description: Filter by parent asset category
responses:
"200":
description: List fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
post:
tags: [Asset Subcategories]
summary: Create Asset Subcategory
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssetSubcategoriesCreateBody" }
responses:
"201":
description: Created
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"409": { description: Duplicate key }
/masters/asset-subcategories/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string }
get:
tags: [Asset Subcategories]
summary: Get Asset Subcategory by id
responses:
"200":
description: Fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
put:
tags: [Asset Subcategories]
summary: Update Asset Subcategory
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssetSubcategoriesUpdateBody" }
responses:
"200":
description: Updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
delete:
tags: [Asset Subcategories]
summary: Delete Asset Subcategory
responses:
"200":
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/departments:
get:
tags: [Departments]
@ -1237,6 +1359,82 @@ paths:
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/locations:
get:
tags: [Locations]
summary: List locations (plants and warehouses)
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: is_active
schema: { type: boolean }
- in: query
name: type
schema: { type: string, enum: [plant, warehouse] }
- in: query
name: parent_id
schema: { type: integer }
description: Filter warehouses by plant id
responses:
"200":
description: List fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
post:
tags: [Locations]
summary: Create location (plant or warehouse)
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/LocationsCreateBody" }
responses:
"201":
description: Created
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"409": { description: Duplicate key }
/masters/locations/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string }
get:
tags: [Locations]
summary: Get location by id
responses:
"200":
description: Fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
put:
tags: [Locations]
summary: Update location
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/LocationsUpdateBody" }
responses:
"200":
description: Updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
delete:
tags: [Locations]
summary: Delete location
responses:
"200":
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/warehouses:
get:
tags: [Warehouses]

View File

@ -20,6 +20,7 @@ components:
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 }
asset_subcategory_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]

View File

@ -36,6 +36,9 @@ components:
discount_amount: { type: number, example: 0 }
freight_charges: { type: number, example: 0 }
other_charges: { type: number, example: 0 }
tds_applicable: { type: boolean, example: false }
tds_section_pct: { type: number, nullable: true, example: 2 }
adjustment: { type: number, example: -10 }
terms_and_conditions: { type: string, example: 'Standard terms apply' }
remarks: { type: string, example: 'Monthly RM order' }
items:
@ -59,6 +62,9 @@ components:
discount_amount: { type: number }
freight_charges: { type: number }
other_charges: { type: number }
tds_applicable: { type: boolean }
tds_section_pct: { type: number, nullable: true }
adjustment: { type: number }
terms_and_conditions: { type: string }
remarks: { type: string }
items:

View File

@ -0,0 +1,88 @@
tags:
- name: Settings
components:
schemas:
CompanySettingsBody:
type: object
minProperties: 1
properties:
org_name: { type: string, example: 'Bharat Consumer Products' }
mobile: { type: string, example: '9876543210' }
email: { type: string, format: email, example: 'info@company.com' }
website: { type: string, example: 'https://www.company.com' }
address: { type: string, example: '3rd Floor, Business Tower' }
city: { type: string, example: 'Noida' }
state: { type: string, example: 'Uttar Pradesh' }
pincode: { type: string, example: '201301' }
EmailSettingsBody:
type: object
minProperties: 1
properties:
smtp_host: { type: string, example: 'smtp.gmail.com' }
smtp_port: { type: integer, example: 587 }
smtp_username: { type: string, example: 'smtp-user@company.com' }
smtp_password: { type: string, example: 'app-password' }
sender_email: { type: string, format: email, example: 'noreply@company.com' }
sender_name: { type: string, example: 'Bharat ERP' }
paths:
/settings/company:
get:
tags: [Settings]
summary: Get company profile settings
responses:
"200":
description: Company settings fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
put:
tags: [Settings]
summary: Update company profile settings
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CompanySettingsBody" }
responses:
"200":
description: Company settings updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/settings/company/logo:
post:
tags: [Settings]
summary: Upload company logo
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [logo]
properties:
logo:
type: string
format: binary
responses:
"200":
description: Logo updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/settings/email:
get:
tags: [Settings]
summary: Get SMTP email settings
responses:
"200":
description: Email settings fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
put:
tags: [Settings]
summary: Update SMTP email settings
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/EmailSettingsBody" }
responses:
"200":
description: Email settings updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }

View File

@ -12,6 +12,11 @@ components:
type: string
enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL]
example: RAW_MATERIAL
gst_treatment:
type: string
enum: [REGULAR, COMPOSITION, UNREGISTERED, CONSUMER, SEZ, OVERSEAS, DEEMED_EXPORT, OTHER]
example: REGULAR
source_of_supply: { type: string, example: 'Maharashtra' }
gstin: { type: string, example: '27ABCDE1234F1Z5' }
pan: { type: string, example: 'ABCDE1234F' }
payment_term_id: { type: integer, example: 1 }
@ -26,6 +31,10 @@ components:
vendor_type:
type: string
enum: [RAW_MATERIAL, PACKING_MATERIAL, ASSET_CAPITAL, SERVICE, GENERAL]
gst_treatment:
type: string
enum: [REGULAR, COMPOSITION, UNREGISTERED, CONSUMER, SEZ, OVERSEAS, DEEMED_EXPORT, OTHER]
source_of_supply: { type: string, example: 'Maharashtra' }
gstin: { type: string, example: '27ABCDE1234F1Z5' }
pan: { type: string, example: 'ABCDE1234F' }
payment_term_id: { type: integer, example: 1 }
@ -120,6 +129,21 @@ components:
enum: [CURRENT, SAVINGS, OVERDRAFT]
is_primary: { type: boolean, example: true }
is_active: { type: boolean, example: true }
VendorItemMappingsCreateBody:
type: object
required: [item_id]
properties:
item_id: { type: integer, example: 12 }
last_purchase_rate: { type: number, example: 1250.5 }
is_preferred: { type: boolean, example: false }
is_active: { type: boolean, example: true }
VendorItemMappingsUpdateBody:
type: object
minProperties: 1
properties:
last_purchase_rate: { type: number, example: 1300 }
is_preferred: { type: boolean, example: true }
is_active: { type: boolean, example: true }
paths:
/vendors:
@ -163,6 +187,22 @@ paths:
"201":
description: Created
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/vendors/gst-treatments:
get:
tags: [Vendors]
summary: List GST treatment dropdown options
responses:
"200":
description: GST treatment options fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/vendors/source-of-supply:
get:
tags: [Vendors]
summary: List source of supply dropdown options (Indian states and UTs)
responses:
"200":
description: Source of supply options fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/vendors/{id}:
parameters:
- in: path
@ -409,3 +449,68 @@ paths:
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/vendors/{vendorId}/items:
parameters:
- in: path
name: vendorId
required: true
schema: { type: string }
get:
tags: [Vendors]
summary: List vendor item mappings
responses:
"200":
description: Fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
post:
tags: [Vendors]
summary: Map item to vendor
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/VendorItemMappingsCreateBody" }
responses:
"201":
description: Created
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"409": { description: Item already mapped }
/vendors/{vendorId}/items/{mappingId}:
parameters:
- in: path
name: vendorId
required: true
schema: { type: string }
- in: path
name: mappingId
required: true
schema: { type: string }
get:
tags: [Vendors]
summary: Get vendor item mapping
responses:
"200":
description: Fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
put:
tags: [Vendors]
summary: Update vendor item mapping
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/VendorItemMappingsUpdateBody" }
responses:
"200":
description: Updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
delete:
tags: [Vendors]
summary: Delete vendor item mapping
responses:
"200":
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }

View File

@ -15,7 +15,11 @@ const amcInclude = {
const sanitizeAmc = (row) => {
if (!row) return null;
const { vendors, ...rest } = row;
return { ...rest, vendor: vendors || null };
return {
...rest,
annual_cost: rest.annual_cost !== null && rest.annual_cost !== undefined ? Number(rest.annual_cost) : 0,
vendor: vendors || null,
};
};
const buildAmcData = async (payload, assetId) => {

View File

@ -3,7 +3,15 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { toDateOnly, getAssetOrThrow, deactivateOtherActive } = require('./assets.helpers');
const sanitizeInsurance = (row) => (row ? { ...row } : null);
const sanitizeInsurance = (row) => {
if (!row) return null;
return {
...row,
sum_insured: row.sum_insured !== null && row.sum_insured !== undefined ? Number(row.sum_insured) : 0,
annual_premium:
row.annual_premium !== null && row.annual_premium !== undefined ? Number(row.annual_premium) : 0,
};
};
const buildInsuranceData = async (payload, assetId) => ({
asset_id: BigInt(assetId),

View File

@ -13,6 +13,12 @@ const sanitizeVisit = (row) => {
const { vendors, asset_amc_contracts, ...rest } = row;
return {
...rest,
downtime_hours:
rest.downtime_hours !== null && rest.downtime_hours !== undefined
? Number(rest.downtime_hours)
: 0,
service_cost:
rest.service_cost !== null && rest.service_cost !== undefined ? Number(rest.service_cost) : 0,
vendor: vendors || null,
amc_contract: asset_amc_contracts || null,
};

View File

@ -1,6 +1,6 @@
const ASSET_CONDITIONS = ['NEW', 'GOOD', 'FAIR', 'POOR'];
const ASSET_STATUSES = ['IN_USE', 'IDLE', 'UNDER_MAINTENANCE', 'DISPOSED', 'SCRAPPED'];
const DEPRECIATION_METHODS = ['SLM', 'WDV'];
const DEPRECIATION_METHODS = ['SLM', 'WDV', 'OTHER'];
const DISPOSAL_STATUSES = ['DISPOSED', 'SCRAPPED'];
const AMC_CONTRACT_TYPES = ['COMPREHENSIVE', 'LABOUR_ONLY', 'PARTS_ONLY', 'PREVENTIVE_ONLY'];

View File

@ -172,6 +172,16 @@ const serviceAlerts = asyncHandler(async (req, res) => {
res.json(new ApiResponse(200, result.data, 'Service alerts fetched', result.meta));
});
const listDepreciationMethods = asyncHandler(async (_req, res) => {
const data = service.listDepreciationMethods();
res.json(new ApiResponse(200, data, 'Depreciation methods fetched'));
});
const calculateDepreciation = asyncHandler(async (req, res) => {
const data = service.previewDepreciation(req.body);
res.json(new ApiResponse(200, data, 'Depreciation calculated'));
});
module.exports = {
create,
list,
@ -180,6 +190,8 @@ module.exports = {
remove,
transfer,
transferHistory,
listDepreciationMethods,
calculateDepreciation,
listAmc,
createAmc,
getAmc,

View File

@ -0,0 +1,141 @@
const round4 = (value) => Math.round(Number(value || 0) * 10000) / 10000;
const DEPRECIATION_METHOD_OPTIONS = [
{
value: 'SLM',
label: 'Straight Line Method (SLM)',
description:
'Equal depreciation each year on original cost. Rate auto-calculated from useful life when not provided.',
},
{
value: 'WDV',
label: 'Written Down Value (WDV)',
description:
'Depreciation on reducing book value. Rate auto-calculated from cost, salvage and useful life when not provided.',
},
{
value: 'OTHER',
label: 'Other',
description: 'Custom method. Depreciation rate must be entered manually.',
},
];
const yearsElapsed = (purchaseDate, asOfDate = new Date()) => {
if (!purchaseDate) return 0;
const start = new Date(purchaseDate);
const end = new Date(asOfDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) return 0;
const ms = end.getTime() - start.getTime();
return round4(ms / (365.25 * 24 * 60 * 60 * 1000));
};
const resolveDepreciationRate = ({
method,
depreciation_rate: rate,
purchase_cost: cost,
salvage_value: salvage,
useful_life_years: lifeYears,
}) => {
if (rate !== undefined && rate !== null && rate !== '') {
return round4(rate);
}
const purchaseCost = Number(cost || 0);
const salvageValue = Number(salvage || 0);
const years = Number(lifeYears || 0);
if (!method || method === 'OTHER' || years <= 0) return null;
if (method === 'SLM') {
if (purchaseCost <= 0) return round4(100 / years);
const depreciable = Math.max(purchaseCost - salvageValue, 0);
return round4((depreciable / purchaseCost / years) * 100);
}
if (method === 'WDV') {
if (purchaseCost <= 0 || salvageValue <= 0 || salvageValue >= purchaseCost) return null;
return round4((1 - (salvageValue / purchaseCost) ** (1 / years)) * 100);
}
return null;
};
const calculateDepreciation = ({
depreciation_method: method,
depreciation_rate: rateInput,
purchase_cost: cost,
salvage_value: salvage,
useful_life_years: lifeYears,
purchase_date: purchaseDate,
as_of_date: asOfDate,
} = {}) => {
const purchaseCost = Number(cost || 0);
const salvageValue = Number(salvage || 0);
const rate = resolveDepreciationRate({
method,
depreciation_rate: rateInput,
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: lifeYears,
});
const elapsed = yearsElapsed(purchaseDate, asOfDate || new Date());
const cappedYears =
lifeYears && Number(lifeYears) > 0 ? Math.min(elapsed, Number(lifeYears)) : elapsed;
let annualDepreciation = 0;
let accumulatedDepreciation = 0;
let bookValue = purchaseCost;
if (method === 'SLM' && rate !== null) {
annualDepreciation = round4((purchaseCost * rate) / 100);
const maxDepreciable = Math.max(purchaseCost - salvageValue, 0);
accumulatedDepreciation = round4(Math.min(annualDepreciation * cappedYears, maxDepreciable));
bookValue = round4(Math.max(purchaseCost - accumulatedDepreciation, salvageValue));
} else if (method === 'WDV' && rate !== null) {
annualDepreciation = round4((purchaseCost * rate) / 100);
bookValue = purchaseCost;
const fullYears = Math.floor(cappedYears);
const fraction = cappedYears - fullYears;
for (let i = 0; i < fullYears; i += 1) {
const yearDep = round4((bookValue * rate) / 100);
bookValue = round4(Math.max(bookValue - yearDep, salvageValue));
accumulatedDepreciation = round4(purchaseCost - bookValue);
if (bookValue <= salvageValue) break;
}
if (fraction > 0 && bookValue > salvageValue) {
const yearDep = round4(((bookValue * rate) / 100) * fraction);
bookValue = round4(Math.max(bookValue - yearDep, salvageValue));
accumulatedDepreciation = round4(purchaseCost - bookValue);
}
annualDepreciation = round4((bookValue * rate) / 100);
} else if (method === 'OTHER' && rate !== null) {
annualDepreciation = round4((purchaseCost * rate) / 100);
const maxDepreciable = Math.max(purchaseCost - salvageValue, 0);
accumulatedDepreciation = round4(Math.min(annualDepreciation * cappedYears, maxDepreciable));
bookValue = round4(Math.max(purchaseCost - accumulatedDepreciation, salvageValue));
}
return {
depreciation_method: method || null,
depreciation_rate: rate,
annual_depreciation: annualDepreciation,
accumulated_depreciation: accumulatedDepreciation,
book_value: bookValue,
years_elapsed: elapsed,
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: lifeYears ?? null,
};
};
module.exports = {
DEPRECIATION_METHOD_OPTIONS,
resolveDepreciationRate,
calculateDepreciation,
yearsElapsed,
round4,
};

View File

@ -10,6 +10,7 @@ const SOFT_DELETE_TABLES = new Set([
'grn',
'grn_items',
'asset_categories',
'asset_subcategories',
]);
const toDateOnly = (value) => {

View File

@ -18,6 +18,7 @@ const {
insurancePolicySchema,
updateInsurancePolicySchema,
renewInsurancePolicySchema,
depreciationCalculateSchema,
expiryAlertsQuerySchema,
serviceAlertsQuerySchema,
} = require('./assets.validation');
@ -47,6 +48,14 @@ router.get(
controller.serviceAlerts
);
router.get('/depreciation-methods', authorize('ASSET', 'view'), controller.listDepreciationMethods);
router.post(
'/depreciation/calculate',
authorize('ASSET', 'view'),
validate(depreciationCalculateSchema),
controller.calculateDepreciation
);
router.get('/:id/amc', authorize('ASSET', 'view'), controller.listAmc);
router.post(
'/:id/amc',

View File

@ -6,6 +6,11 @@ const { nextDocumentNumber } = require('../../utils/generateCode');
const { DISPOSAL_STATUSES } = require('./assets.constants');
const { assertPlant, assertWarehouse } = require('../../utils/locations');
const repository = require('./assets.repository');
const {
DEPRECIATION_METHOD_OPTIONS,
resolveDepreciationRate,
calculateDepreciation,
} = require('./assets.depreciation');
const SOFT_DELETE_TABLES = new Set([
'vendors',
@ -16,10 +21,12 @@ const SOFT_DELETE_TABLES = new Set([
'grn',
'grn_items',
'asset_categories',
'asset_subcategories',
]);
const assetInclude = {
asset_categories: { select: { id: true, code: true, name: true, code_prefix: true } },
asset_subcategories: { select: { id: true, code: true, name: true, asset_category_id: true } },
plant: { select: { id: true, code: true, name: true } },
departments: { select: { id: true, name: true } },
warehouse: { select: { id: true, code: true, name: true } },
@ -61,6 +68,7 @@ const sanitizeAsset = (asset) => {
if (!asset) return null;
const {
asset_categories,
asset_subcategories,
plant,
departments,
warehouse,
@ -73,9 +81,26 @@ const sanitizeAsset = (asset) => {
...rest
} = asset;
const purchaseCost =
rest.purchase_cost !== null && rest.purchase_cost !== undefined
? Number(rest.purchase_cost)
: 0;
const salvageValue =
rest.salvage_value !== null && rest.salvage_value !== undefined
? Number(rest.salvage_value)
: 0;
const depreciationRate =
rest.depreciation_rate !== null && rest.depreciation_rate !== undefined
? Number(rest.depreciation_rate)
: null;
return {
...rest,
purchase_cost: purchaseCost,
salvage_value: salvageValue,
depreciation_rate: depreciationRate,
asset_category: asset_categories || null,
asset_subcategory: asset_subcategories || null,
plant: plant || null,
department: departments || null,
warehouse: warehouse || null,
@ -85,7 +110,16 @@ const sanitizeAsset = (asset) => {
grn: grn || null,
created_by_user: users_assets_created_byTousers || null,
updated_by_user: users_assets_updated_byTousers || null,
depreciation: calculateDepreciation({
depreciation_method: rest.depreciation_method,
depreciation_rate: depreciationRate,
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: rest.useful_life_years,
purchase_date: rest.purchase_date,
}),
asset_categories: undefined,
asset_subcategories: undefined,
departments: undefined,
users_assets_assigned_to_user_idTousers: undefined,
vendors_assets_vendor_idTovendors: undefined,
@ -143,12 +177,30 @@ const assertDisposalFields = (status, disposalDate) => {
}
};
const assertAssetSubcategory = async (subcategoryId, categoryId) => {
const subcategory = await assertReference(
'asset_subcategories',
subcategoryId,
'asset_subcategory_id'
);
if (subcategory.asset_category_id.toString() !== BigInt(categoryId).toString()) {
throw new ApiError(422, 'asset_subcategory_id does not belong to the selected asset_category_id');
}
return subcategory;
};
const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
const category = await assertReference(
'asset_categories',
payload.asset_category_id,
'asset_category_id'
);
if (isCreate && !payload.asset_subcategory_id) {
throw new ApiError(422, 'asset_subcategory_id is required');
}
if (payload.asset_subcategory_id) {
await assertAssetSubcategory(payload.asset_subcategory_id, payload.asset_category_id);
}
await assertPlant(payload.plant_id, 'plant_id');
if (payload.department_id)
await assertReference('departments', payload.department_id, 'department_id');
@ -167,9 +219,33 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
const disposalDate = payload.disposal_date ? toDateOnly(payload.disposal_date) : null;
assertDisposalFields(status, disposalDate);
const purchaseCost = payload.purchase_cost ?? 0;
const salvageValue = payload.salvage_value ?? 0;
const usefulLifeYears =
payload.useful_life_years !== undefined && payload.useful_life_years !== null
? payload.useful_life_years
: category.default_useful_life_years;
const depreciationMethod =
payload.depreciation_method || category.default_depreciation_method || null;
if (depreciationMethod === 'OTHER' && (payload.depreciation_rate === undefined || payload.depreciation_rate === null || payload.depreciation_rate === '')) {
throw new ApiError(422, 'depreciation_rate is required when depreciation_method is OTHER');
}
const depreciationRate = resolveDepreciationRate({
method: depreciationMethod,
depreciation_rate: payload.depreciation_rate,
purchase_cost: purchaseCost,
salvage_value: salvageValue,
useful_life_years: usefulLifeYears,
});
return {
asset_name: payload.asset_name,
asset_category_id: BigInt(payload.asset_category_id),
asset_subcategory_id: payload.asset_subcategory_id
? BigInt(payload.asset_subcategory_id)
: null,
brand_model: payload.brand_model || null,
manufacturer: payload.manufacturer || null,
serial_number: payload.serial_number || null,
@ -184,14 +260,11 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
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,
purchase_cost: purchaseCost,
useful_life_years: usefulLifeYears,
depreciation_method: depreciationMethod,
depreciation_rate: depreciationRate,
salvage_value: salvageValue,
warranty_expiry_date: payload.warranty_expiry_date
? toDateOnly(payload.warranty_expiry_date)
: null,
@ -252,6 +325,9 @@ const listAssets = async (query) => {
...(query.status ? { status: query.status } : {}),
...(query.condition ? { condition: query.condition } : {}),
...(query.asset_category_id ? { asset_category_id: BigInt(query.asset_category_id) } : {}),
...(query.asset_subcategory_id
? { asset_subcategory_id: BigInt(query.asset_subcategory_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 } : {}),
@ -288,6 +364,10 @@ const updateAsset = async (id, payload, userId, requestId) => {
const merged = {
asset_name: payload.asset_name ?? existing.asset_name,
asset_category_id: payload.asset_category_id ?? existing.asset_category_id,
asset_subcategory_id:
payload.asset_subcategory_id !== undefined
? payload.asset_subcategory_id
: existing.asset_subcategory_id,
brand_model: payload.brand_model !== undefined ? payload.brand_model : existing.brand_model,
manufacturer: payload.manufacturer !== undefined ? payload.manufacturer : existing.manufacturer,
serial_number:
@ -318,6 +398,12 @@ const updateAsset = async (id, payload, userId, requestId) => {
payload.depreciation_method !== undefined
? payload.depreciation_method
: existing.depreciation_method,
depreciation_rate:
payload.depreciation_rate !== undefined
? payload.depreciation_rate
: existing.depreciation_rate !== null && existing.depreciation_rate !== undefined
? Number(existing.depreciation_rate)
: null,
salvage_value: payload.salvage_value ?? Number(existing.salvage_value ?? 0),
warranty_expiry_date:
payload.warranty_expiry_date !== undefined
@ -468,6 +554,15 @@ const getTransferHistory = async (id) => {
return rows.map(sanitizeTransfer);
};
const listDepreciationMethods = () => DEPRECIATION_METHOD_OPTIONS;
const previewDepreciation = (payload) => {
if (payload.depreciation_method === 'OTHER' && (payload.depreciation_rate === undefined || payload.depreciation_rate === null)) {
throw new ApiError(422, 'depreciation_rate is required when depreciation_method is OTHER');
}
return calculateDepreciation(payload);
};
module.exports = {
createAsset,
listAssets,
@ -476,4 +571,6 @@ module.exports = {
deleteAsset,
transferAsset,
getTransferHistory,
listDepreciationMethods,
previewDepreciation,
};

View File

@ -17,6 +17,7 @@ const {
const assetFields = {
asset_name: Joi.string().max(200).required(),
asset_category_id: Joi.number().integer().positive().required(),
asset_subcategory_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(),
@ -37,6 +38,7 @@ const assetFields = {
.valid(...DEPRECIATION_METHODS)
.allow(null)
.optional(),
depreciation_rate: Joi.number().min(0).max(100).allow(null).optional(),
salvage_value: Joi.number().min(0).allow(null).optional(),
warranty_expiry_date: Joi.date().iso().allow(null).optional(),
condition: Joi.string()
@ -58,6 +60,7 @@ const createAssetSchema = Joi.object(assetFields);
const updateAssetSchema = Joi.object({
asset_name: assetFields.asset_name.optional(),
asset_category_id: assetFields.asset_category_id.optional(),
asset_subcategory_id: assetFields.asset_subcategory_id.optional(),
brand_model: assetFields.brand_model,
manufacturer: assetFields.manufacturer,
serial_number: assetFields.serial_number,
@ -75,6 +78,7 @@ const updateAssetSchema = Joi.object({
purchase_cost: assetFields.purchase_cost.optional(),
useful_life_years: assetFields.useful_life_years,
depreciation_method: assetFields.depreciation_method,
depreciation_rate: assetFields.depreciation_rate,
salvage_value: assetFields.salvage_value,
warranty_expiry_date: assetFields.warranty_expiry_date,
condition: assetFields.condition.optional(),
@ -98,6 +102,7 @@ const listAssetsQuerySchema = Joi.object({
.valid(...ASSET_CONDITIONS)
.optional(),
asset_category_id: Joi.number().integer().positive().optional(),
asset_subcategory_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(),
@ -267,6 +272,18 @@ const renewInsurancePolicySchema = Joi.object({
remarks: Joi.string().allow(null, '').optional(),
});
const depreciationCalculateSchema = Joi.object({
depreciation_method: Joi.string()
.valid(...DEPRECIATION_METHODS)
.required(),
depreciation_rate: Joi.number().min(0).max(100).allow(null).optional(),
purchase_cost: Joi.number().min(0).default(0),
salvage_value: Joi.number().min(0).default(0),
useful_life_years: Joi.number().integer().min(0).allow(null).optional(),
purchase_date: Joi.date().iso().allow(null).optional(),
as_of_date: Joi.date().iso().allow(null).optional(),
});
const expiryAlertsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
@ -299,6 +316,7 @@ module.exports = {
insurancePolicySchema,
updateInsurancePolicySchema,
renewInsurancePolicySchema,
depreciationCalculateSchema,
expiryAlertsQuerySchema,
serviceAlertsQuerySchema,
};

View File

@ -4,7 +4,7 @@ const { recalculatePoStatus } = require('./grn.poStatus');
const createAssetsForLine = async (
tx,
{ grn, grnItem, item, assetCategory, plantId, assetCodes, userId }
{ grn, grnItem, item, assetCategory, assetSubcategory, plantId, assetCodes, userId }
) => {
const createdAssets = [];
@ -14,6 +14,7 @@ const createAssetsForLine = async (
asset_code: assetCodes[index],
asset_name: assetCodes.length > 1 ? `${item.item_name} #${index + 1}` : item.item_name,
asset_category_id: assetCategory.id,
asset_subcategory_id: assetSubcategory.id,
plant_id: BigInt(plantId),
warehouse_id: grn.warehouse_id,
vendor_id: grn.vendor_id,
@ -67,6 +68,7 @@ const createGrnWithReceipt = async ({ grnNumber, header, items, assetPlans, user
grnItem,
item: plan.item,
assetCategory: plan.assetCategory,
assetSubcategory: plan.assetSubcategory,
plantId: plan.plantId,
assetCodes: plan.assetCodes,
userId,

View File

@ -181,12 +181,31 @@ const validateAndBuildItems = async (po, payloadItems) => {
`Line ${row.line_no}: asset_category_id is required for asset items`
);
}
if (!row.asset_subcategory_id) {
throw new ApiError(
422,
`Line ${row.line_no}: asset_subcategory_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 assetSubcategory = await prisma.asset_subcategories.findFirst({
where: { id: BigInt(row.asset_subcategory_id), deleted_at: null, is_active: true },
});
if (!assetSubcategory) {
throw new ApiError(422, `Line ${row.line_no}: invalid asset_subcategory_id`);
}
if (assetSubcategory.asset_category_id.toString() !== assetCategory.id.toString()) {
throw new ApiError(
422,
`Line ${row.line_no}: asset_subcategory_id does not belong to asset_category_id`
);
}
const units = Math.floor(acceptedQty);
const assetCodes = [];
for (let unit = 0; unit < units; unit += 1) {
@ -197,6 +216,7 @@ const validateAndBuildItems = async (po, payloadItems) => {
po_item_id: poItem.id,
item,
assetCategory,
assetSubcategory,
plantId: po.plant_id,
assetCodes,
});

View File

@ -15,6 +15,7 @@ const grnItemSchema = Joi.object({
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(),
asset_subcategory_id: Joi.number().integer().positive().allow(null).optional(),
});
const createGrnSchema = Joi.object({

View File

@ -12,7 +12,7 @@ const normalizePayload = (payload, fields) => {
if (f.uppercase) out[f.name] = out[f.name].toUpperCase();
}
if (f.type === 'int' && out[f.name] !== null && out[f.name] !== undefined) {
out[f.name] = Number(out[f.name]);
out[f.name] = f.asBigInt ? BigInt(out[f.name]) : Number(out[f.name]);
}
if (f.type === 'decimal' && out[f.name] !== null && out[f.name] !== undefined) {
out[f.name] = Number(out[f.name]);
@ -78,6 +78,13 @@ const buildMasterService = ({
: {}),
};
for (const f of fields) {
if (!f.filterable || query[f.name] === undefined || query[f.name] === null || query[f.name] === '') {
continue;
}
where[f.name] = f.type === 'int' ? BigInt(query[f.name]) : query[f.name];
}
const [data, total] = await Promise.all([
model.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit }),
model.count({ where }),

View File

@ -0,0 +1,80 @@
const Joi = require('joi');
const MASTER_CODE_REGEX = /^[A-Za-z0-9\-_/]+$/;
const MASTER_NAME_REGEX = /^[A-Za-z0-9 \-_/&.()]+$/;
const MASTER_CODE_MESSAGE = 'Allowed characters: A-Z, a-z, 0-9, -, _, /';
const MASTER_NAME_MESSAGE = 'Allowed characters: A-Z, a-z, 0-9, space, -, _, /, &, ., (, )';
const validateMasterCode = (value, helpers) => {
if (value === undefined || value === null) return value;
const str = String(value).trim();
if (!str) return helpers.error('string.masterCodeEmpty');
if (!MASTER_CODE_REGEX.test(str)) return helpers.error('string.masterCode');
return str;
};
const validateMasterName = (value, helpers) => {
if (value === undefined || value === null) return value;
const str = String(value).trim();
if (!str) return helpers.error('string.masterNameEmpty');
if (!MASTER_NAME_REGEX.test(str)) return helpers.error('string.masterName');
return str;
};
const validateMasterCodeOptional = (value, helpers) => {
if (value === undefined || value === null || value === '') return value === '' ? '' : value;
const str = String(value).trim();
if (!str) return '';
if (!MASTER_CODE_REGEX.test(str)) return helpers.error('string.masterCode');
return str;
};
const validateMasterNameOptional = (value, helpers) => {
if (value === undefined || value === null || value === '') return value === '' ? '' : value;
const str = String(value).trim();
if (!str) return '';
if (!MASTER_NAME_REGEX.test(str)) return helpers.error('string.masterName');
return str;
};
const masterCodeMessages = {
'string.masterCode': MASTER_CODE_MESSAGE,
'string.masterCodeEmpty': 'code is required',
'any.required': 'code is required',
};
const masterNameMessages = {
'string.masterName': MASTER_NAME_MESSAGE,
'string.masterNameEmpty': 'name is required',
'any.required': 'name is required',
};
const masterCode = ({ max = 30, required = false } = {}) => {
const validator = required ? validateMasterCode : validateMasterCodeOptional;
let schema = Joi.string().max(max).custom(validator).messages(masterCodeMessages);
return required ? schema.required() : schema.optional();
};
const masterName = ({ max = 150, required = false } = {}) => {
const validator = required ? validateMasterName : validateMasterNameOptional;
let schema = Joi.string().max(max).custom(validator).messages(masterNameMessages);
return required ? schema.required() : schema.optional();
};
const listQuerySchema = 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(),
is_active: Joi.boolean().optional(),
});
module.exports = {
MASTER_CODE_REGEX,
MASTER_NAME_REGEX,
MASTER_CODE_MESSAGE,
MASTER_NAME_MESSAGE,
masterCode,
masterName,
listQuerySchema,
};

View File

@ -1,21 +1,29 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const { DEPRECIATION_METHODS } = require('../../assets/assets.constants');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
code_prefix: Joi.string().allow('').optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
code_prefix: masterCode({ max: 20 }),
default_useful_life_years: Joi.number().integer().optional(),
default_depreciation_method: Joi.string().allow('').optional(),
default_depreciation_method: Joi.string()
.valid(...DEPRECIATION_METHODS)
.allow(null, '')
.optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
code_prefix: masterCode({ max: 20 }),
default_useful_life_years: Joi.number().integer().optional(),
default_depreciation_method: Joi.string()
.valid(...DEPRECIATION_METHODS)
.allow(null, '')
.optional(),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -0,0 +1,35 @@
const asyncHandler = require('../../../utils/asyncHandler');
const ApiResponse = require('../../../utils/ApiResponse');
const service = require('./asset-subcategories.service');
const create = asyncHandler(async (req, res) => {
const data = await service.createAssetSubcategories(req.body, req.user?.id, req.id);
res.status(201).json(new ApiResponse(201, data, 'asset_subcategories created successfully'));
});
const list = asyncHandler(async (req, res) => {
const result = await service.listAssetSubcategories(req.query);
res.json(new ApiResponse(200, result.data, 'asset_subcategories list fetched', result.meta));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getAssetSubcategoriesById(req.params.id);
res.json(new ApiResponse(200, data, 'asset_subcategories fetched'));
});
const update = asyncHandler(async (req, res) => {
const data = await service.updateAssetSubcategories(
req.params.id,
req.body,
req.user?.id,
req.id
);
res.json(new ApiResponse(200, data, 'asset_subcategories updated successfully'));
});
const remove = asyncHandler(async (req, res) => {
await service.deleteAssetSubcategories(req.params.id, req.user?.id, req.id);
res.json(new ApiResponse(200, null, 'asset_subcategories deleted successfully'));
});
module.exports = { create, list, getOne, update, remove };

View File

@ -0,0 +1,17 @@
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('./asset-subcategories.controller');
const { createSchema, updateSchema, listQuerySchema } = require('./asset-subcategories.validation');
const router = express.Router();
router.use(authenticate);
router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list);
router.get('/:id', authorize('MASTERS', 'view'), controller.getOne);
router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create);
router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update);
router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove);
module.exports = router;

View File

@ -0,0 +1,81 @@
const prisma = require('../../../config/prisma');
const ApiError = require('../../../utils/ApiError');
const { buildMasterService } = require('../_shared/master.factory');
const config = {
modelName: 'asset_subcategories',
tableName: 'asset_subcategories',
uniqueField: 'code',
softDelete: true,
fields: [
{
name: 'asset_category_id',
type: 'int',
asBigInt: true,
filterable: true,
uppercase: false,
searchable: false,
},
{
name: 'code',
type: 'string',
uppercase: true,
searchable: true,
},
{
name: 'name',
type: 'string',
uppercase: false,
searchable: true,
},
{
name: 'is_active',
type: 'boolean',
uppercase: false,
searchable: false,
},
{
name: 'created_by',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'updated_by',
type: 'int',
uppercase: false,
searchable: false,
},
],
};
const service = buildMasterService(config);
const assertAssetCategory = async (assetCategoryId) => {
const category = await prisma.asset_categories.findFirst({
where: { id: BigInt(assetCategoryId), deleted_at: null },
});
if (!category) throw new ApiError(422, 'Invalid asset_category_id');
if (category.is_active === false) throw new ApiError(422, 'asset_category_id is inactive');
return category;
};
const createAssetSubcategories = async (payload, userId, requestId) => {
await assertAssetCategory(payload.asset_category_id);
return service.createOne(payload, userId, requestId);
};
const updateAssetSubcategories = async (id, payload, userId, requestId) => {
if (payload.asset_category_id !== undefined) {
await assertAssetCategory(payload.asset_category_id);
}
return service.updateOne(id, payload, userId, requestId);
};
module.exports = {
createAssetSubcategories,
listAssetSubcategories: service.list,
getAssetSubcategoriesById: service.getOne,
updateAssetSubcategories,
deleteAssetSubcategories: service.removeOne,
};

View File

@ -0,0 +1,26 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
asset_category_id: Joi.number().integer().positive().required(),
code: masterCode({ max: 30, required: true }),
name: masterName({ max: 150, required: true }),
is_active: Joi.boolean().optional(),
});
const updateSchema = Joi.object({
asset_category_id: Joi.number().integer().positive().optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
}).min(1);
const listAssetSubcategoriesQuerySchema = listQuerySchema.keys({
asset_category_id: Joi.number().integer().positive().optional(),
});
module.exports = {
createSchema,
updateSchema,
listQuerySchema: listAssetSubcategoriesQuerySchema,
};

View File

@ -1,28 +1,28 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const BRAND_TYPES = ['OWN', 'OEM', 'THIRD_PARTY'];
const createSchema = Joi.object({
code: Joi.string().max(30).required(),
name: Joi.string().max(150).required(),
code: masterCode({ max: 30, required: true }),
name: masterName({ max: 150, required: true }),
brand_type: Joi.string()
.valid(...BRAND_TYPES)
.default('OWN'),
contact_person: Joi.string().max(100).allow(null, '').optional(),
contact_person: masterName({ max: 100 }).allow(null),
phone: Joi.string().max(20).allow(null, '').optional(),
email: Joi.string().email().max(200).allow(null, '').optional(),
is_active: Joi.boolean().default(true),
});
const updateSchema = createSchema
.fork(['code', 'name', 'brand_type'], (schema) => schema.optional())
.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
brand_type: Joi.string().valid(...BRAND_TYPES).optional(),
contact_person: masterName({ max: 100 }).allow(null),
phone: Joi.string().max(20).allow(null, '').optional(),
email: Joi.string().email().max(200).allow(null, '').optional(),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema, BRAND_TYPES };

View File

@ -1,19 +1,18 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,17 +1,14 @@
const Joi = require('joi');
const { masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
name: Joi.string().allow('').optional(),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,17 +1,14 @@
const Joi = require('joi');
const { masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
name: Joi.string().allow('').optional(),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,21 +1,22 @@
const Joi = require('joi');
const { masterCode, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
prefix: Joi.string().allow('').optional(),
code: masterCode({ max: 30 }),
prefix: masterCode({ max: 30 }),
current_number: Joi.number().integer().optional(),
padding: Joi.number().integer().optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
prefix: masterCode({ max: 30 }),
current_number: Joi.number().integer().optional(),
padding: Joi.number().integer().optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -9,6 +9,7 @@ router.use('/gst-rates', require('./gst-rates/gst-rates.routes'));
router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
router.use('/asset-categories', require('./asset-categories/asset-categories.routes'));
router.use('/asset-subcategories', require('./asset-subcategories/asset-subcategories.routes'));
router.use('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes'));
router.use('/document-series', require('./document-series/document-series.routes'));

View File

@ -1,18 +1,16 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,19 +1,18 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
item_category_id: Joi.number().integer().optional(),
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
item_category_id: Joi.number().integer().optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,8 +1,9 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
item_code: Joi.string().allow('').optional(),
item_name: Joi.string().allow('').optional(),
item_code: masterCode({ max: 30 }),
item_name: masterName({ max: 200 }),
item_category_id: Joi.number().integer().optional(),
item_subcategory_id: Joi.number().integer().optional(),
uom_id: Joi.number().integer().optional(),
@ -17,13 +18,21 @@ const createSchema = Joi.object({
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
item_code: masterCode({ max: 30 }),
item_name: masterName({ max: 200 }),
item_category_id: Joi.number().integer().optional(),
item_subcategory_id: Joi.number().integer().optional(),
uom_id: Joi.number().integer().optional(),
hsn_code_id: Joi.number().integer().optional(),
gst_rate_id: Joi.number().integer().optional(),
brand_id: Joi.number().integer().optional(),
is_asset_item: Joi.boolean().optional(),
description: Joi.string().allow('').optional(),
specification: Joi.string().allow('').optional(),
min_order_qty: Joi.number().precision(4).optional(),
reorder_level: Joi.number().precision(4).optional(),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -100,10 +100,11 @@ const createLocation = async (type, payload, userId, requestId) => {
const listLocations = async (query, type = null) => {
const { page, limit, skip } = getPagination(query);
const effectiveType = type || query.type || null;
const where = {
deleted_at: null,
...(type ? { type } : {}),
...(effectiveType ? { type: effectiveType } : {}),
...(query.parent_id ? { parent_id: BigInt(query.parent_id) } : {}),
...(query.is_active !== undefined ? { is_active: query.is_active } : {}),
...(query.search
@ -119,7 +120,10 @@ const listLocations = async (query, type = null) => {
const [rows, total] = await Promise.all([
prisma.locations.findMany({
where,
include: type === LOCATION_TYPES.WAREHOUSE ? warehouseInclude : undefined,
include:
effectiveType === LOCATION_TYPES.WAREHOUSE || !effectiveType
? warehouseInclude
: undefined,
orderBy: { created_at: 'desc' },
skip,
take: limit,
@ -128,8 +132,8 @@ const listLocations = async (query, type = null) => {
]);
const mapRow = (row) => {
if (type === LOCATION_TYPES.PLANT) return toPlantResponse(row);
if (type === LOCATION_TYPES.WAREHOUSE) return toWarehouseResponse(row);
if (effectiveType === LOCATION_TYPES.PLANT) return toPlantResponse(row);
if (effectiveType === LOCATION_TYPES.WAREHOUSE) return toWarehouseResponse(row);
return toLocationResponse(row);
};

View File

@ -1,8 +1,7 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const locationFields = {
code: Joi.string().max(30).optional(),
name: Joi.string().max(150).optional(),
gstin: Joi.string().max(15).allow(null, '').optional(),
address: Joi.string().allow(null, '').optional(),
city: Joi.string().max(100).allow(null, '').optional(),
@ -16,8 +15,8 @@ const locationFields = {
};
const createPlantSchema = Joi.object({
code: Joi.string().max(30).required(),
name: Joi.string().max(150).required(),
code: masterCode({ max: 30, required: true }),
name: masterName({ max: 150, required: true }),
gstin: locationFields.gstin,
address: locationFields.address,
city: locationFields.city,
@ -27,24 +26,38 @@ const createPlantSchema = Joi.object({
is_active: Joi.boolean().default(true),
});
const updatePlantSchema = createPlantSchema.fork(['code', 'name'], (schema) => schema.optional()).min(1);
const updatePlantSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
gstin: locationFields.gstin,
address: locationFields.address,
city: locationFields.city,
state: locationFields.state,
pincode: locationFields.pincode,
phone: locationFields.phone,
is_active: Joi.boolean().optional(),
}).min(1);
const createWarehouseSchema = Joi.object({
code: Joi.string().max(30).required(),
name: Joi.string().max(150).required(),
code: masterCode({ max: 30, required: true }),
name: masterName({ max: 150, required: true }),
plant_id: Joi.number().integer().positive().required(),
location: locationFields.location,
is_active: Joi.boolean().default(true),
});
const updateWarehouseSchema = createWarehouseSchema
.fork(['code', 'name', 'plant_id'], (schema) => schema.optional())
.min(1);
const updateWarehouseSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
plant_id: Joi.number().integer().positive().optional(),
location: locationFields.location,
is_active: Joi.boolean().optional(),
}).min(1);
const createLocationSchema = Joi.object({
type: Joi.string().valid('plant', 'warehouse').required(),
code: Joi.string().max(30).required(),
name: Joi.string().max(150).required(),
code: masterCode({ max: 30, required: true }),
name: masterName({ max: 150, required: true }),
parent_id: Joi.when('type', {
is: 'warehouse',
then: Joi.number().integer().positive().required(),
@ -61,8 +74,8 @@ const createLocationSchema = Joi.object({
});
const updateLocationSchema = Joi.object({
code: Joi.string().max(30).optional(),
name: Joi.string().max(150).optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
parent_id: Joi.number().integer().positive().optional(),
gstin: locationFields.gstin,
address: locationFields.address,
@ -74,11 +87,7 @@ const updateLocationSchema = Joi.object({
is_active: Joi.boolean().optional(),
}).min(1);
const listQuerySchema = 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(),
is_active: Joi.boolean().optional(),
const listLocationsQuerySchema = listQuerySchema.keys({
type: Joi.string().valid('plant', 'warehouse').optional(),
parent_id: Joi.number().integer().positive().optional(),
});
@ -90,5 +99,5 @@ module.exports = {
updateWarehouseSchema,
createLocationSchema,
updateLocationSchema,
listQuerySchema,
listQuerySchema: listLocationsQuerySchema,
};

View File

@ -1,20 +1,20 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
credit_days: Joi.number().integer().optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
credit_days: Joi.number().integer().optional(),
description: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
}).min(1);
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,24 +1,7 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
gstin: Joi.string().allow('').optional(),
address: Joi.string().allow('').optional(),
city: Joi.string().allow('').optional(),
state: Joi.string().allow('').optional(),
pincode: Joi.string().allow('').optional(),
phone: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
is_active: Joi.boolean().optional(),
});
const {
createPlantSchema: createSchema,
updatePlantSchema: updateSchema,
listQuerySchema,
} = require('../locations/locations.validation');
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -1,22 +1,16 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const createUomSchema = Joi.object({
code: Joi.string().max(20).required(),
name: Joi.string().max(100).required(),
code: masterCode({ max: 20, required: true }),
name: masterName({ max: 100, required: true }),
is_active: Joi.boolean().optional(),
});
const updateUomSchema = Joi.object({
code: Joi.string().max(20).optional(),
name: Joi.string().max(100).optional(),
code: masterCode({ max: 20 }),
name: masterName({ max: 100 }),
is_active: Joi.boolean().optional(),
}).min(1);
const listUomQuerySchema = 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(),
is_active: Joi.boolean().optional(),
});
module.exports = { createUomSchema, updateUomSchema, listUomQuerySchema };
module.exports = { createUomSchema, updateUomSchema, listUomQuerySchema: listQuerySchema };

View File

@ -1,20 +1,7 @@
const Joi = require('joi');
const createSchema = Joi.object({
code: Joi.string().allow('').optional(),
name: Joi.string().allow('').optional(),
plant_id: Joi.number().integer().optional(),
location: Joi.string().allow('').optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = createSchema.min(1);
const listQuerySchema = 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(),
is_active: Joi.boolean().optional(),
});
const {
createWarehouseSchema: createSchema,
updateWarehouseSchema: updateSchema,
listQuerySchema,
} = require('../locations/locations.validation');
module.exports = { createSchema, updateSchema, listQuerySchema };

View File

@ -24,14 +24,28 @@ const computeLineAmounts = (line, gstRatePct = 0) => {
};
};
const computeHeaderTotals = (lines, headerDiscount = 0, freight = 0, other = 0) => {
const computeHeaderTotals = (
lines,
headerDiscount = 0,
freight = 0,
other = 0,
{ tdsApplicable = false, tdsSectionPct = 0, adjustment = 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 tdsAmount = tdsApplicable ? round4((subTotal * toNum(tdsSectionPct)) / 100) : 0;
const adjustmentAmount = round4(adjustment);
const grandTotal = round4(
subTotal + taxTotal + freightCharges + otherCharges - headerDiscountAmount
subTotal +
taxTotal +
freightCharges +
otherCharges -
headerDiscountAmount -
tdsAmount +
adjustmentAmount
);
return {
@ -40,6 +54,8 @@ const computeHeaderTotals = (lines, headerDiscount = 0, freight = 0, other = 0)
discount_amount: headerDiscountAmount,
freight_charges: freightCharges,
other_charges: otherCharges,
tds_amount: tdsAmount,
adjustment: adjustmentAmount,
grand_total: grandTotal,
};
};

View File

@ -12,7 +12,7 @@ const {
AMENDABLE_STATUSES,
DELETABLE_STATUSES,
} = require('./purchase-orders.constants');
const { computeLineAmounts, computeHeaderTotals } = require('./purchase-orders.calculations');
const { computeLineAmounts, computeHeaderTotals, toNum } = require('./purchase-orders.calculations');
const repository = require('./purchase-orders.repository');
const { assertPlant, assertWarehouse } = require('../../utils/locations');
@ -215,11 +215,19 @@ const buildHeaderData = async (payload, builtItems, userId) => {
if (payload.delivery_term_id)
await assertReference('delivery_terms', payload.delivery_term_id, 'delivery_term_id');
const tdsApplicable = Boolean(payload.tds_applicable);
const tdsSectionPct = tdsApplicable ? toNum(payload.tds_section_pct) : 0;
const totals = computeHeaderTotals(
builtItems,
payload.discount_amount,
payload.freight_charges,
payload.other_charges
payload.other_charges,
{
tdsApplicable,
tdsSectionPct,
adjustment: payload.adjustment,
}
);
return {
@ -236,6 +244,8 @@ const buildHeaderData = async (payload, builtItems, userId) => {
: null,
terms_and_conditions: payload.terms_and_conditions || null,
remarks: payload.remarks || null,
tds_applicable: tdsApplicable,
tds_section_pct: tdsApplicable ? tdsSectionPct : null,
...totals,
updated_by: userId ? BigInt(userId) : null,
};
@ -337,6 +347,16 @@ const updatePurchaseOrder = async (id, payload, userId, requestId) => {
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),
tds_applicable:
payload.tds_applicable !== undefined ? payload.tds_applicable : existing.tds_applicable,
tds_section_pct:
payload.tds_section_pct !== undefined
? payload.tds_section_pct
: existing.tds_section_pct !== null && existing.tds_section_pct !== undefined
? Number(existing.tds_section_pct)
: null,
adjustment:
payload.adjustment !== undefined ? payload.adjustment : Number(existing.adjustment ?? 0),
terms_and_conditions:
payload.terms_and_conditions !== undefined
? payload.terms_and_conditions
@ -598,6 +618,16 @@ const amendPurchaseOrder = async (id, payload, userId, requestId) => {
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),
tds_applicable:
payload.tds_applicable !== undefined ? payload.tds_applicable : existing.tds_applicable,
tds_section_pct:
payload.tds_section_pct !== undefined
? payload.tds_section_pct
: existing.tds_section_pct !== null && existing.tds_section_pct !== undefined
? Number(existing.tds_section_pct)
: null,
adjustment:
payload.adjustment !== undefined ? payload.adjustment : Number(existing.adjustment ?? 0),
terms_and_conditions:
payload.terms_and_conditions !== undefined
? payload.terms_and_conditions

View File

@ -29,6 +29,17 @@ const poHeaderFields = {
discount_amount: Joi.number().min(0).default(0),
freight_charges: Joi.number().min(0).default(0),
other_charges: Joi.number().min(0).default(0),
tds_applicable: Joi.boolean().default(false),
tds_section_pct: Joi.number()
.min(0)
.max(100)
.allow(null)
.when('tds_applicable', {
is: true,
then: Joi.required(),
otherwise: Joi.valid(null).optional(),
}),
adjustment: Joi.number().precision(4).default(0),
terms_and_conditions: Joi.string().allow(null, '').optional(),
remarks: Joi.string().allow(null, '').optional(),
};
@ -51,6 +62,9 @@ const updatePurchaseOrderSchema = Joi.object({
discount_amount: poHeaderFields.discount_amount.optional(),
freight_charges: poHeaderFields.freight_charges.optional(),
other_charges: poHeaderFields.other_charges.optional(),
tds_applicable: poHeaderFields.tds_applicable.optional(),
tds_section_pct: poHeaderFields.tds_section_pct.optional(),
adjustment: poHeaderFields.adjustment.optional(),
terms_and_conditions: poHeaderFields.terms_and_conditions.optional(),
remarks: poHeaderFields.remarks.optional(),
items: Joi.array().items(poItemSchema).min(1).optional(),
@ -69,6 +83,9 @@ const amendPurchaseOrderSchema = Joi.object({
discount_amount: poHeaderFields.discount_amount.optional(),
freight_charges: poHeaderFields.freight_charges.optional(),
other_charges: poHeaderFields.other_charges.optional(),
tds_applicable: poHeaderFields.tds_applicable.optional(),
tds_section_pct: poHeaderFields.tds_section_pct.optional(),
adjustment: poHeaderFields.adjustment.optional(),
terms_and_conditions: poHeaderFields.terms_and_conditions.optional(),
remarks: poHeaderFields.remarks.optional(),
items: Joi.array().items(poItemSchema).min(1).optional(),

View File

@ -0,0 +1,36 @@
const asyncHandler = require('../../utils/asyncHandler');
const ApiResponse = require('../../utils/ApiResponse');
const service = require('./settings.service');
const getCompany = asyncHandler(async (_req, res) => {
const data = await service.getCompany();
res.json(new ApiResponse(200, data, 'Company settings fetched'));
});
const updateCompany = asyncHandler(async (req, res) => {
const data = await service.updateCompany(req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'Company settings updated successfully'));
});
const uploadCompanyLogo = asyncHandler(async (req, res) => {
const data = await service.updateCompanyLogo(req.file, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'Company logo updated successfully'));
});
const getEmailSettings = asyncHandler(async (_req, res) => {
const data = await service.getEmailSettings();
res.json(new ApiResponse(200, data, 'Email settings fetched'));
});
const updateEmailSettings = asyncHandler(async (req, res) => {
const data = await service.updateEmailSettings(req.body, req.user?.id, req.id);
res.json(new ApiResponse(200, data, 'Email settings updated successfully'));
});
module.exports = {
getCompany,
updateCompany,
uploadCompanyLogo,
getEmailSettings,
updateEmailSettings,
};

View File

@ -0,0 +1,25 @@
const express = require('express');
const authenticate = require('../../middlewares/auth.middleware');
const authorize = require('../../middlewares/rbac.middleware');
const validate = require('../../middlewares/validate.middleware');
const upload = require('../../middlewares/upload.middleware');
const controller = require('./settings.controller');
const { companyUpdateSchema, emailUpdateSchema } = require('./settings.validation');
const router = express.Router();
router.use(authenticate);
router.get('/company', authorize('SETTINGS', 'view'), controller.getCompany);
router.put('/company', authorize('SETTINGS', 'edit'), validate(companyUpdateSchema), controller.updateCompany);
router.post(
'/company/logo',
authorize('SETTINGS', 'edit'),
upload.single('logo'),
controller.uploadCompanyLogo
);
router.get('/email', authorize('SETTINGS', 'view'), controller.getEmailSettings);
router.put('/email', authorize('SETTINGS', 'edit'), validate(emailUpdateSchema), controller.updateEmailSettings);
module.exports = router;

View File

@ -0,0 +1,211 @@
const path = require('path');
const fs = require('fs');
const prisma = require('../../config/prisma');
const env = require('../../config/env');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { encrypt, decrypt } = require('../../utils/encryption');
const COMPANY_ID = BigInt(1);
const EMAIL_SETTINGS_ID = BigInt(1);
const buildLogoUrl = (logoPath) => {
if (!logoPath) return null;
const normalized = logoPath.replace(/\\/g, '/');
return normalized.startsWith('/') ? normalized : `/${normalized}`;
};
const sanitizeCompany = (row) => {
if (!row) return null;
return {
id: row.id,
org_name: row.org_name,
mobile: row.mobile,
email: row.email,
website: row.website,
logo_path: row.logo_path,
logo_url: buildLogoUrl(row.logo_path),
address: row.address,
city: row.city,
state: row.state,
pincode: row.pincode,
created_at: row.created_at,
updated_at: row.updated_at,
};
};
const sanitizeEmailSettings = (row) => {
if (!row) return null;
return {
id: row.id,
smtp_host: row.smtp_host,
smtp_port: row.smtp_port,
smtp_username: row.smtp_username,
sender_email: row.sender_email,
sender_name: row.sender_name,
has_smtp_password: Boolean(row.smtp_password),
created_at: row.created_at,
updated_at: row.updated_at,
};
};
const getOrCreateCompany = async () => {
return prisma.company.upsert({
where: { id: COMPANY_ID },
create: { id: COMPANY_ID, org_name: '' },
update: {},
});
};
const getOrCreateEmailSettings = async () => {
return prisma.email_settings.upsert({
where: { id: EMAIL_SETTINGS_ID },
create: { id: EMAIL_SETTINGS_ID, smtp_port: 587 },
update: {},
});
};
const getCompany = async () => sanitizeCompany(await getOrCreateCompany());
const updateCompany = async (payload, userId, requestId) => {
const existing = await getOrCreateCompany();
const data = {
...(payload.org_name !== undefined ? { org_name: payload.org_name || null } : {}),
...(payload.mobile !== undefined ? { mobile: payload.mobile || null } : {}),
...(payload.email !== undefined ? { email: payload.email || null } : {}),
...(payload.website !== undefined ? { website: payload.website || null } : {}),
...(payload.address !== undefined ? { address: payload.address || null } : {}),
...(payload.city !== undefined ? { city: payload.city || null } : {}),
...(payload.state !== undefined ? { state: payload.state || null } : {}),
...(payload.pincode !== undefined ? { pincode: payload.pincode || null } : {}),
updated_by: userId ? BigInt(userId) : null,
};
const updated = await prisma.company.update({
where: { id: COMPANY_ID },
data,
});
await auditLog({
tableName: 'company',
recordId: COMPANY_ID,
action: 'UPDATE',
oldValue: sanitizeCompany(existing),
newValue: sanitizeCompany(updated),
userId,
requestId,
});
return sanitizeCompany(updated);
};
const updateCompanyLogo = async (file, userId, requestId) => {
if (!file) throw new ApiError(400, 'Logo file is required');
const existing = await getOrCreateCompany();
if (existing.logo_path) {
const oldPath = path.resolve(process.cwd(), existing.logo_path);
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath);
}
}
const logoPath = path.join(env.UPLOAD_DIR, file.filename).replace(/\\/g, '/');
const updated = await prisma.company.update({
where: { id: COMPANY_ID },
data: {
logo_path: logoPath,
updated_by: userId ? BigInt(userId) : null,
},
});
await auditLog({
tableName: 'company',
recordId: COMPANY_ID,
action: 'UPDATE',
oldValue: { logo_path: existing.logo_path },
newValue: { logo_path: updated.logo_path },
userId,
requestId,
});
return sanitizeCompany(updated);
};
const getEmailSettings = async () => sanitizeEmailSettings(await getOrCreateEmailSettings());
const updateEmailSettings = async (payload, userId, requestId) => {
const existing = await getOrCreateEmailSettings();
const data = {
...(payload.smtp_host !== undefined ? { smtp_host: payload.smtp_host || null } : {}),
...(payload.smtp_port !== undefined ? { smtp_port: payload.smtp_port } : {}),
...(payload.smtp_username !== undefined ? { smtp_username: payload.smtp_username || null } : {}),
...(payload.sender_email !== undefined ? { sender_email: payload.sender_email || null } : {}),
...(payload.sender_name !== undefined ? { sender_name: payload.sender_name || null } : {}),
updated_by: userId ? BigInt(userId) : null,
};
if (payload.smtp_password !== undefined && payload.smtp_password !== '') {
data.smtp_password = encrypt(payload.smtp_password);
}
const updated = await prisma.email_settings.update({
where: { id: EMAIL_SETTINGS_ID },
data,
});
await auditLog({
tableName: 'email_settings',
recordId: EMAIL_SETTINGS_ID,
action: 'UPDATE',
oldValue: sanitizeEmailSettings(existing),
newValue: sanitizeEmailSettings(updated),
userId,
requestId,
});
return sanitizeEmailSettings(updated);
};
const getCompanyForDocuments = async () => {
const row = await getOrCreateCompany();
return {
name: row.org_name || 'Company',
address: row.address || '',
city: row.city || '',
state: row.state || '',
pincode: row.pincode || '',
gstin: '',
phone: row.mobile || '',
email: row.email || '',
website: row.website || '',
logo_url: buildLogoUrl(row.logo_path),
};
};
const getSmtpConfig = async () => {
const row = await getOrCreateEmailSettings();
if (!row.smtp_host) return null;
return {
host: row.smtp_host,
port: row.smtp_port,
username: row.smtp_username,
password: row.smtp_password ? decrypt(row.smtp_password) : null,
fromEmail: row.sender_email,
fromName: row.sender_name,
};
};
module.exports = {
getCompany,
updateCompany,
updateCompanyLogo,
getEmailSettings,
updateEmailSettings,
getCompanyForDocuments,
getSmtpConfig,
};

View File

@ -0,0 +1,27 @@
const Joi = require('joi');
const { masterName } = require('../masters/_shared/masters.validation');
const companyUpdateSchema = Joi.object({
org_name: masterName({ max: 200 }),
mobile: Joi.string().max(20).allow(null, '').optional(),
email: Joi.string().email().max(200).allow(null, '').optional(),
website: Joi.string().max(255).allow(null, '').optional(),
address: Joi.string().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(),
}).min(1);
const emailUpdateSchema = Joi.object({
smtp_host: Joi.string().max(255).allow(null, '').optional(),
smtp_port: Joi.number().integer().min(1).max(65535).optional(),
smtp_username: Joi.string().max(200).allow(null, '').optional(),
smtp_password: Joi.string().max(200).allow(null, '').optional(),
sender_email: Joi.string().email().max(200).allow(null, '').optional(),
sender_name: masterName({ max: 200 }),
}).min(1);
module.exports = {
companyUpdateSchema,
emailUpdateSchema,
};

View File

@ -0,0 +1,59 @@
const GST_TREATMENTS = [
{ value: 'REGULAR', label: 'Regular' },
{ value: 'COMPOSITION', label: 'Composition' },
{ value: 'UNREGISTERED', label: 'Unregistered' },
{ value: 'CONSUMER', label: 'Consumer' },
{ value: 'SEZ', label: 'SEZ' },
{ value: 'OVERSEAS', label: 'Overseas' },
{ value: 'DEEMED_EXPORT', label: 'Deemed Export' },
{ value: 'OTHER', label: 'Other' },
];
const SOURCE_OF_SUPPLY_OPTIONS = [
{ value: 'Andhra Pradesh', label: 'Andhra Pradesh' },
{ value: 'Arunachal Pradesh', label: 'Arunachal Pradesh' },
{ value: 'Assam', label: 'Assam' },
{ value: 'Bihar', label: 'Bihar' },
{ value: 'Chhattisgarh', label: 'Chhattisgarh' },
{ value: 'Goa', label: 'Goa' },
{ value: 'Gujarat', label: 'Gujarat' },
{ value: 'Haryana', label: 'Haryana' },
{ value: 'Himachal Pradesh', label: 'Himachal Pradesh' },
{ value: 'Jharkhand', label: 'Jharkhand' },
{ value: 'Karnataka', label: 'Karnataka' },
{ value: 'Kerala', label: 'Kerala' },
{ value: 'Madhya Pradesh', label: 'Madhya Pradesh' },
{ value: 'Maharashtra', label: 'Maharashtra' },
{ value: 'Manipur', label: 'Manipur' },
{ value: 'Meghalaya', label: 'Meghalaya' },
{ value: 'Mizoram', label: 'Mizoram' },
{ value: 'Nagaland', label: 'Nagaland' },
{ value: 'Odisha', label: 'Odisha' },
{ value: 'Punjab', label: 'Punjab' },
{ value: 'Rajasthan', label: 'Rajasthan' },
{ value: 'Sikkim', label: 'Sikkim' },
{ value: 'Tamil Nadu', label: 'Tamil Nadu' },
{ value: 'Telangana', label: 'Telangana' },
{ value: 'Tripura', label: 'Tripura' },
{ value: 'Uttar Pradesh', label: 'Uttar Pradesh' },
{ value: 'Uttarakhand', label: 'Uttarakhand' },
{ value: 'West Bengal', label: 'West Bengal' },
{ value: 'Andaman and Nicobar Islands', label: 'Andaman and Nicobar Islands' },
{ value: 'Chandigarh', label: 'Chandigarh' },
{ value: 'Dadra and Nagar Haveli and Daman and Diu', label: 'Dadra and Nagar Haveli and Daman and Diu' },
{ value: 'Delhi', label: 'Delhi' },
{ value: 'Jammu and Kashmir', label: 'Jammu and Kashmir' },
{ value: 'Ladakh', label: 'Ladakh' },
{ value: 'Lakshadweep', label: 'Lakshadweep' },
{ value: 'Puducherry', label: 'Puducherry' },
];
const GST_TREATMENT_VALUES = GST_TREATMENTS.map((item) => item.value);
const SOURCE_OF_SUPPLY_VALUES = SOURCE_OF_SUPPLY_OPTIONS.map((item) => item.value);
module.exports = {
GST_TREATMENTS,
SOURCE_OF_SUPPLY_OPTIONS,
GST_TREATMENT_VALUES,
SOURCE_OF_SUPPLY_VALUES,
};

View File

@ -12,6 +12,16 @@ const list = asyncHandler(async (req, res) => {
res.json(new ApiResponse(200, result.data, 'Vendors fetched', result.meta));
});
const listGstTreatments = asyncHandler(async (_req, res) => {
const data = service.listGstTreatments();
res.json(new ApiResponse(200, data, 'GST treatment options fetched'));
});
const listSourceOfSupplyOptions = asyncHandler(async (_req, res) => {
const data = service.listSourceOfSupplyOptions();
res.json(new ApiResponse(200, data, 'Source of supply options fetched'));
});
const getOne = asyncHandler(async (req, res) => {
const data = await service.getVendorById(req.params.id);
res.json(new ApiResponse(200, data, 'Vendor fetched'));
@ -130,9 +140,52 @@ const removeBankDetail = asyncHandler(async (req, res) => {
res.json(new ApiResponse(200, null, 'Vendor bank detail deleted successfully'));
});
const listItemMappings = asyncHandler(async (req, res) => {
const data = await service.listItemMappings(req.params.vendorId);
res.json(new ApiResponse(200, data, 'Vendor item mappings fetched'));
});
const createItemMapping = asyncHandler(async (req, res) => {
const data = await service.createItemMapping(
req.params.vendorId,
req.body,
req.user?.id,
req.id
);
res.status(201).json(new ApiResponse(201, data, 'Vendor item mapping created successfully'));
});
const getItemMapping = asyncHandler(async (req, res) => {
const data = await service.getItemMapping(req.params.vendorId, req.params.mappingId);
res.json(new ApiResponse(200, data, 'Vendor item mapping fetched'));
});
const updateItemMapping = asyncHandler(async (req, res) => {
const data = await service.updateItemMapping(
req.params.vendorId,
req.params.mappingId,
req.body,
req.user?.id,
req.id
);
res.json(new ApiResponse(200, data, 'Vendor item mapping updated successfully'));
});
const removeItemMapping = asyncHandler(async (req, res) => {
await service.deleteItemMapping(
req.params.vendorId,
req.params.mappingId,
req.user?.id,
req.id
);
res.json(new ApiResponse(200, null, 'Vendor item mapping deleted successfully'));
});
module.exports = {
create,
list,
listGstTreatments,
listSourceOfSupplyOptions,
getOne,
update,
changeStatus,
@ -152,4 +205,9 @@ module.exports = {
getBankDetail,
updateBankDetail,
removeBankDetail,
listItemMappings,
createItemMapping,
getItemMapping,
updateItemMapping,
removeItemMapping,
};

View File

@ -14,6 +14,8 @@ const {
updateContactSchema,
createBankDetailSchema,
updateBankDetailSchema,
createItemMappingSchema,
updateItemMappingSchema,
} = require('./vendors.validation');
const router = express.Router();
@ -28,6 +30,9 @@ router.get(
);
router.post('/', authorize('VENDOR', 'create'), validate(createVendorSchema), controller.create);
router.get('/gst-treatments', authorize('VENDOR', 'view'), controller.listGstTreatments);
router.get('/source-of-supply', authorize('VENDOR', 'view'), controller.listSourceOfSupplyOptions);
router.get('/:vendorId/addresses', authorize('VENDOR', 'view'), controller.listAddresses);
router.post(
'/:vendorId/addresses',
@ -92,6 +97,30 @@ router.delete(
controller.removeBankDetail
);
router.get('/:vendorId/items', authorize('VENDOR', 'view'), controller.listItemMappings);
router.post(
'/:vendorId/items',
authorize('VENDOR', 'create'),
validate(createItemMappingSchema),
controller.createItemMapping
);
router.get(
'/:vendorId/items/:mappingId',
authorize('VENDOR', 'view'),
controller.getItemMapping
);
router.put(
'/:vendorId/items/:mappingId',
authorize('VENDOR', 'edit'),
validate(updateItemMappingSchema),
controller.updateItemMapping
);
router.delete(
'/:vendorId/items/:mappingId',
authorize('VENDOR', 'delete'),
controller.removeItemMapping
);
router.get('/:id', authorize('VENDOR', 'view'), controller.getOne);
router.put('/:id', authorize('VENDOR', 'edit'), validate(updateVendorSchema), controller.update);
router.patch(

View File

@ -4,6 +4,10 @@ const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode');
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
const {
GST_TREATMENTS,
SOURCE_OF_SUPPLY_OPTIONS,
} = require('./vendors.constants');
const vendorInclude = {
payment_terms: { select: { id: true, code: true, name: true } },
@ -14,6 +18,17 @@ const vendorDetailInclude = {
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' } },
vendor_item_mapping: {
where: { is_active: true },
orderBy: { created_at: 'asc' },
include: {
items: { select: { id: true, item_code: true, item_name: true, is_active: true } },
},
},
};
const itemMappingInclude = {
items: { select: { id: true, item_code: true, item_name: true, is_active: true } },
};
const sanitizeBankDetail = (row) => {
@ -26,6 +41,19 @@ const sanitizeBankDetail = (row) => {
};
};
const sanitizeItemMapping = (row) => {
if (!row) return null;
const { items, ...rest } = row;
return {
...rest,
last_purchase_rate:
rest.last_purchase_rate !== null && rest.last_purchase_rate !== undefined
? Number(rest.last_purchase_rate)
: null,
item: items || null,
};
};
const sanitizeVendor = (vendor, { includeChildren = false } = {}) => {
if (!vendor) return null;
const result = { ...vendor };
@ -33,9 +61,11 @@ const sanitizeVendor = (vendor, { includeChildren = false } = {}) => {
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);
result.item_mappings = (vendor.vendor_item_mapping || []).map(sanitizeItemMapping);
delete result.vendor_addresses;
delete result.vendor_contacts;
delete result.vendor_bank_details;
delete result.vendor_item_mapping;
}
return result;
};
@ -57,10 +87,20 @@ const assertPaymentTerm = async (paymentTermId) => {
if (!term) throw new ApiError(422, 'Invalid payment_term_id');
};
const assertItem = async (itemId) => {
const item = await prisma.items.findFirst({
where: { id: BigInt(itemId), deleted_at: null },
});
if (!item) throw new ApiError(422, 'Invalid item_id');
return item;
};
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.gst_treatment !== undefined) data.gst_treatment = data.gst_treatment || null;
if (data.source_of_supply !== undefined) data.source_of_supply = data.source_of_supply || 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);
@ -126,6 +166,10 @@ const listVendors = async (query) => {
return { data: rows.map((row) => sanitizeVendor(row)), meta: { page, limit, total } };
};
const listGstTreatments = () => GST_TREATMENTS;
const listSourceOfSupplyOptions = () => SOURCE_OF_SUPPLY_OPTIONS;
const getVendorById = async (id) => {
const vendor = await getVendorOrThrow(id, { includeChildren: true });
return sanitizeVendor(vendor, { includeChildren: true });
@ -469,9 +513,152 @@ const deleteBankDetail = async (vendorId, bankDetailId, userId, requestId) => {
});
};
const listItemMappings = async (vendorId) => {
await getVendorOrThrow(vendorId);
const rows = await prisma.vendor_item_mapping.findMany({
where: { vendor_id: BigInt(vendorId), is_active: true },
include: itemMappingInclude,
orderBy: { created_at: 'asc' },
});
return rows.map(sanitizeItemMapping);
};
const createItemMapping = async (vendorId, payload, userId, requestId) => {
await getVendorOrThrow(vendorId);
await assertItem(payload.item_id);
const vendor_id = BigInt(vendorId);
const item_id = BigInt(payload.item_id);
const existing = await prisma.vendor_item_mapping.findUnique({
where: { vendor_id_item_id: { vendor_id, item_id } },
include: itemMappingInclude,
});
if (existing) {
if (existing.is_active) {
throw new ApiError(409, 'This item is already mapped to the vendor');
}
const reactivated = await prisma.vendor_item_mapping.update({
where: { id: existing.id },
data: {
last_purchase_rate: payload.last_purchase_rate ?? null,
is_preferred: payload.is_preferred ?? false,
is_active: true,
updated_by: userId ? BigInt(userId) : null,
},
include: itemMappingInclude,
});
await auditLog({
tableName: 'vendor_item_mapping',
recordId: reactivated.id,
action: 'UPDATE',
oldValue: sanitizeItemMapping(existing),
newValue: sanitizeItemMapping(reactivated),
userId,
requestId,
});
return sanitizeItemMapping(reactivated);
}
const created = await prisma.vendor_item_mapping.create({
data: {
vendor_id,
item_id,
last_purchase_rate: payload.last_purchase_rate ?? null,
is_preferred: payload.is_preferred ?? false,
is_active: payload.is_active ?? true,
created_by: userId ? BigInt(userId) : null,
updated_by: userId ? BigInt(userId) : null,
},
include: itemMappingInclude,
});
await auditLog({
tableName: 'vendor_item_mapping',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: sanitizeItemMapping(created),
userId,
requestId,
});
return sanitizeItemMapping(created);
};
const getItemMapping = async (vendorId, mappingId) => {
await getVendorOrThrow(vendorId);
const row = await prisma.vendor_item_mapping.findFirst({
where: { id: BigInt(mappingId), vendor_id: BigInt(vendorId), is_active: true },
include: itemMappingInclude,
});
if (!row) throw new ApiError(404, 'Vendor item mapping not found');
return sanitizeItemMapping(row);
};
const updateItemMapping = async (vendorId, mappingId, payload, userId, requestId) => {
const existing = await prisma.vendor_item_mapping.findFirst({
where: { id: BigInt(mappingId), vendor_id: BigInt(vendorId), is_active: true },
include: itemMappingInclude,
});
if (!existing) throw new ApiError(404, 'Vendor item mapping not found');
const data = { ...payload, updated_by: userId ? BigInt(userId) : null };
if (payload.last_purchase_rate === null) {
data.last_purchase_rate = null;
}
const updated = await prisma.vendor_item_mapping.update({
where: { id: BigInt(mappingId) },
data,
include: itemMappingInclude,
});
await auditLog({
tableName: 'vendor_item_mapping',
recordId: mappingId,
action: 'UPDATE',
oldValue: sanitizeItemMapping(existing),
newValue: sanitizeItemMapping(updated),
userId,
requestId,
});
return sanitizeItemMapping(updated);
};
const deleteItemMapping = async (vendorId, mappingId, userId, requestId) => {
const existing = await prisma.vendor_item_mapping.findFirst({
where: { id: BigInt(mappingId), vendor_id: BigInt(vendorId), is_active: true },
include: itemMappingInclude,
});
if (!existing) throw new ApiError(404, 'Vendor item mapping not found');
await prisma.vendor_item_mapping.update({
where: { id: BigInt(mappingId) },
data: { is_active: false, updated_by: userId ? BigInt(userId) : null },
});
await auditLog({
tableName: 'vendor_item_mapping',
recordId: mappingId,
action: 'DELETE',
oldValue: sanitizeItemMapping(existing),
newValue: { is_active: false },
userId,
requestId,
});
};
module.exports = {
createVendor,
listVendors,
listGstTreatments,
listSourceOfSupplyOptions,
getVendorById,
updateVendor,
setVendorStatus,
@ -491,4 +678,9 @@ module.exports = {
getBankDetail,
updateBankDetail,
deleteBankDetail,
listItemMappings,
createItemMapping,
getItemMapping,
updateItemMapping,
deleteItemMapping,
};

View File

@ -1,4 +1,8 @@
const Joi = require('joi');
const {
GST_TREATMENT_VALUES,
SOURCE_OF_SUPPLY_VALUES,
} = require('./vendors.constants');
const vendorTypes = ['RAW_MATERIAL', 'PACKING_MATERIAL', 'ASSET_CAPITAL', 'SERVICE', 'GENERAL'];
const vendorStatuses = ['active', 'inactive', 'blacklisted'];
@ -10,6 +14,14 @@ const createVendorSchema = Joi.object({
vendor_type: Joi.string()
.valid(...vendorTypes)
.required(),
gst_treatment: Joi.string()
.valid(...GST_TREATMENT_VALUES)
.allow(null, '')
.optional(),
source_of_supply: Joi.string()
.valid(...SOURCE_OF_SUPPLY_VALUES)
.allow(null, '')
.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(),
@ -23,6 +35,14 @@ const updateVendorSchema = Joi.object({
vendor_type: Joi.string()
.valid(...vendorTypes)
.optional(),
gst_treatment: Joi.string()
.valid(...GST_TREATMENT_VALUES)
.allow(null, '')
.optional(),
source_of_supply: Joi.string()
.valid(...SOURCE_OF_SUPPLY_VALUES)
.allow(null, '')
.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(),
@ -107,6 +127,19 @@ const updateBankDetailSchema = Joi.object({
is_active: Joi.boolean().optional(),
}).min(1);
const createItemMappingSchema = Joi.object({
item_id: Joi.number().integer().positive().required(),
last_purchase_rate: Joi.number().precision(4).min(0).allow(null).optional(),
is_preferred: Joi.boolean().default(false),
is_active: Joi.boolean().default(true),
});
const updateItemMappingSchema = Joi.object({
last_purchase_rate: Joi.number().precision(4).min(0).allow(null).optional(),
is_preferred: Joi.boolean().optional(),
is_active: Joi.boolean().optional(),
}).min(1);
module.exports = {
createVendorSchema,
updateVendorSchema,
@ -118,4 +151,6 @@ module.exports = {
updateContactSchema,
createBankDetailSchema,
updateBankDetailSchema,
createItemMappingSchema,
updateItemMappingSchema,
};

View File

@ -9,6 +9,7 @@ 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('/settings', require('../../modules/settings/settings.routes'));
router.use('/masters', require('../../modules/masters'));
router.get('/healthz', (req, res) => {

View File

@ -1,4 +1,6 @@
const app = require('./app');
const fs = require('fs');
const path = require('path');
const env = require('./config/env');
const logger = require('./config/logger');
const prisma = require('./config/prisma');
@ -7,6 +9,8 @@ BigInt.prototype.toJSON = function toJSON() {
return this.toString();
};
fs.mkdirSync(path.resolve(env.UPLOAD_DIR), { recursive: true });
const server = app.listen(env.PORT, () => {
logger.info(`Server running on port ${env.PORT} [${env.NODE_ENV}]`);
});