From f04b917b76291471e67e270b78fc16abc3268b15 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Tue, 23 Jun 2026 14:34:36 +0530 Subject: [PATCH] GWM : plan and warehouse merged as locations --- BACKEND_TASKS.md | 5 +- prisma/schema.prisma | 108 +++---- scripts/patch-assets-views.sql | 12 +- scripts/patch-locations-merge.sql | 148 ++++++++++ scripts/patch-locations-views.sql | 33 +++ src/modules/assets/assets.helpers.js | 3 +- src/modules/assets/assets.service.js | 57 ++-- src/modules/auth/auth.service.js | 4 +- src/modules/grn/grn.service.js | 18 +- src/modules/masters/index.js | 1 + .../masters/locations/locations.controller.js | 30 ++ .../masters/locations/locations.routes.js | 21 ++ .../masters/locations/locations.service.js | 274 ++++++++++++++++++ .../masters/locations/locations.validation.js | 94 ++++++ src/modules/masters/plants/plants.service.js | 89 +----- .../masters/warehouses/warehouses.service.js | 65 +---- .../purchase-orders.service.js | 23 +- src/modules/users/users.service.js | 12 +- src/utils/locations.js | 96 ++++++ 19 files changed, 817 insertions(+), 276 deletions(-) create mode 100644 scripts/patch-locations-merge.sql create mode 100644 scripts/patch-locations-views.sql create mode 100644 src/modules/masters/locations/locations.controller.js create mode 100644 src/modules/masters/locations/locations.routes.js create mode 100644 src/modules/masters/locations/locations.service.js create mode 100644 src/modules/masters/locations/locations.validation.js create mode 100644 src/utils/locations.js diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index 64d583f..8b74779 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -171,8 +171,9 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | [x] | Asset Categories | `/masters/asset-categories` | | [x] | Departments | `/masters/departments` | | [x] | Designations | `/masters/designations` | -| [x] | Plants | `/masters/plants` | -| [x] | Warehouses | `/masters/warehouses` | +| [x] | Locations | `/masters/locations` | Unified plants + warehouses (`type`: `plant` \| `warehouse`) | +| [x] | Plants | `/masters/plants` | Alias — plant locations only | +| [x] | Warehouses | `/masters/warehouses` | Alias — warehouse locations only | | [x] | Document Series | `/masters/document-series` | **Masters total:** 14 modules × 5 endpoints = **70 APIs** [x] diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c9201f8..01a94da 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -171,13 +171,13 @@ model asset_transfers { created_at DateTime @default(now()) @db.Timestamptz(6) assets assets @relation(fields: [asset_id], references: [id], onDelete: Cascade, onUpdate: NoAction) departments_asset_transfers_from_department_idTodepartments departments? @relation("asset_transfers_from_department_idTodepartments", fields: [from_department_id], references: [id], onUpdate: NoAction) - plants_asset_transfers_from_plant_idToplants plants? @relation("asset_transfers_from_plant_idToplants", fields: [from_plant_id], references: [id], onUpdate: NoAction) + from_plant locations? @relation("TransferFromPlant", fields: [from_plant_id], references: [id], onUpdate: NoAction) users_asset_transfers_from_user_idTousers users? @relation("asset_transfers_from_user_idTousers", fields: [from_user_id], references: [id], onUpdate: NoAction) - warehouses_asset_transfers_from_warehouse_idTowarehouses warehouses? @relation("asset_transfers_from_warehouse_idTowarehouses", fields: [from_warehouse_id], references: [id], onUpdate: NoAction) + from_warehouse locations? @relation("TransferFromWarehouse", fields: [from_warehouse_id], references: [id], onUpdate: NoAction) departments_asset_transfers_to_department_idTodepartments departments? @relation("asset_transfers_to_department_idTodepartments", fields: [to_department_id], references: [id], onUpdate: NoAction) - plants_asset_transfers_to_plant_idToplants plants? @relation("asset_transfers_to_plant_idToplants", fields: [to_plant_id], references: [id], onUpdate: NoAction) + to_plant locations? @relation("TransferToPlant", fields: [to_plant_id], references: [id], onUpdate: NoAction) users_asset_transfers_to_user_idTousers users? @relation("asset_transfers_to_user_idTousers", fields: [to_user_id], references: [id], onUpdate: NoAction) - warehouses_asset_transfers_to_warehouse_idTowarehouses warehouses? @relation("asset_transfers_to_warehouse_idTowarehouses", fields: [to_warehouse_id], references: [id], onUpdate: NoAction) + to_warehouse locations? @relation("TransferToWarehouse", fields: [to_warehouse_id], references: [id], onUpdate: NoAction) users_asset_transfers_transferred_byTousers users? @relation("asset_transfers_transferred_byTousers", fields: [transferred_by], references: [id], onUpdate: NoAction) @@index([asset_id], map: "idx_asset_transfers_asset_id") @@ -232,11 +232,11 @@ model assets { departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction) grn grn? @relation(fields: [grn_id], references: [id], onUpdate: NoAction) grn_items grn_items? @relation(fields: [grn_item_id], references: [id], onUpdate: NoAction) - plants plants @relation(fields: [plant_id], references: [id], onUpdate: NoAction) + plant locations @relation("AssetPlantLocation", fields: [plant_id], references: [id], onUpdate: NoAction) purchase_orders purchase_orders? @relation(fields: [po_id], references: [id], onUpdate: NoAction) users_assets_updated_byTousers users? @relation("assets_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) vendors_assets_vendor_idTovendors vendors? @relation("assets_vendor_idTovendors", fields: [vendor_id], references: [id], onUpdate: NoAction) - warehouses warehouses? @relation(fields: [warehouse_id], references: [id], onUpdate: NoAction) + warehouse locations? @relation("AssetWarehouseLocation", fields: [warehouse_id], references: [id], onUpdate: NoAction) @@index([asset_category_id], map: "idx_assets_category_id") @@index([department_id], map: "idx_assets_dept_id") @@ -375,7 +375,7 @@ model grn { users_grn_received_byTousers users? @relation("grn_received_byTousers", fields: [received_by], references: [id], onUpdate: NoAction) users_grn_updated_byTousers users? @relation("grn_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) vendors vendors @relation(fields: [vendor_id], references: [id], onUpdate: NoAction) - warehouses warehouses @relation(fields: [warehouse_id], references: [id], onUpdate: NoAction) + warehouse locations @relation("GRNWarehouseLocation", fields: [warehouse_id], references: [id], onUpdate: NoAction) grn_attachments grn_attachments[] grn_items grn_items[] @@ -576,30 +576,42 @@ model permissions { @@unique([module_id, action]) } -model plants { - id BigInt @id @default(autoincrement()) - code String @unique @db.VarChar(20) - name String @db.VarChar(150) - gstin String? @db.VarChar(15) - address String? - city String? @db.VarChar(100) - state String? @db.VarChar(100) - pincode String? @db.VarChar(10) - phone String? @db.VarChar(15) - 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) - asset_transfers_asset_transfers_from_plant_idToplants asset_transfers[] @relation("asset_transfers_from_plant_idToplants") - asset_transfers_asset_transfers_to_plant_idToplants asset_transfers[] @relation("asset_transfers_to_plant_idToplants") - assets assets[] - users_plants_created_byTousers users? @relation("plants_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction, map: "fk_plants_created_by") - users_plants_updated_byTousers users? @relation("plants_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction, map: "fk_plants_updated_by") - purchase_orders purchase_orders[] - users_users_plant_idToplants users[] @relation("users_plant_idToplants") - warehouses warehouses[] +model locations { + id BigInt @id @default(autoincrement()) + type String @db.VarChar(20) + code String @db.VarChar(30) + name String @db.VarChar(150) + parent_id BigInt? + gstin String? @db.VarChar(15) + address String? + city String? @db.VarChar(100) + state String? @db.VarChar(100) + pincode String? @db.VarChar(10) + phone String? @db.VarChar(15) + location String? @db.VarChar(200) + 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) + transfers_from_plant asset_transfers[] @relation("TransferFromPlant") + transfers_to_plant asset_transfers[] @relation("TransferToPlant") + transfers_from_wh asset_transfers[] @relation("TransferFromWarehouse") + transfers_to_wh asset_transfers[] @relation("TransferToWarehouse") + assets_plant assets[] @relation("AssetPlantLocation") + assets_warehouse assets[] @relation("AssetWarehouseLocation") + grn_warehouse grn[] @relation("GRNWarehouseLocation") + parent locations? @relation("LocationHierarchy", fields: [parent_id], references: [id], onUpdate: NoAction) + children locations[] @relation("LocationHierarchy") + po_plant purchase_orders[] @relation("POPlantLocation") + po_warehouse purchase_orders[] @relation("POWarehouseLocation") + users_plant users[] @relation("UserPlantLocation") + users_created users? @relation("locations_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction, map: "fk_locations_created_by") + users_updated users? @relation("locations_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction, map: "fk_locations_updated_by") + + @@index([type], map: "idx_locations_type") + @@index([parent_id], map: "idx_locations_parent_id") } /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. @@ -706,10 +718,10 @@ model purchase_orders { purchase_orders purchase_orders? @relation("purchase_ordersTopurchase_orders", fields: [parent_po_id], references: [id], onUpdate: NoAction) other_purchase_orders purchase_orders[] @relation("purchase_ordersTopurchase_orders") payment_terms payment_terms? @relation(fields: [payment_term_id], references: [id], onUpdate: NoAction) - plants plants @relation(fields: [plant_id], references: [id], onUpdate: NoAction) + plant locations @relation("POPlantLocation", fields: [plant_id], references: [id], onUpdate: NoAction) users_purchase_orders_updated_byTousers users? @relation("purchase_orders_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) vendors vendors @relation(fields: [vendor_id], references: [id], onUpdate: NoAction) - warehouses warehouses? @relation(fields: [warehouse_id], references: [id], onDelete: Restrict, onUpdate: NoAction) + warehouse locations? @relation("POWarehouseLocation", fields: [warehouse_id], references: [id], onDelete: Restrict, onUpdate: NoAction) @@index([created_at], map: "idx_po_created_at") @@index([po_date], map: "idx_po_po_date") @@ -846,8 +858,8 @@ model users { password_reset_tokens password_reset_tokens[] payment_terms_payment_terms_created_byTousers payment_terms[] @relation("payment_terms_created_byTousers") payment_terms_payment_terms_updated_byTousers payment_terms[] @relation("payment_terms_updated_byTousers") - plants_plants_created_byTousers plants[] @relation("plants_created_byTousers") - plants_plants_updated_byTousers plants[] @relation("plants_updated_byTousers") + locations_locations_created_byTousers locations[] @relation("locations_created_byTousers") + locations_locations_updated_byTousers locations[] @relation("locations_updated_byTousers") po_approvals po_approvals[] po_attachments po_attachments[] purchase_orders_purchase_orders_created_byTousers purchase_orders[] @relation("purchase_orders_created_byTousers") @@ -862,7 +874,7 @@ model users { other_users_users_created_byTousers users[] @relation("users_created_byTousers") departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction) designations designations? @relation(fields: [designation_id], references: [id], onUpdate: NoAction) - plants_users_plant_idToplants plants? @relation("users_plant_idToplants", fields: [plant_id], references: [id], onUpdate: NoAction) + plant_location locations? @relation("UserPlantLocation", fields: [plant_id], references: [id], onUpdate: NoAction) users_users_reporting_toTousers users? @relation("users_reporting_toTousers", fields: [reporting_to], references: [id], onUpdate: NoAction) other_users_users_reporting_toTousers users[] @relation("users_reporting_toTousers") users_users_updated_byTousers users? @relation("users_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) @@ -877,8 +889,6 @@ model users { vendor_item_mapping_vendor_item_mapping_updated_byTousers vendor_item_mapping[] @relation("vendor_item_mapping_updated_byTousers") vendors_vendors_created_byTousers vendors[] @relation("vendors_created_byTousers") vendors_vendors_updated_byTousers vendors[] @relation("vendors_updated_byTousers") - warehouses_warehouses_created_byTousers warehouses[] @relation("warehouses_created_byTousers") - warehouses_warehouses_updated_byTousers warehouses[] @relation("warehouses_updated_byTousers") } /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. @@ -995,25 +1005,3 @@ model vendors { @@index([vendor_name(ops: raw("gin_trgm_ops"))], map: "idx_vendors_name_trgm", type: Gin) } - -model warehouses { - id BigInt @id @default(autoincrement()) - code String @unique @db.VarChar(30) - name String @db.VarChar(150) - plant_id BigInt - location String? @db.VarChar(200) - 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) - asset_transfers_asset_transfers_from_warehouse_idTowarehouses asset_transfers[] @relation("asset_transfers_from_warehouse_idTowarehouses") - asset_transfers_asset_transfers_to_warehouse_idTowarehouses asset_transfers[] @relation("asset_transfers_to_warehouse_idTowarehouses") - assets assets[] - grn grn[] - purchase_orders purchase_orders[] - users_warehouses_created_byTousers users? @relation("warehouses_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) - plants plants @relation(fields: [plant_id], references: [id], onUpdate: NoAction) - users_warehouses_updated_byTousers users? @relation("warehouses_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) -} diff --git a/scripts/patch-assets-views.sql b/scripts/patch-assets-views.sql index fec2123..013bfb2 100644 --- a/scripts/patch-assets-views.sql +++ b/scripts/patch-assets-views.sql @@ -27,7 +27,7 @@ CREATE VIEW v_asset_expiry_alerts AS FROM asset_amc_contracts amc JOIN assets a ON a.id = amc.asset_id JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id - JOIN plants p ON p.id = a.plant_id + JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id LEFT JOIN vendors v ON v.id = amc.vendor_id WHERE amc.is_active = TRUE AND amc.deleted_at IS NULL AND a.deleted_at IS NULL @@ -48,7 +48,7 @@ UNION ALL FROM asset_insurance_policies ins JOIN assets a ON a.id = ins.asset_id JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id - JOIN plants p ON p.id = a.plant_id + JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id WHERE ins.is_active = TRUE AND ins.deleted_at IS NULL AND a.deleted_at IS NULL AND (ins.policy_end_date - CURRENT_DATE) <= 90 @@ -67,7 +67,7 @@ UNION ALL END FROM assets a JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id - JOIN plants p ON p.id = a.plant_id + JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id WHERE a.warranty_expiry_date IS NOT NULL AND a.deleted_at IS NULL AND (a.warranty_expiry_date - CURRENT_DATE) <= 90; @@ -96,7 +96,7 @@ SELECT amc.end_date AS amc_end_date FROM assets a JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id -JOIN plants p ON p.id = a.plant_id +JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id JOIN LATERAL ( SELECT * FROM asset_service_visits @@ -145,9 +145,9 @@ SELECT a.created_at FROM assets a JOIN asset_categories ac ON ac.id = a.asset_category_id -JOIN plants p ON p.id = a.plant_id +JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id -LEFT JOIN warehouses w ON w.id = a.warehouse_id +LEFT JOIN locations w ON w.id = a.warehouse_id AND w.type = 'warehouse' LEFT JOIN users u ON u.id = a.assigned_to_user_id LEFT JOIN vendors v ON v.id = a.vendor_id LEFT JOIN asset_amc_contracts amc diff --git a/scripts/patch-locations-merge.sql b/scripts/patch-locations-merge.sql new file mode 100644 index 0000000..0acdcfe --- /dev/null +++ b/scripts/patch-locations-merge.sql @@ -0,0 +1,148 @@ +-- Merge plants + warehouses into locations (type: plant | warehouse) + +BEGIN; + +CREATE TABLE IF NOT EXISTS locations ( + id BIGSERIAL PRIMARY KEY, + type VARCHAR(20) NOT NULL CHECK (type IN ('plant', 'warehouse')), + code VARCHAR(30) NOT NULL, + name VARCHAR(150) NOT NULL, + parent_id BIGINT REFERENCES locations(id), + gstin VARCHAR(15), + address TEXT, + city VARCHAR(100), + state VARCHAR(100), + pincode VARCHAR(10), + phone VARCHAR(15), + location VARCHAR(200), + 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 UNIQUE INDEX IF NOT EXISTS idx_locations_code_active + ON locations (code) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_locations_type ON locations (type); +CREATE INDEX IF NOT EXISTS idx_locations_parent_id ON locations (parent_id); + +-- Migrate plants (preserve ids) when legacy table exists +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'plants' + ) THEN + INSERT INTO locations ( + id, type, code, name, gstin, address, city, state, pincode, phone, + is_active, created_by, updated_by, created_at, updated_at, deleted_at + ) + OVERRIDING SYSTEM VALUE + SELECT + id, 'plant', code, name, gstin, address, city, state, pincode, phone, + is_active, created_by, updated_by, created_at, updated_at, deleted_at + FROM plants + ON CONFLICT (id) DO NOTHING; + END IF; +END $$; + +-- Migrate warehouses (preserve ids; plant_id -> parent_id) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'warehouses' + ) THEN + INSERT INTO locations ( + id, type, code, name, parent_id, location, + is_active, created_by, updated_by, created_at, updated_at, deleted_at + ) + OVERRIDING SYSTEM VALUE + SELECT + id, 'warehouse', code, name, plant_id, location, + is_active, created_by, updated_by, created_at, updated_at, deleted_at + FROM warehouses + ON CONFLICT (id) DO NOTHING; + END IF; +END $$; + +SELECT setval( + pg_get_serial_sequence('locations', 'id'), + GREATEST(COALESCE((SELECT MAX(id) FROM locations), 1), 1) +); + +-- Drop FKs referencing plants / warehouses +DO $$ +DECLARE r RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'plants') THEN + FOR r IN + SELECT c.conname, c.conrelid::regclass AS tbl + FROM pg_constraint c + WHERE c.contype = 'f' AND c.confrelid = 'public.plants'::regclass + LOOP + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT IF EXISTS %I', r.tbl, r.conname); + END LOOP; + END IF; + + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'warehouses') THEN + FOR r IN + SELECT c.conname, c.conrelid::regclass AS tbl + FROM pg_constraint c + WHERE c.contype = 'f' AND c.confrelid = 'public.warehouses'::regclass + LOOP + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT IF EXISTS %I', r.tbl, r.conname); + END LOOP; + END IF; +END $$; + +-- Point existing FK columns to locations (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_plant_id_fkey') THEN + ALTER TABLE users ADD CONSTRAINT users_plant_id_fkey FOREIGN KEY (plant_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'assets_plant_id_fkey') THEN + ALTER TABLE assets ADD CONSTRAINT assets_plant_id_fkey FOREIGN KEY (plant_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'assets_warehouse_id_fkey') THEN + ALTER TABLE assets ADD CONSTRAINT assets_warehouse_id_fkey FOREIGN KEY (warehouse_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'purchase_orders_plant_id_fkey') THEN + ALTER TABLE purchase_orders ADD CONSTRAINT purchase_orders_plant_id_fkey FOREIGN KEY (plant_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'purchase_orders_warehouse_id_fkey') THEN + ALTER TABLE purchase_orders ADD CONSTRAINT purchase_orders_warehouse_id_fkey FOREIGN KEY (warehouse_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'grn_warehouse_id_fkey') THEN + ALTER TABLE grn ADD CONSTRAINT grn_warehouse_id_fkey FOREIGN KEY (warehouse_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'asset_transfers_from_plant_id_fkey') THEN + ALTER TABLE asset_transfers ADD CONSTRAINT asset_transfers_from_plant_id_fkey FOREIGN KEY (from_plant_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'asset_transfers_to_plant_id_fkey') THEN + ALTER TABLE asset_transfers ADD CONSTRAINT asset_transfers_to_plant_id_fkey FOREIGN KEY (to_plant_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'asset_transfers_from_warehouse_id_fkey') THEN + ALTER TABLE asset_transfers ADD CONSTRAINT asset_transfers_from_warehouse_id_fkey FOREIGN KEY (from_warehouse_id) REFERENCES locations(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'asset_transfers_to_warehouse_id_fkey') THEN + ALTER TABLE asset_transfers ADD CONSTRAINT asset_transfers_to_warehouse_id_fkey FOREIGN KEY (to_warehouse_id) REFERENCES locations(id); + END IF; +END $$; + +-- Drop views that depend on plants/warehouses, then drop legacy tables +DROP VIEW IF EXISTS v_purchase_orders CASCADE; +DROP VIEW IF EXISTS v_grn CASCADE; +DROP VIEW IF EXISTS v_assets CASCADE; +DROP VIEW IF EXISTS v_asset_expiry_alerts CASCADE; +DROP VIEW IF EXISTS v_asset_next_service CASCADE; + +DROP TABLE IF EXISTS warehouses; +DROP TABLE IF EXISTS plants; + +COMMIT; diff --git a/scripts/patch-locations-views.sql b/scripts/patch-locations-views.sql new file mode 100644 index 0000000..b9e0c0e --- /dev/null +++ b/scripts/patch-locations-views.sql @@ -0,0 +1,33 @@ +-- Recreate PO/GRN views after locations merge + +DROP VIEW IF EXISTS v_purchase_orders CASCADE; +DROP VIEW IF EXISTS v_grn CASCADE; + +CREATE VIEW v_purchase_orders AS +SELECT + po.*, + pl.code AS plant_code, + pl.name AS plant_name, + wh.code AS warehouse_code, + wh.name AS warehouse_name, + v.vendor_code, + v.vendor_name +FROM purchase_orders po +JOIN locations pl ON pl.id = po.plant_id AND pl.type = 'plant' +LEFT JOIN locations wh ON wh.id = po.warehouse_id AND wh.type = 'warehouse' +JOIN vendors v ON v.id = po.vendor_id +WHERE po.deleted_at IS NULL; + +CREATE VIEW v_grn AS +SELECT + g.*, + po.po_number, + v.vendor_code, + v.vendor_name, + wh.code AS warehouse_code, + wh.name AS warehouse_name +FROM grn g +JOIN purchase_orders po ON po.id = g.po_id +JOIN vendors v ON v.id = g.vendor_id +JOIN locations wh ON wh.id = g.warehouse_id AND wh.type = 'warehouse' +WHERE g.deleted_at IS NULL; diff --git a/src/modules/assets/assets.helpers.js b/src/modules/assets/assets.helpers.js index 7ae8c86..58b2360 100644 --- a/src/modules/assets/assets.helpers.js +++ b/src/modules/assets/assets.helpers.js @@ -3,8 +3,7 @@ const ApiError = require('../../utils/ApiError'); const SOFT_DELETE_TABLES = new Set([ 'vendors', - 'plants', - 'warehouses', + 'locations', 'departments', 'users', 'purchase_orders', diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index d0ed14b..d9e79e8 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -4,12 +4,12 @@ const auditLog = require('../../utils/auditLog'); const { getPagination } = require('../../utils/pagination'); const { nextDocumentNumber } = require('../../utils/generateCode'); const { DISPOSAL_STATUSES } = require('./assets.constants'); +const { assertPlant, assertWarehouse } = require('../../utils/locations'); const repository = require('./assets.repository'); const SOFT_DELETE_TABLES = new Set([ 'vendors', - 'plants', - 'warehouses', + 'locations', 'departments', 'users', 'purchase_orders', @@ -20,9 +20,9 @@ const SOFT_DELETE_TABLES = new Set([ const assetInclude = { asset_categories: { select: { id: true, code: true, name: true, code_prefix: true } }, - plants: { select: { id: true, code: true, name: true } }, + plant: { select: { id: true, code: true, name: true } }, departments: { select: { id: true, name: true } }, - warehouses: { select: { id: true, code: true, name: true } }, + warehouse: { select: { id: true, code: true, name: true } }, users_assets_assigned_to_user_idTousers: { select: { id: true, full_name: true, employee_code: true }, }, @@ -38,18 +38,14 @@ const assetDetailInclude = { }; const transferInclude = { - plants_asset_transfers_from_plant_idToplants: { select: { id: true, code: true, name: true } }, - plants_asset_transfers_to_plant_idToplants: { select: { id: true, code: true, name: true } }, + from_plant: { select: { id: true, code: true, name: true } }, + to_plant: { select: { id: true, code: true, name: true } }, departments_asset_transfers_from_department_idTodepartments: { select: { id: true, name: true } }, departments_asset_transfers_to_department_idTodepartments: { select: { id: true, name: true } }, users_asset_transfers_from_user_idTousers: { select: { id: true, full_name: true } }, users_asset_transfers_to_user_idTousers: { select: { id: true, full_name: true } }, - warehouses_asset_transfers_from_warehouse_idTowarehouses: { - select: { id: true, code: true, name: true }, - }, - warehouses_asset_transfers_to_warehouse_idTowarehouses: { - select: { id: true, code: true, name: true }, - }, + from_warehouse: { select: { id: true, code: true, name: true } }, + to_warehouse: { select: { id: true, code: true, name: true } }, users_asset_transfers_transferred_byTousers: { select: { id: true, full_name: true } }, }; @@ -65,9 +61,9 @@ const sanitizeAsset = (asset) => { if (!asset) return null; const { asset_categories, - plants, + plant, departments, - warehouses, + warehouse, users_assets_assigned_to_user_idTousers, vendors_assets_vendor_idTovendors, purchase_orders, @@ -80,9 +76,9 @@ const sanitizeAsset = (asset) => { return { ...rest, asset_category: asset_categories || null, - plant: plants || null, + plant: plant || null, department: departments || null, - warehouse: warehouses || null, + warehouse: warehouse || null, assigned_to_user: users_assets_assigned_to_user_idTousers || null, vendor: vendors_assets_vendor_idTovendors || null, purchase_order: purchase_orders || null, @@ -90,9 +86,7 @@ const sanitizeAsset = (asset) => { created_by_user: users_assets_created_byTousers || null, updated_by_user: users_assets_updated_byTousers || null, asset_categories: undefined, - plants: undefined, departments: undefined, - warehouses: undefined, users_assets_assigned_to_user_idTousers: undefined, vendors_assets_vendor_idTovendors: undefined, purchase_orders: undefined, @@ -104,28 +98,28 @@ const sanitizeAsset = (asset) => { const sanitizeTransfer = (row) => { if (!row) return null; const { - plants_asset_transfers_from_plant_idToplants, - plants_asset_transfers_to_plant_idToplants, + from_plant, + to_plant, departments_asset_transfers_from_department_idTodepartments, departments_asset_transfers_to_department_idTodepartments, users_asset_transfers_from_user_idTousers, users_asset_transfers_to_user_idTousers, - warehouses_asset_transfers_from_warehouse_idTowarehouses, - warehouses_asset_transfers_to_warehouse_idTowarehouses, + from_warehouse, + to_warehouse, users_asset_transfers_transferred_byTousers, ...rest } = row; return { ...rest, - from_plant: plants_asset_transfers_from_plant_idToplants || null, - to_plant: plants_asset_transfers_to_plant_idToplants || null, + from_plant: from_plant || null, + to_plant: to_plant || null, from_department: departments_asset_transfers_from_department_idTodepartments || null, to_department: departments_asset_transfers_to_department_idTodepartments || null, from_user: users_asset_transfers_from_user_idTousers || null, to_user: users_asset_transfers_to_user_idTousers || null, - from_warehouse: warehouses_asset_transfers_from_warehouse_idTowarehouses || null, - to_warehouse: warehouses_asset_transfers_to_warehouse_idTowarehouses || null, + from_warehouse: from_warehouse || null, + to_warehouse: to_warehouse || null, transferred_by_user: users_asset_transfers_transferred_byTousers || null, }; }; @@ -155,11 +149,10 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { payload.asset_category_id, 'asset_category_id' ); - await assertReference('plants', payload.plant_id, 'plant_id'); + await assertPlant(payload.plant_id, 'plant_id'); if (payload.department_id) await assertReference('departments', payload.department_id, 'department_id'); - if (payload.warehouse_id) - await assertReference('warehouses', payload.warehouse_id, 'warehouse_id'); + if (payload.warehouse_id) await assertWarehouse(payload.warehouse_id, 'warehouse_id'); if (payload.assigned_to_user_id) await assertReference('users', payload.assigned_to_user_id, 'assigned_to_user_id'); if (payload.vendor_id) await assertReference('vendors', payload.vendor_id, 'vendor_id'); @@ -402,14 +395,12 @@ const transferAsset = async (id, payload, userId, requestId) => { throw new ApiError(409, 'Cannot transfer disposed or scrapped assets'); } - if (payload.to_plant_id) await assertReference('plants', payload.to_plant_id, 'to_plant_id'); + if (payload.to_plant_id) await assertPlant(payload.to_plant_id, 'to_plant_id'); if (payload.to_department_id) { await assertReference('departments', payload.to_department_id, 'to_department_id'); } if (payload.to_user_id) await assertReference('users', payload.to_user_id, 'to_user_id'); - if (payload.to_warehouse_id) { - await assertReference('warehouses', payload.to_warehouse_id, 'to_warehouse_id'); - } + if (payload.to_warehouse_id) await assertWarehouse(payload.to_warehouse_id, 'to_warehouse_id'); const transfer = { transfer_date: toDateOnly(payload.transfer_date), diff --git a/src/modules/auth/auth.service.js b/src/modules/auth/auth.service.js index 0227029..cab4a3a 100644 --- a/src/modules/auth/auth.service.js +++ b/src/modules/auth/auth.service.js @@ -110,7 +110,7 @@ const getMe = async (userId) => { }, departments: { select: { id: true, name: true } }, designations: { select: { id: true, name: true } }, - plants_users_plant_idToplants: { select: { id: true, code: true, name: true } }, + plant_location: { select: { id: true, code: true, name: true } }, }, }); @@ -138,7 +138,7 @@ const getMe = async (userId) => { role: roles[0] || null, department: user.departments, designation: user.designations, - plant: user.plants_users_plant_idToplants, + plant: user.plant_location, permissions, }; }; diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js index 5b57f6a..015882d 100644 --- a/src/modules/grn/grn.service.js +++ b/src/modules/grn/grn.service.js @@ -5,11 +5,12 @@ const { getPagination } = require('../../utils/pagination'); const { nextDocumentNumber } = require('../../utils/generateCode'); const { buildSimplePdf } = require('../../utils/simplePdf'); const repository = require('./grn.repository'); +const { assertWarehouse } = require('../../utils/locations'); const grnListInclude = { purchase_orders: { select: { id: true, po_number: true, status: true } }, vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, - warehouses: { select: { id: true, code: true, name: true } }, + warehouse: { select: { id: true, code: true, name: true } }, users_grn_received_byTousers: { select: { id: true, full_name: true } }, users_grn_created_byTousers: { select: { id: true, full_name: true } }, }; @@ -46,7 +47,7 @@ const sanitizeGrn = (row) => { const { purchase_orders, vendors, - warehouses, + warehouse, users_grn_received_byTousers, users_grn_created_byTousers, users_grn_quality_checked_byTousers, @@ -60,7 +61,7 @@ const sanitizeGrn = (row) => { ...rest, purchase_order: purchase_orders || null, vendor: vendors || null, - warehouse: warehouses || null, + warehouse: warehouse || null, received_by_user: users_grn_received_byTousers || null, created_by_user: users_grn_created_byTousers || null, quality_checked_by_user: users_grn_quality_checked_byTousers || null, @@ -78,6 +79,7 @@ const sanitizeGrn = (row) => { purchase_orders: undefined, vendors: undefined, warehouses: undefined, + warehouse: undefined, users_grn_received_byTousers: undefined, users_grn_created_byTousers: undefined, users_grn_quality_checked_byTousers: undefined, @@ -242,10 +244,7 @@ const buildHeaderData = (payload, po, userId) => ({ const createGrn = async (payload, userId, requestId) => { const po = await getReceivablePoOrThrow(payload.po_id); - const warehouse = await prisma.warehouses.findFirst({ - where: { id: BigInt(payload.warehouse_id), deleted_at: null, is_active: true }, - }); - if (!warehouse) throw new ApiError(422, 'Invalid warehouse_id'); + await assertWarehouse(payload.warehouse_id, 'warehouse_id'); const { builtItems, assetPlans } = await validateAndBuildItems(po, payload.items); const header = buildHeaderData(payload, po, userId); @@ -316,10 +315,7 @@ const updateGrn = async (id, payload, userId, requestId) => { } if (payload.warehouse_id) { - const warehouse = await prisma.warehouses.findFirst({ - where: { id: BigInt(payload.warehouse_id), deleted_at: null, is_active: true }, - }); - if (!warehouse) throw new ApiError(422, 'Invalid warehouse_id'); + await assertWarehouse(payload.warehouse_id, 'warehouse_id'); } const data = { diff --git a/src/modules/masters/index.js b/src/modules/masters/index.js index 1ea39d3..d2c8910 100644 --- a/src/modules/masters/index.js +++ b/src/modules/masters/index.js @@ -14,6 +14,7 @@ router.use('/designations', require('./designations/designations.routes')); router.use('/document-series', require('./document-series/document-series.routes')); router.use('/item-subcategories', require('./item-subcategories/item-subcategories.routes')); router.use('/items', require('./items/items.routes')); +router.use('/locations', require('./locations/locations.routes')); router.use('/warehouses', require('./warehouses/warehouses.routes')); router.use('/plants', require('./plants/plants.routes')); diff --git a/src/modules/masters/locations/locations.controller.js b/src/modules/masters/locations/locations.controller.js new file mode 100644 index 0000000..3d9ca22 --- /dev/null +++ b/src/modules/masters/locations/locations.controller.js @@ -0,0 +1,30 @@ +const asyncHandler = require('../../../utils/asyncHandler'); +const ApiResponse = require('../../../utils/ApiResponse'); +const service = require('./locations.service'); + +const create = asyncHandler(async (req, res) => { + const data = await service.createLocation(req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'Location created successfully')); +}); + +const list = asyncHandler(async (req, res) => { + const result = await service.listLocations(req.query); + res.json(new ApiResponse(200, result.data, 'Locations list fetched', result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getLocationById(req.params.id); + res.json(new ApiResponse(200, data, 'Location fetched')); +}); + +const update = asyncHandler(async (req, res) => { + const data = await service.updateLocationById(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'Location updated successfully')); +}); + +const remove = asyncHandler(async (req, res) => { + await service.deleteLocationById(req.params.id, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'Location deleted successfully')); +}); + +module.exports = { create, list, getOne, update, remove }; diff --git a/src/modules/masters/locations/locations.routes.js b/src/modules/masters/locations/locations.routes.js new file mode 100644 index 0000000..1b95afb --- /dev/null +++ b/src/modules/masters/locations/locations.routes.js @@ -0,0 +1,21 @@ +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('./locations.controller'); +const { + createLocationSchema, + updateLocationSchema, + listQuerySchema, +} = require('./locations.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(createLocationSchema), controller.create); +router.put('/:id', authorize('MASTERS', 'edit'), validate(updateLocationSchema), controller.update); +router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove); + +module.exports = router; diff --git a/src/modules/masters/locations/locations.service.js b/src/modules/masters/locations/locations.service.js new file mode 100644 index 0000000..21ee03e --- /dev/null +++ b/src/modules/masters/locations/locations.service.js @@ -0,0 +1,274 @@ +const prisma = require('../../../config/prisma'); +const ApiError = require('../../../utils/ApiError'); +const auditLog = require('../../../utils/auditLog'); +const { getPagination } = require('../../../utils/pagination'); +const { + LOCATION_TYPES, + assertPlant, + toPlantResponse, + toWarehouseResponse, + toLocationResponse, +} = require('../../../utils/locations'); + +const TABLE_NAME = 'locations'; + +const normalizeString = (value) => { + if (value === null || value === undefined) return value; + return String(value).trim(); +}; + +const buildPlantData = (payload) => ({ + type: LOCATION_TYPES.PLANT, + code: payload.code ? String(payload.code).trim().toUpperCase() : undefined, + name: normalizeString(payload.name), + gstin: normalizeString(payload.gstin), + address: normalizeString(payload.address), + city: normalizeString(payload.city), + state: normalizeString(payload.state), + pincode: normalizeString(payload.pincode), + phone: normalizeString(payload.phone), + is_active: payload.is_active, + parent_id: null, + location: null, +}); + +const buildWarehouseData = (payload) => ({ + type: LOCATION_TYPES.WAREHOUSE, + code: payload.code ? String(payload.code).trim().toUpperCase() : undefined, + name: normalizeString(payload.name), + parent_id: payload.parent_id ?? payload.plant_id ?? null, + location: normalizeString(payload.location), + is_active: payload.is_active, + gstin: null, + address: null, + city: null, + state: null, + pincode: null, + phone: null, +}); + +const warehouseInclude = { parent: { select: { id: true, code: true, name: true } } }; + +const assertUniqueCode = async (code, excludeId = null) => { + if (!code) return; + const existing = await prisma.locations.findFirst({ + where: { + code, + deleted_at: null, + ...(excludeId ? { id: { not: BigInt(excludeId) } } : {}), + }, + }); + if (existing) throw new ApiError(409, 'Location code already exists'); +}; + +const createLocation = async (type, payload, userId, requestId) => { + const data = + type === LOCATION_TYPES.PLANT ? buildPlantData(payload) : buildWarehouseData(payload); + + if (!data.code || !data.name) throw new ApiError(422, 'code and name are required'); + await assertUniqueCode(data.code); + + if (type === LOCATION_TYPES.WAREHOUSE) { + if (!data.parent_id) throw new ApiError(422, 'parent_id (plant) is required for warehouse'); + await assertPlant(data.parent_id, 'parent_id'); + data.parent_id = BigInt(data.parent_id); + } + + data.created_by = userId ? BigInt(userId) : null; + data.updated_by = userId ? BigInt(userId) : null; + + const created = await prisma.locations.create({ + data, + include: type === LOCATION_TYPES.WAREHOUSE ? warehouseInclude : undefined, + }); + + const response = + type === LOCATION_TYPES.PLANT ? toPlantResponse(created) : toWarehouseResponse(created); + + await auditLog({ + tableName: TABLE_NAME, + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: response, + userId, + requestId, + }); + + return response; +}; + +const listLocations = async (query, type = null) => { + const { page, limit, skip } = getPagination(query); + + const where = { + deleted_at: null, + ...(type ? { type } : {}), + ...(query.parent_id ? { parent_id: BigInt(query.parent_id) } : {}), + ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), + ...(query.search + ? { + OR: [ + { code: { contains: query.search, mode: 'insensitive' } }, + { name: { contains: query.search, mode: 'insensitive' } }, + ], + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.locations.findMany({ + where, + include: type === LOCATION_TYPES.WAREHOUSE ? warehouseInclude : undefined, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.locations.count({ where }), + ]); + + const mapRow = (row) => { + if (type === LOCATION_TYPES.PLANT) return toPlantResponse(row); + if (type === LOCATION_TYPES.WAREHOUSE) return toWarehouseResponse(row); + return toLocationResponse(row); + }; + + return { data: rows.map(mapRow), meta: { page, limit, total } }; +}; + +const getLocationById = async (id, type = null) => { + const row = await prisma.locations.findFirst({ + where: { + id: BigInt(id), + deleted_at: null, + ...(type ? { type } : {}), + }, + include: warehouseInclude, + }); + if (!row) throw new ApiError(404, type ? `${type} not found` : 'Location not found'); + + if (type === LOCATION_TYPES.PLANT) return toPlantResponse(row); + if (type === LOCATION_TYPES.WAREHOUSE) return toWarehouseResponse(row); + return toLocationResponse(row); +}; + +const updateLocation = async (id, type, payload, userId, requestId) => { + const existing = await prisma.locations.findFirst({ + where: { id: BigInt(id), deleted_at: null, type }, + include: warehouseInclude, + }); + if (!existing) throw new ApiError(404, `${type} not found`); + + const data = + type === LOCATION_TYPES.PLANT ? buildPlantData(payload) : buildWarehouseData(payload); + Object.keys(data).forEach((key) => { + if (data[key] === undefined) delete data[key]; + }); + + if (data.code) await assertUniqueCode(data.code, id); + + if (type === LOCATION_TYPES.WAREHOUSE && data.parent_id !== undefined) { + if (!data.parent_id) throw new ApiError(422, 'parent_id (plant) is required for warehouse'); + await assertPlant(data.parent_id, 'parent_id'); + data.parent_id = BigInt(data.parent_id); + } + + data.updated_by = userId ? BigInt(userId) : null; + + const updated = await prisma.locations.update({ + where: { id: BigInt(id) }, + data, + include: type === LOCATION_TYPES.WAREHOUSE ? warehouseInclude : undefined, + }); + + const oldValue = + type === LOCATION_TYPES.PLANT ? toPlantResponse(existing) : toWarehouseResponse(existing); + const newValue = + type === LOCATION_TYPES.PLANT ? toPlantResponse(updated) : toWarehouseResponse(updated); + + await auditLog({ + tableName: TABLE_NAME, + recordId: id, + action: 'UPDATE', + oldValue, + newValue, + userId, + requestId, + }); + + return newValue; +}; + +const deleteLocation = async (id, type, userId, requestId) => { + const existing = await prisma.locations.findFirst({ + where: { id: BigInt(id), deleted_at: null, type }, + include: warehouseInclude, + }); + if (!existing) throw new ApiError(404, `${type} not found`); + + await prisma.locations.update({ + where: { id: BigInt(id) }, + data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null }, + }); + + const oldValue = + type === LOCATION_TYPES.PLANT ? toPlantResponse(existing) : toWarehouseResponse(existing); + + await auditLog({ + tableName: TABLE_NAME, + recordId: id, + action: 'DELETE', + oldValue, + newValue: { deleted_at: new Date() }, + userId, + requestId, + }); +}; + +const deleteLocationById = async (id, userId, requestId) => { + const existing = await prisma.locations.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + include: warehouseInclude, + }); + if (!existing) throw new ApiError(404, 'Location not found'); + return deleteLocation(id, existing.type, userId, requestId); +}; + +const updateLocationById = async (id, payload, userId, requestId) => { + const existing = await prisma.locations.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + }); + if (!existing) throw new ApiError(404, 'Location not found'); + return updateLocation(id, existing.type, payload, userId, requestId); +}; + +module.exports = { + LOCATION_TYPES, + createPlant: (payload, userId, requestId) => + createLocation(LOCATION_TYPES.PLANT, payload, userId, requestId), + listPlants: (query) => listLocations(query, LOCATION_TYPES.PLANT), + getPlantById: (id) => getLocationById(id, LOCATION_TYPES.PLANT), + updatePlant: (id, payload, userId, requestId) => + updateLocation(id, LOCATION_TYPES.PLANT, payload, userId, requestId), + deletePlant: (id, userId, requestId) => + deleteLocation(id, LOCATION_TYPES.PLANT, userId, requestId), + createWarehouse: (payload, userId, requestId) => + createLocation(LOCATION_TYPES.WAREHOUSE, payload, userId, requestId), + listWarehouses: (query) => listLocations(query, LOCATION_TYPES.WAREHOUSE), + getWarehouseById: (id) => getLocationById(id, LOCATION_TYPES.WAREHOUSE), + updateWarehouse: (id, payload, userId, requestId) => + updateLocation(id, LOCATION_TYPES.WAREHOUSE, payload, userId, requestId), + deleteWarehouse: (id, userId, requestId) => + deleteLocation(id, LOCATION_TYPES.WAREHOUSE, userId, requestId), + createLocation: (payload, userId, requestId) => { + if (!payload.type) throw new ApiError(422, 'type is required'); + if (![LOCATION_TYPES.PLANT, LOCATION_TYPES.WAREHOUSE].includes(payload.type)) { + throw new ApiError(422, 'type must be plant or warehouse'); + } + return createLocation(payload.type, payload, userId, requestId); + }, + listLocations: (query) => listLocations(query), + getLocationById: (id) => getLocationById(id), + updateLocationById, + deleteLocationById, +}; diff --git a/src/modules/masters/locations/locations.validation.js b/src/modules/masters/locations/locations.validation.js new file mode 100644 index 0000000..160d17e --- /dev/null +++ b/src/modules/masters/locations/locations.validation.js @@ -0,0 +1,94 @@ +const Joi = require('joi'); + +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(), + state: Joi.string().max(100).allow(null, '').optional(), + pincode: Joi.string().max(10).allow(null, '').optional(), + phone: Joi.string().max(15).allow(null, '').optional(), + location: Joi.string().max(200).allow(null, '').optional(), + parent_id: Joi.number().integer().positive().optional(), + plant_id: Joi.number().integer().positive().optional(), + is_active: Joi.boolean().optional(), +}; + +const createPlantSchema = Joi.object({ + code: Joi.string().max(30).required(), + name: Joi.string().max(150).required(), + gstin: locationFields.gstin, + address: locationFields.address, + city: locationFields.city, + state: locationFields.state, + pincode: locationFields.pincode, + phone: locationFields.phone, + is_active: Joi.boolean().default(true), +}); + +const updatePlantSchema = createPlantSchema.fork(['code', 'name'], (schema) => schema.optional()).min(1); + +const createWarehouseSchema = Joi.object({ + code: Joi.string().max(30).required(), + name: Joi.string().max(150).required(), + 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 createLocationSchema = Joi.object({ + type: Joi.string().valid('plant', 'warehouse').required(), + code: Joi.string().max(30).required(), + name: Joi.string().max(150).required(), + parent_id: Joi.when('type', { + is: 'warehouse', + then: Joi.number().integer().positive().required(), + otherwise: Joi.number().integer().positive().allow(null).optional(), + }), + gstin: locationFields.gstin, + address: locationFields.address, + city: locationFields.city, + state: locationFields.state, + pincode: locationFields.pincode, + phone: locationFields.phone, + location: locationFields.location, + is_active: Joi.boolean().default(true), +}); + +const updateLocationSchema = Joi.object({ + code: Joi.string().max(30).optional(), + name: Joi.string().max(150).optional(), + parent_id: Joi.number().integer().positive().optional(), + gstin: locationFields.gstin, + address: locationFields.address, + city: locationFields.city, + state: locationFields.state, + pincode: locationFields.pincode, + phone: locationFields.phone, + location: locationFields.location, + 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(), + type: Joi.string().valid('plant', 'warehouse').optional(), + parent_id: Joi.number().integer().positive().optional(), +}); + +module.exports = { + createPlantSchema, + updatePlantSchema, + createWarehouseSchema, + updateWarehouseSchema, + createLocationSchema, + updateLocationSchema, + listQuerySchema, +}; diff --git a/src/modules/masters/plants/plants.service.js b/src/modules/masters/plants/plants.service.js index 5d69b65..2a01c75 100644 --- a/src/modules/masters/plants/plants.service.js +++ b/src/modules/masters/plants/plants.service.js @@ -1,86 +1,9 @@ -const { buildMasterService } = require('../_shared/master.factory'); - -const config = { - modelName: 'plants', - tableName: 'plants', - uniqueField: 'code', - softDelete: true, - fields: [ - { - name: 'code', - type: 'string', - uppercase: true, - searchable: true, - }, - { - name: 'name', - type: 'string', - uppercase: false, - searchable: true, - }, - { - name: 'gstin', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'address', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'city', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'state', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'pincode', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'phone', - type: 'string', - uppercase: false, - searchable: false, - }, - { - 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 service = require('../locations/locations.service'); module.exports = { - createPlants: service.createOne, - listPlants: service.list, - getPlantsById: service.getOne, - updatePlants: service.updateOne, - deletePlants: service.removeOne, + createPlants: service.createPlant, + listPlants: service.listPlants, + getPlantsById: service.getPlantById, + updatePlants: service.updatePlant, + deletePlants: service.deletePlant, }; diff --git a/src/modules/masters/warehouses/warehouses.service.js b/src/modules/masters/warehouses/warehouses.service.js index 07a175a..625114e 100644 --- a/src/modules/masters/warehouses/warehouses.service.js +++ b/src/modules/masters/warehouses/warehouses.service.js @@ -1,62 +1,9 @@ -const { buildMasterService } = require('../_shared/master.factory'); - -const config = { - modelName: 'warehouses', - tableName: 'warehouses', - uniqueField: 'code', - softDelete: true, - fields: [ - { - name: 'code', - type: 'string', - uppercase: true, - searchable: true, - }, - { - name: 'name', - type: 'string', - uppercase: false, - searchable: true, - }, - { - name: 'plant_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'location', - type: 'string', - uppercase: false, - searchable: false, - }, - { - 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 service = require('../locations/locations.service'); module.exports = { - createWarehouses: service.createOne, - listWarehouses: service.list, - getWarehousesById: service.getOne, - updateWarehouses: service.updateOne, - deleteWarehouses: service.removeOne, + createWarehouses: service.createWarehouse, + listWarehouses: service.listWarehouses, + getWarehousesById: service.getWarehouseById, + updateWarehouses: service.updateWarehouse, + deleteWarehouses: service.deleteWarehouse, }; diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js index 9fdcdc5..3d89731 100644 --- a/src/modules/purchase-orders/purchase-orders.service.js +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -14,11 +14,12 @@ const { } = require('./purchase-orders.constants'); const { computeLineAmounts, computeHeaderTotals } = require('./purchase-orders.calculations'); const repository = require('./purchase-orders.repository'); +const { assertPlant, assertWarehouse } = require('../../utils/locations'); const poListInclude = { vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, - plants: { select: { id: true, code: true, name: true } }, - warehouses: { select: { id: true, code: true, name: true } }, + plant: { select: { id: true, code: true, name: true } }, + warehouse: { select: { id: true, code: true, name: true } }, brands: { select: { id: true, code: true, name: true } }, users_purchase_orders_created_byTousers: { select: { id: true, full_name: true } }, }; @@ -57,8 +58,8 @@ const sanitizePo = (po) => { if (!po) return null; const { vendors, - plants, - warehouses, + plant, + warehouse, brands, payment_terms, delivery_terms, @@ -73,8 +74,8 @@ const sanitizePo = (po) => { return { ...rest, vendor: vendors || null, - plant: plants || null, - warehouse: warehouses || null, + plant: plant || null, + warehouse: warehouse || null, brand: brands || null, payment_term: payment_terms || null, delivery_term: delivery_terms || null, @@ -102,7 +103,9 @@ const sanitizePo = (po) => { po_approvals: undefined, vendors: undefined, plants: undefined, + plant: undefined, warehouses: undefined, + warehouse: undefined, brands: undefined, payment_terms: undefined, delivery_terms: undefined, @@ -120,8 +123,7 @@ const assertStatus = (po, allowedStatuses, action) => { const SOFT_DELETE_TABLES = new Set([ 'vendors', - 'plants', - 'warehouses', + 'locations', 'payment_terms', 'delivery_terms', 'items', @@ -206,9 +208,8 @@ const validateAndBuildItems = async (items) => { const buildHeaderData = async (payload, builtItems, userId) => { await assertReference('vendors', payload.vendor_id, 'vendor_id'); - await assertReference('plants', payload.plant_id, 'plant_id'); - if (payload.warehouse_id) - await assertReference('warehouses', payload.warehouse_id, 'warehouse_id'); + await assertPlant(payload.plant_id, 'plant_id'); + if (payload.warehouse_id) await assertWarehouse(payload.warehouse_id, 'warehouse_id'); if (payload.brand_id) await assertReference('brands', payload.brand_id, 'brand_id', { requireActive: false }); if (payload.payment_term_id) diff --git a/src/modules/users/users.service.js b/src/modules/users/users.service.js index cf14d09..7485251 100644 --- a/src/modules/users/users.service.js +++ b/src/modules/users/users.service.js @@ -5,6 +5,7 @@ const ApiError = require('../../utils/ApiError'); const auditLog = require('../../utils/auditLog'); const { getPagination } = require('../../utils/pagination'); const { encrypt, decrypt, blindIndex } = require('../../utils/encryption'); +const { assertPlant } = require('../../utils/locations'); const { extractUserRoles } = require('../../utils/userPermissions'); const userInclude = { @@ -13,7 +14,7 @@ const userInclude = { }, departments: { select: { id: true, name: true } }, designations: { select: { id: true, name: true } }, - plants_users_plant_idToplants: { select: { id: true, code: true, name: true } }, + plant_location: { select: { id: true, code: true, name: true } }, users_users_reporting_toTousers: { select: { id: true, full_name: true, employee_code: true } }, }; @@ -26,7 +27,7 @@ const sanitizeUser = (user) => { if (!user) return null; const { user_roles, - plants_users_plant_idToplants, + plant_location, users_users_reporting_toTousers, departments, designations, @@ -44,7 +45,7 @@ const sanitizeUser = (user) => { role: roles[0] || null, department: departments || null, designation: designations || null, - plant: plants_users_plant_idToplants || null, + plant: plant_location || null, reporting_manager: users_users_reporting_toTousers || null, }; }; @@ -129,10 +130,7 @@ const assertFk = async (payload) => { if (!row) throw new ApiError(422, 'Invalid designation_id'); } if (payload.plant_id) { - const row = await prisma.plants.findFirst({ - where: { id: BigInt(payload.plant_id), deleted_at: null }, - }); - if (!row) throw new ApiError(422, 'Invalid plant_id'); + await assertPlant(payload.plant_id, 'plant_id'); } if (payload.reporting_to) { const row = await prisma.users.findFirst({ diff --git a/src/utils/locations.js b/src/utils/locations.js new file mode 100644 index 0000000..585d0eb --- /dev/null +++ b/src/utils/locations.js @@ -0,0 +1,96 @@ +const prisma = require('../config/prisma'); +const ApiError = require('./ApiError'); + +const LOCATION_TYPES = Object.freeze({ + PLANT: 'plant', + WAREHOUSE: 'warehouse', +}); + +const assertLocation = async (id, expectedType, label, { requireActive = true } = {}) => { + if (!id) return null; + + const row = await prisma.locations.findFirst({ + where: { + id: BigInt(id), + type: expectedType, + deleted_at: null, + }, + include: expectedType === LOCATION_TYPES.WAREHOUSE + ? { parent: { select: { id: true, code: true, name: true } } } + : undefined, + }); + + if (!row) throw new ApiError(422, `Invalid ${label}`); + if (requireActive && !row.is_active) throw new ApiError(422, `${label} is inactive`); + return row; +}; + +const assertPlant = (id, label = 'plant_id') => + assertLocation(id, LOCATION_TYPES.PLANT, label); + +const assertWarehouse = (id, label = 'warehouse_id') => + assertLocation(id, LOCATION_TYPES.WAREHOUSE, label); + +const locationListSelect = { id: true, code: true, name: true, type: true }; + +const plantListSelect = { id: true, code: true, name: true }; + +const warehouseListSelect = { + id: true, + code: true, + name: true, + parent_id: true, + location: true, + parent: { select: plantListSelect }, +}; + +const toPlantResponse = (row) => ({ + id: row.id, + code: row.code, + name: row.name, + gstin: row.gstin, + address: row.address, + city: row.city, + state: row.state, + pincode: row.pincode, + phone: row.phone, + is_active: row.is_active, + created_by: row.created_by, + updated_by: row.updated_by, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: row.deleted_at, +}); + +const toWarehouseResponse = (row) => ({ + id: row.id, + code: row.code, + name: row.name, + plant_id: row.parent_id, + plant: row.parent || null, + location: row.location, + is_active: row.is_active, + created_by: row.created_by, + updated_by: row.updated_by, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: row.deleted_at, +}); + +const toLocationResponse = (row) => { + if (row.type === LOCATION_TYPES.PLANT) return { ...toPlantResponse(row), type: row.type }; + return { ...toWarehouseResponse(row), type: row.type, parent_id: row.parent_id }; +}; + +module.exports = { + LOCATION_TYPES, + assertLocation, + assertPlant, + assertWarehouse, + locationListSelect, + plantListSelect, + warehouseListSelect, + toPlantResponse, + toWarehouseResponse, + toLocationResponse, +};