diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index 8b74779..b7dfedc 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -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 vendor–item 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** | --- diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 01a94da..ba363ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) +} diff --git a/scripts/patch-asset-subcategories.sql b/scripts/patch-asset-subcategories.sql new file mode 100644 index 0000000..6bb4263 --- /dev/null +++ b/scripts/patch-asset-subcategories.sql @@ -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'; diff --git a/scripts/patch-assets-depreciation-rate.sql b/scripts/patch-assets-depreciation-rate.sql new file mode 100644 index 0000000..04429d1 --- /dev/null +++ b/scripts/patch-assets-depreciation-rate.sql @@ -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'; diff --git a/scripts/patch-company-email-settings.sql b/scripts/patch-company-email-settings.sql new file mode 100644 index 0000000..e0a68af --- /dev/null +++ b/scripts/patch-company-email-settings.sql @@ -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; diff --git a/scripts/patch-po-tds-adjustment.sql b/scripts/patch-po-tds-adjustment.sql new file mode 100644 index 0000000..7c34c82 --- /dev/null +++ b/scripts/patch-po-tds-adjustment.sql @@ -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'; diff --git a/scripts/patch-vendors-gst-source.sql b/scripts/patch-vendors-gst-source.sql new file mode 100644 index 0000000..02915e2 --- /dev/null +++ b/scripts/patch-vendors-gst-source.sql @@ -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'; diff --git a/src/app.js b/src/app.js index 958cb2e..858594b 100644 --- a/src/app.js +++ b/src/app.js @@ -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() }); }); diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 983241b..ce97f62 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -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' } } } diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index e054960..75c32ca 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -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] diff --git a/src/docs/grn-routes.yaml b/src/docs/grn-routes.yaml index 675c4a1..42332df 100644 --- a/src/docs/grn-routes.yaml +++ b/src/docs/grn-routes.yaml @@ -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] diff --git a/src/docs/purchase-orders-routes.yaml b/src/docs/purchase-orders-routes.yaml index bd51abb..e8ac2ca 100644 --- a/src/docs/purchase-orders-routes.yaml +++ b/src/docs/purchase-orders-routes.yaml @@ -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: diff --git a/src/docs/settings-routes.yaml b/src/docs/settings-routes.yaml new file mode 100644 index 0000000..c323fad --- /dev/null +++ b/src/docs/settings-routes.yaml @@ -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" } } } diff --git a/src/docs/vendors-routes.yaml b/src/docs/vendors-routes.yaml index 4f838a4..93e37d8 100644 --- a/src/docs/vendors-routes.yaml +++ b/src/docs/vendors-routes.yaml @@ -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 } diff --git a/src/modules/assets/assets-amc.service.js b/src/modules/assets/assets-amc.service.js index b3fc61c..cf0573c 100644 --- a/src/modules/assets/assets-amc.service.js +++ b/src/modules/assets/assets-amc.service.js @@ -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) => { diff --git a/src/modules/assets/assets-insurance.service.js b/src/modules/assets/assets-insurance.service.js index 0e5b1e2..dbc115d 100644 --- a/src/modules/assets/assets-insurance.service.js +++ b/src/modules/assets/assets-insurance.service.js @@ -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), diff --git a/src/modules/assets/assets-service-visits.service.js b/src/modules/assets/assets-service-visits.service.js index 768e107..b456c8f 100644 --- a/src/modules/assets/assets-service-visits.service.js +++ b/src/modules/assets/assets-service-visits.service.js @@ -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, }; diff --git a/src/modules/assets/assets.constants.js b/src/modules/assets/assets.constants.js index 00dabeb..7f74984 100644 --- a/src/modules/assets/assets.constants.js +++ b/src/modules/assets/assets.constants.js @@ -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']; diff --git a/src/modules/assets/assets.controller.js b/src/modules/assets/assets.controller.js index 7335a5a..a82eeb3 100644 --- a/src/modules/assets/assets.controller.js +++ b/src/modules/assets/assets.controller.js @@ -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, diff --git a/src/modules/assets/assets.depreciation.js b/src/modules/assets/assets.depreciation.js new file mode 100644 index 0000000..1dcadc9 --- /dev/null +++ b/src/modules/assets/assets.depreciation.js @@ -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, +}; diff --git a/src/modules/assets/assets.helpers.js b/src/modules/assets/assets.helpers.js index 58b2360..0bf5d28 100644 --- a/src/modules/assets/assets.helpers.js +++ b/src/modules/assets/assets.helpers.js @@ -10,6 +10,7 @@ const SOFT_DELETE_TABLES = new Set([ 'grn', 'grn_items', 'asset_categories', + 'asset_subcategories', ]); const toDateOnly = (value) => { diff --git a/src/modules/assets/assets.routes.js b/src/modules/assets/assets.routes.js index 73bc929..4e0d45a 100644 --- a/src/modules/assets/assets.routes.js +++ b/src/modules/assets/assets.routes.js @@ -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', diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index d9e79e8..7d3a62d 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -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, }; diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index 358c661..f3732ad 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -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, }; diff --git a/src/modules/grn/grn.repository.js b/src/modules/grn/grn.repository.js index e0e25b1..84758ff 100644 --- a/src/modules/grn/grn.repository.js +++ b/src/modules/grn/grn.repository.js @@ -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, diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js index 0cb52b4..57cbcea 100644 --- a/src/modules/grn/grn.service.js +++ b/src/modules/grn/grn.service.js @@ -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, }); diff --git a/src/modules/grn/grn.validation.js b/src/modules/grn/grn.validation.js index 51fe203..a03474a 100644 --- a/src/modules/grn/grn.validation.js +++ b/src/modules/grn/grn.validation.js @@ -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({ diff --git a/src/modules/masters/_shared/master.factory.js b/src/modules/masters/_shared/master.factory.js index cb9d4fa..cc3748a 100644 --- a/src/modules/masters/_shared/master.factory.js +++ b/src/modules/masters/_shared/master.factory.js @@ -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 }), diff --git a/src/modules/masters/_shared/masters.validation.js b/src/modules/masters/_shared/masters.validation.js new file mode 100644 index 0000000..8d00752 --- /dev/null +++ b/src/modules/masters/_shared/masters.validation.js @@ -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, +}; diff --git a/src/modules/masters/asset-categories/asset-categories.validation.js b/src/modules/masters/asset-categories/asset-categories.validation.js index 0f59161..7e34fe6 100644 --- a/src/modules/masters/asset-categories/asset-categories.validation.js +++ b/src/modules/masters/asset-categories/asset-categories.validation.js @@ -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 }; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.controller.js b/src/modules/masters/asset-subcategories/asset-subcategories.controller.js new file mode 100644 index 0000000..6308aab --- /dev/null +++ b/src/modules/masters/asset-subcategories/asset-subcategories.controller.js @@ -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 }; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.routes.js b/src/modules/masters/asset-subcategories/asset-subcategories.routes.js new file mode 100644 index 0000000..7c5dad1 --- /dev/null +++ b/src/modules/masters/asset-subcategories/asset-subcategories.routes.js @@ -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; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.service.js b/src/modules/masters/asset-subcategories/asset-subcategories.service.js new file mode 100644 index 0000000..d02d9c8 --- /dev/null +++ b/src/modules/masters/asset-subcategories/asset-subcategories.service.js @@ -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, +}; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.validation.js b/src/modules/masters/asset-subcategories/asset-subcategories.validation.js new file mode 100644 index 0000000..480dc26 --- /dev/null +++ b/src/modules/masters/asset-subcategories/asset-subcategories.validation.js @@ -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, +}; diff --git a/src/modules/masters/brands/brands.validation.js b/src/modules/masters/brands/brands.validation.js index fd08da6..2b102b4 100644 --- a/src/modules/masters/brands/brands.validation.js +++ b/src/modules/masters/brands/brands.validation.js @@ -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 }; diff --git a/src/modules/masters/delivery-terms/delivery-terms.validation.js b/src/modules/masters/delivery-terms/delivery-terms.validation.js index f5b8222..f680f7a 100644 --- a/src/modules/masters/delivery-terms/delivery-terms.validation.js +++ b/src/modules/masters/delivery-terms/delivery-terms.validation.js @@ -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 }; diff --git a/src/modules/masters/departments/departments.validation.js b/src/modules/masters/departments/departments.validation.js index 517a4d1..0829b11 100644 --- a/src/modules/masters/departments/departments.validation.js +++ b/src/modules/masters/departments/departments.validation.js @@ -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 }; diff --git a/src/modules/masters/designations/designations.validation.js b/src/modules/masters/designations/designations.validation.js index 517a4d1..0829b11 100644 --- a/src/modules/masters/designations/designations.validation.js +++ b/src/modules/masters/designations/designations.validation.js @@ -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 }; diff --git a/src/modules/masters/document-series/document-series.validation.js b/src/modules/masters/document-series/document-series.validation.js index bfcf865..e0b9bfc 100644 --- a/src/modules/masters/document-series/document-series.validation.js +++ b/src/modules/masters/document-series/document-series.validation.js @@ -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 }; diff --git a/src/modules/masters/index.js b/src/modules/masters/index.js index d2c8910..62e0ab1 100644 --- a/src/modules/masters/index.js +++ b/src/modules/masters/index.js @@ -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')); diff --git a/src/modules/masters/item-categories/item-categories.validation.js b/src/modules/masters/item-categories/item-categories.validation.js index 2a546f6..c90ee1a 100644 --- a/src/modules/masters/item-categories/item-categories.validation.js +++ b/src/modules/masters/item-categories/item-categories.validation.js @@ -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 }; diff --git a/src/modules/masters/item-subcategories/item-subcategories.validation.js b/src/modules/masters/item-subcategories/item-subcategories.validation.js index 6eb3cdb..a71be13 100644 --- a/src/modules/masters/item-subcategories/item-subcategories.validation.js +++ b/src/modules/masters/item-subcategories/item-subcategories.validation.js @@ -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 }; diff --git a/src/modules/masters/items/items.validation.js b/src/modules/masters/items/items.validation.js index 0427146..7a7a5cf 100644 --- a/src/modules/masters/items/items.validation.js +++ b/src/modules/masters/items/items.validation.js @@ -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 }; diff --git a/src/modules/masters/locations/locations.service.js b/src/modules/masters/locations/locations.service.js index 21ee03e..4c1be1a 100644 --- a/src/modules/masters/locations/locations.service.js +++ b/src/modules/masters/locations/locations.service.js @@ -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); }; diff --git a/src/modules/masters/locations/locations.validation.js b/src/modules/masters/locations/locations.validation.js index 160d17e..bb0e113 100644 --- a/src/modules/masters/locations/locations.validation.js +++ b/src/modules/masters/locations/locations.validation.js @@ -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, }; diff --git a/src/modules/masters/payment-terms/payment-terms.validation.js b/src/modules/masters/payment-terms/payment-terms.validation.js index f0d4947..0615f07 100644 --- a/src/modules/masters/payment-terms/payment-terms.validation.js +++ b/src/modules/masters/payment-terms/payment-terms.validation.js @@ -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 }; diff --git a/src/modules/masters/plants/plants.validation.js b/src/modules/masters/plants/plants.validation.js index eb45dc7..ab851b1 100644 --- a/src/modules/masters/plants/plants.validation.js +++ b/src/modules/masters/plants/plants.validation.js @@ -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 }; diff --git a/src/modules/masters/uom/uom.validation.js b/src/modules/masters/uom/uom.validation.js index 8a04795..1e3196f 100644 --- a/src/modules/masters/uom/uom.validation.js +++ b/src/modules/masters/uom/uom.validation.js @@ -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 }; diff --git a/src/modules/masters/warehouses/warehouses.validation.js b/src/modules/masters/warehouses/warehouses.validation.js index 6f846d5..5fdf27d 100644 --- a/src/modules/masters/warehouses/warehouses.validation.js +++ b/src/modules/masters/warehouses/warehouses.validation.js @@ -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 }; diff --git a/src/modules/purchase-orders/purchase-orders.calculations.js b/src/modules/purchase-orders/purchase-orders.calculations.js index d26d7c8..86558f9 100644 --- a/src/modules/purchase-orders/purchase-orders.calculations.js +++ b/src/modules/purchase-orders/purchase-orders.calculations.js @@ -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, }; }; diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js index bae2a3b..e6c0c8e 100644 --- a/src/modules/purchase-orders/purchase-orders.service.js +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -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 diff --git a/src/modules/purchase-orders/purchase-orders.validation.js b/src/modules/purchase-orders/purchase-orders.validation.js index 2406e5e..327d70a 100644 --- a/src/modules/purchase-orders/purchase-orders.validation.js +++ b/src/modules/purchase-orders/purchase-orders.validation.js @@ -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(), diff --git a/src/modules/settings/settings.controller.js b/src/modules/settings/settings.controller.js new file mode 100644 index 0000000..471393b --- /dev/null +++ b/src/modules/settings/settings.controller.js @@ -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, +}; diff --git a/src/modules/settings/settings.routes.js b/src/modules/settings/settings.routes.js new file mode 100644 index 0000000..bce685b --- /dev/null +++ b/src/modules/settings/settings.routes.js @@ -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; diff --git a/src/modules/settings/settings.service.js b/src/modules/settings/settings.service.js new file mode 100644 index 0000000..6bbc86d --- /dev/null +++ b/src/modules/settings/settings.service.js @@ -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, +}; diff --git a/src/modules/settings/settings.validation.js b/src/modules/settings/settings.validation.js new file mode 100644 index 0000000..7eeef15 --- /dev/null +++ b/src/modules/settings/settings.validation.js @@ -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, +}; diff --git a/src/modules/vendors/vendors.constants.js b/src/modules/vendors/vendors.constants.js new file mode 100644 index 0000000..d7fae07 --- /dev/null +++ b/src/modules/vendors/vendors.constants.js @@ -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, +}; diff --git a/src/modules/vendors/vendors.controller.js b/src/modules/vendors/vendors.controller.js index faabb5c..e51659d 100644 --- a/src/modules/vendors/vendors.controller.js +++ b/src/modules/vendors/vendors.controller.js @@ -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, }; diff --git a/src/modules/vendors/vendors.routes.js b/src/modules/vendors/vendors.routes.js index 4172918..b694eb1 100644 --- a/src/modules/vendors/vendors.routes.js +++ b/src/modules/vendors/vendors.routes.js @@ -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( diff --git a/src/modules/vendors/vendors.service.js b/src/modules/vendors/vendors.service.js index d13db1d..31aff54 100644 --- a/src/modules/vendors/vendors.service.js +++ b/src/modules/vendors/vendors.service.js @@ -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, }; diff --git a/src/modules/vendors/vendors.validation.js b/src/modules/vendors/vendors.validation.js index 22d001d..a936a43 100644 --- a/src/modules/vendors/vendors.validation.js +++ b/src/modules/vendors/vendors.validation.js @@ -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, }; diff --git a/src/routes/v1/index.js b/src/routes/v1/index.js index 6288159..9258b64 100644 --- a/src/routes/v1/index.js +++ b/src/routes/v1/index.js @@ -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) => { diff --git a/src/server.js b/src/server.js index 160edb2..fb8b934 100644 --- a/src/server.js +++ b/src/server.js @@ -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}]`); });