From a108c9c925d027cd93126b79ed94e79a47d97b9b Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Thu, 16 Jul 2026 15:53:17 +0530 Subject: [PATCH] GWM : plant , warehouse removed shippment, billing added & igst,cgst,sgst added in po module --- docs/MODULE_DATA_FLOW.md | 10 +- prisma/schema.prisma | 44 ++-- scripts/patch-asset-transfers-location.sql | 45 ++++ scripts/patch-assets-location.sql | 206 ++++++++++++++++++ scripts/patch-po-billing-shipping-gst.sql | 75 +++++++ src/docs/assets-routes.yaml | 17 +- src/docs/purchase-orders-routes.yaml | 16 +- src/docs/reports-routes.yaml | 4 +- src/modules/assets/assets.repository.js | 6 +- src/modules/assets/assets.service.js | 57 ++--- src/modules/assets/assets.validation.js | 13 +- src/modules/grn/grn.repository.js | 7 +- src/modules/grn/grn.service.js | 2 +- .../purchase-orders.calculations.js | 17 +- .../purchase-orders.service.js | 115 +++++++--- .../purchase-orders.validation.js | 15 +- .../reports-assets-depreciation.service.js | 16 +- src/modules/reports/reports.validation.js | 2 +- src/utils/locations.js | 15 ++ src/utils/pdf/templates/asset.template.js | 14 +- src/utils/pdf/templates/po.template.js | 61 +++++- 21 files changed, 589 insertions(+), 168 deletions(-) create mode 100644 scripts/patch-asset-transfers-location.sql create mode 100644 scripts/patch-assets-location.sql create mode 100644 scripts/patch-po-billing-shipping-gst.sql diff --git a/docs/MODULE_DATA_FLOW.md b/docs/MODULE_DATA_FLOW.md index 3cf33c8..b7b6d85 100644 --- a/docs/MODULE_DATA_FLOW.md +++ b/docs/MODULE_DATA_FLOW.md @@ -329,8 +329,8 @@ flowchart TD | `asset_code` | `document_series` via `ASSET_{category.code}` | | `asset_name` | Item name (+ `#N` if qty > 1) | | `item_category_id` / `item_subcategory_id` | From item master (optional GRN line override) | -| `plant_id` | From PO | -| `warehouse_id`, `vendor_id`, `po_id`, `grn_id`, `grn_item_id` | From GRN | +| `location_id` | From PO `shipping_id` (plant or warehouse) | +| `vendor_id`, `po_id`, `grn_id`, `grn_item_id` | From GRN | | `purchase_date` | `grn.grn_date` | | `purchase_cost` | `grn_items.rate` | | `useful_life_years`, `depreciation_method` | From `item_categories` defaults | @@ -350,7 +350,7 @@ flowchart TB subgraph Validate["normalizeAssetPayload validations"] V1[item_category + subcategory match] - V2[plant / dept / warehouse / user refs] + V2[location / dept / user refs] V3[vendor / PO / GRN / grn_item refs] V4[disposal_date required if DISPOSED/SCRAPPED] V5[depreciation_rate required if method=OTHER] @@ -364,7 +364,7 @@ flowchart TB SAVE --> TRANSFER["POST /assets/:id/transfer"] TRANSFER --> TH[(asset_transfers)] - TRANSFER --> UPDATE_LOC[Update plant/dept/user/warehouse on asset] + TRANSFER --> UPDATE_LOC[Update location/dept/user on asset] SAVE --> UPDATE["PUT /assets/:id"] SAVE --> DELETE["DELETE /assets/:id soft delete"] @@ -656,7 +656,7 @@ erDiagram asset_amc_contracts ||--o{ asset_service_visits : covers - locations ||--o{ assets : plant_warehouse + locations ||--o{ assets : location departments ||--o{ assets : assigned_dept users ||--o{ assets : assigned_user diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3edb3c2..cb1dc4c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,26 +140,22 @@ model asset_transfers { id BigInt @id @default(autoincrement()) asset_id BigInt transfer_date DateTime @db.Date - from_plant_id BigInt? - to_plant_id BigInt? + from_location_id BigInt? + to_location_id BigInt? from_department_id BigInt? to_department_id BigInt? from_user_id BigInt? to_user_id BigInt? - from_warehouse_id BigInt? - to_warehouse_id BigInt? reason String? transferred_by BigInt? 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) - from_plant locations? @relation("TransferFromPlant", fields: [from_plant_id], references: [id], onUpdate: NoAction) + from_location locations? @relation("TransferFromLocation", fields: [from_location_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) - 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) - to_plant locations? @relation("TransferToPlant", fields: [to_plant_id], references: [id], onUpdate: NoAction) + to_location locations? @relation("TransferToLocation", fields: [to_location_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) - 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") @@ -176,9 +172,8 @@ model assets { manufacturer String? @db.VarChar(200) serial_number String? @db.VarChar(100) part_number String? @db.VarChar(100) - plant_id BigInt + location_id BigInt department_id BigInt? - warehouse_id BigInt? location_detail String? @db.VarChar(200) assigned_to_user_id BigInt? vendor_id BigInt? @@ -217,16 +212,15 @@ 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) - plant locations @relation("AssetPlantLocation", fields: [plant_id], references: [id], onUpdate: NoAction) + location locations @relation("AssetLocation", fields: [location_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) - warehouse locations? @relation("AssetWarehouseLocation", fields: [warehouse_id], references: [id], onUpdate: NoAction) @@index([item_category_id], map: "idx_assets_item_category_id") @@index([item_subcategory_id], map: "idx_assets_item_subcategory_id") @@index([department_id], map: "idx_assets_dept_id") - @@index([plant_id], map: "idx_assets_plant_id") + @@index([location_id], map: "idx_assets_location_id") } model audit_logs { @@ -568,17 +562,14 @@ model locations { 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") + transfers_from asset_transfers[] @relation("TransferFromLocation") + transfers_to asset_transfers[] @relation("TransferToLocation") + assets_location assets[] @relation("AssetLocation") 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") + po_billing purchase_orders[] @relation("POBillingLocation") + po_shipping purchase_orders[] @relation("POShippingLocation") 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") @@ -656,14 +647,17 @@ model purchase_orders { po_number String @unique @db.VarChar(30) po_date DateTime @db.Date vendor_id BigInt - plant_id BigInt - warehouse_id BigInt? + billing_id BigInt + shipping_id BigInt payment_term_id BigInt? delivery_term_id BigInt? expected_delivery_date DateTime? @db.Date sub_total Decimal @default(0) @db.Decimal(15, 4) discount_amount Decimal @default(0) @db.Decimal(15, 4) tax_total Decimal @default(0) @db.Decimal(15, 4) + cgst Decimal @default(0) @db.Decimal(15, 4) + sgst Decimal @default(0) @db.Decimal(15, 4) + igst 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) @@ -692,10 +686,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) - plant locations @relation("POPlantLocation", fields: [plant_id], references: [id], onUpdate: NoAction) + billing_location locations @relation("POBillingLocation", fields: [billing_id], references: [id], onUpdate: NoAction) + shipping_location locations @relation("POShippingLocation", fields: [shipping_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) - 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") diff --git a/scripts/patch-asset-transfers-location.sql b/scripts/patch-asset-transfers-location.sql new file mode 100644 index 0000000..3eac43c --- /dev/null +++ b/scripts/patch-asset-transfers-location.sql @@ -0,0 +1,45 @@ +-- Asset transfers: replace from/to plant + warehouse columns with +-- from_location_id / to_location_id (either plant or warehouse). +-- Idempotent: safe to re-run. + +BEGIN; + +ALTER TABLE asset_transfers ADD COLUMN IF NOT EXISTS from_location_id BIGINT; +ALTER TABLE asset_transfers ADD COLUMN IF NOT EXISTS to_location_id BIGINT; + +-- Backfill from old columns (prefer warehouse, else plant) when present +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'asset_transfers' AND column_name = 'from_plant_id' + ) THEN + UPDATE asset_transfers + SET from_location_id = COALESCE(from_warehouse_id, from_plant_id) + WHERE from_location_id IS NULL; + UPDATE asset_transfers + SET to_location_id = COALESCE(to_warehouse_id, to_plant_id) + WHERE to_location_id IS NULL; + END IF; +END $$; + +-- Drop old columns (FK constraints drop with them) +ALTER TABLE asset_transfers DROP COLUMN IF EXISTS from_plant_id; +ALTER TABLE asset_transfers DROP COLUMN IF EXISTS to_plant_id; +ALTER TABLE asset_transfers DROP COLUMN IF EXISTS from_warehouse_id; +ALTER TABLE asset_transfers DROP COLUMN IF EXISTS to_warehouse_id; + +-- Foreign keys for the new columns +ALTER TABLE asset_transfers DROP CONSTRAINT IF EXISTS fk_at_from_location; +ALTER TABLE asset_transfers + ADD CONSTRAINT fk_at_from_location FOREIGN KEY (from_location_id) REFERENCES locations(id); +ALTER TABLE asset_transfers DROP CONSTRAINT IF EXISTS fk_at_to_location; +ALTER TABLE asset_transfers + ADD CONSTRAINT fk_at_to_location FOREIGN KEY (to_location_id) REFERENCES locations(id); + +COMMIT; + +-- Verify: +-- SELECT column_name FROM information_schema.columns +-- WHERE table_name = 'asset_transfers' +-- AND column_name IN ('from_location_id','to_location_id','from_plant_id','to_plant_id'); diff --git a/scripts/patch-assets-location.sql b/scripts/patch-assets-location.sql new file mode 100644 index 0000000..52dc058 --- /dev/null +++ b/scripts/patch-assets-location.sql @@ -0,0 +1,206 @@ +-- Assets: replace plant_id + warehouse_id with a single location_id (plant OR +-- warehouse). For GRN auto-created assets, location_id comes from the PO +-- shipping_id. Recreates the asset views to use location_id. +-- Idempotent: safe to re-run. + +BEGIN; + +-- 1. New unified location column +ALTER TABLE assets ADD COLUMN IF NOT EXISTS location_id BIGINT; + +-- 2. Backfill from old columns (prefer warehouse, else plant) when present +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'assets' AND column_name = 'plant_id' + ) THEN + UPDATE assets + SET location_id = COALESCE(warehouse_id, plant_id) + WHERE location_id IS NULL; + END IF; +END $$; + +ALTER TABLE assets ALTER COLUMN location_id SET NOT NULL; + +-- 3. Drop dependent views before removing old columns +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; + +-- 4. Drop old columns (FK constraints/indexes drop with them) +DROP INDEX IF EXISTS idx_assets_plant_id; +ALTER TABLE assets DROP COLUMN IF EXISTS plant_id; +ALTER TABLE assets DROP COLUMN IF EXISTS warehouse_id; + +-- 5. FK + index for the new column +ALTER TABLE assets DROP CONSTRAINT IF EXISTS fk_assets_location; +ALTER TABLE assets + ADD CONSTRAINT fk_assets_location FOREIGN KEY (location_id) REFERENCES locations(id); +CREATE INDEX IF NOT EXISTS idx_assets_location_id ON assets(location_id); + +-- 6. Recreate views using location_id (join locations regardless of type) +CREATE VIEW v_asset_expiry_alerts AS + SELECT + a.id AS asset_id, + a.asset_code, + a.asset_name, + ac_cat.name AS category_name, + loc.name AS location_name, + d.name AS department_name, + 'AMC'::text AS alert_type, + amc.id AS reference_id, + amc.contract_no AS reference_no, + v.vendor_name AS party_name, + amc.end_date AS expiry_date, + (amc.end_date - CURRENT_DATE) AS days_remaining, + CASE + WHEN (amc.end_date - CURRENT_DATE) <= 0 THEN 'EXPIRED' + WHEN (amc.end_date - CURRENT_DATE) <= 30 THEN 'CRITICAL' + WHEN (amc.end_date - CURRENT_DATE) <= 60 THEN 'WARNING' + WHEN (amc.end_date - CURRENT_DATE) <= 90 THEN 'INFO' + END AS alert_level + FROM asset_amc_contracts amc + JOIN assets a ON a.id = amc.asset_id + JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id + JOIN locations loc ON loc.id = a.location_id + 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 + AND (amc.end_date - CURRENT_DATE) <= 90 + +UNION ALL + + SELECT + a.id, a.asset_code, a.asset_name, ac_cat.name, loc.name, d.name, + 'INSURANCE'::text, ins.id, ins.policy_no, ins.insurer_name, + ins.policy_end_date, (ins.policy_end_date - CURRENT_DATE), + CASE + WHEN (ins.policy_end_date - CURRENT_DATE) <= 0 THEN 'EXPIRED' + WHEN (ins.policy_end_date - CURRENT_DATE) <= 30 THEN 'CRITICAL' + WHEN (ins.policy_end_date - CURRENT_DATE) <= 60 THEN 'WARNING' + WHEN (ins.policy_end_date - CURRENT_DATE) <= 90 THEN 'INFO' + END + FROM asset_insurance_policies ins + JOIN assets a ON a.id = ins.asset_id + JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id + JOIN locations loc ON loc.id = a.location_id + 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 + +UNION ALL + + SELECT + a.id, a.asset_code, a.asset_name, ac_cat.name, loc.name, d.name, + 'WARRANTY'::text, a.id, a.asset_code, 'Manufacturer Warranty'::varchar, + a.warranty_expiry_date, (a.warranty_expiry_date - CURRENT_DATE), + CASE + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 0 THEN 'EXPIRED' + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 30 THEN 'CRITICAL' + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 60 THEN 'WARNING' + WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 90 THEN 'INFO' + END + FROM assets a + JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id + JOIN locations loc ON loc.id = a.location_id + 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; + +CREATE VIEW v_asset_next_service AS +SELECT + a.id AS asset_id, + a.asset_code, + a.asset_name, + ac_cat.name AS category_name, + loc.name AS location_name, + d.name AS department_name, + sv.id AS last_visit_id, + sv.visit_date AS last_service_date, + sv.visit_type AS last_visit_type, + sv.next_service_date, + (sv.next_service_date - CURRENT_DATE) AS days_to_next_service, + CASE + WHEN sv.next_service_date < CURRENT_DATE THEN 'OVERDUE' + WHEN (sv.next_service_date - CURRENT_DATE) <= 7 THEN 'DUE_THIS_WEEK' + WHEN (sv.next_service_date - CURRENT_DATE) <= 30 THEN 'DUE_THIS_MONTH' + ELSE 'UPCOMING' + END AS service_status, + v.vendor_name AS service_vendor, + amc.contract_no AS amc_contract_no, + amc.end_date AS amc_end_date +FROM assets a +JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id +JOIN locations loc ON loc.id = a.location_id +LEFT JOIN departments d ON d.id = a.department_id +JOIN LATERAL ( + SELECT * FROM asset_service_visits + WHERE asset_id = a.id AND deleted_at IS NULL AND next_service_date IS NOT NULL + ORDER BY visit_date DESC + LIMIT 1 +) sv ON TRUE +LEFT JOIN vendors v ON v.id = sv.vendor_id +LEFT JOIN asset_amc_contracts amc + ON amc.asset_id = a.id AND amc.is_active = TRUE AND amc.deleted_at IS NULL +WHERE a.deleted_at IS NULL AND a.status NOT IN ('DISPOSED','SCRAPPED'); + +CREATE VIEW v_assets AS +SELECT + a.id, + a.asset_code, + a.asset_name, + ac.name AS category_name, + a.brand_model, + a.serial_number, + a.status, + a.condition, + a.purchase_date, + a.purchase_cost, + a.warranty_expiry_date, + loc.name AS location_name, + loc.type AS location_type, + d.name AS department_name, + a.location_detail, + u.full_name AS assigned_to, + v.vendor_name AS supplier, + a.qr_code_value, + amc.contract_no AS amc_contract_no, + amc.end_date AS amc_expiry_date, + amc.contract_type AS amc_type, + amcv.vendor_name AS amc_vendor, + (amc.end_date - CURRENT_DATE) AS amc_days_remaining, + ins.policy_no AS insurance_policy_no, + ins.policy_end_date AS insurance_expiry_date, + ins.insurer_name, + ins.sum_insured, + (ins.policy_end_date - CURRENT_DATE) AS insurance_days_remaining, + lsv.visit_date AS last_service_date, + lsv.next_service_date, + (lsv.next_service_date - CURRENT_DATE) AS service_due_in_days, + a.created_at +FROM assets a +JOIN item_categories ac ON ac.id = a.item_category_id +JOIN locations loc ON loc.id = a.location_id +LEFT JOIN departments d ON d.id = a.department_id +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 + ON amc.asset_id = a.id AND amc.is_active = TRUE AND amc.deleted_at IS NULL +LEFT JOIN vendors amcv ON amcv.id = amc.vendor_id +LEFT JOIN asset_insurance_policies ins + ON ins.asset_id = a.id AND ins.is_active = TRUE AND ins.deleted_at IS NULL +LEFT JOIN LATERAL ( + SELECT visit_date, next_service_date + FROM asset_service_visits + WHERE asset_id = a.id AND deleted_at IS NULL + ORDER BY visit_date DESC LIMIT 1 +) lsv ON TRUE +WHERE a.deleted_at IS NULL; + +COMMIT; + +-- Verify: +-- SELECT column_name FROM information_schema.columns +-- WHERE table_name = 'assets' +-- AND column_name IN ('location_id','plant_id','warehouse_id'); diff --git a/scripts/patch-po-billing-shipping-gst.sql b/scripts/patch-po-billing-shipping-gst.sql new file mode 100644 index 0000000..aa8dc0e --- /dev/null +++ b/scripts/patch-po-billing-shipping-gst.sql @@ -0,0 +1,75 @@ +-- Purchase orders: replace plant_id/warehouse_id with billing_id/shipping_id +-- (both reference locations, plant OR warehouse) and add cgst/sgst/igst columns +-- (tax_total is retained). Idempotent: safe to re-run. + +BEGIN; + +-- 1. New GST split columns (tax_total kept as the combined total) +ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS cgst NUMERIC(15, 4) NOT NULL DEFAULT 0; +ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS sgst NUMERIC(15, 4) NOT NULL DEFAULT 0; +ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS igst NUMERIC(15, 4) NOT NULL DEFAULT 0; + +-- 2. New billing/shipping location columns +ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS billing_id BIGINT; +ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS shipping_id BIGINT; + +-- 3. Backfill from the old columns when they still exist +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'purchase_orders' AND column_name = 'plant_id' + ) THEN + UPDATE purchase_orders SET billing_id = plant_id WHERE billing_id IS NULL; + UPDATE purchase_orders + SET shipping_id = COALESCE(warehouse_id, plant_id) + WHERE shipping_id IS NULL; + END IF; +END $$; + +-- 4. Enforce NOT NULL now that data is backfilled +ALTER TABLE purchase_orders ALTER COLUMN billing_id SET NOT NULL; +ALTER TABLE purchase_orders ALTER COLUMN shipping_id SET NOT NULL; + +-- 5. The view selects po.* so it must be dropped before dropping old columns +DROP VIEW IF EXISTS v_purchase_orders CASCADE; + +-- 6. Drop old location columns (their FK constraints/indexes drop with them) +ALTER TABLE purchase_orders DROP COLUMN IF EXISTS plant_id; +ALTER TABLE purchase_orders DROP COLUMN IF EXISTS warehouse_id; + +-- 7. Foreign keys + indexes for the new columns +ALTER TABLE purchase_orders DROP CONSTRAINT IF EXISTS fk_po_billing_location; +ALTER TABLE purchase_orders + ADD CONSTRAINT fk_po_billing_location FOREIGN KEY (billing_id) REFERENCES locations(id); +ALTER TABLE purchase_orders DROP CONSTRAINT IF EXISTS fk_po_shipping_location; +ALTER TABLE purchase_orders + ADD CONSTRAINT fk_po_shipping_location FOREIGN KEY (shipping_id) REFERENCES locations(id); +CREATE INDEX IF NOT EXISTS idx_po_billing_id ON purchase_orders(billing_id); +CREATE INDEX IF NOT EXISTS idx_po_shipping_id ON purchase_orders(shipping_id); + +-- 8. Recreate the reporting view against billing/shipping locations +CREATE VIEW v_purchase_orders AS +SELECT + po.*, + bl.code AS billing_code, + bl.name AS billing_name, + bl.type AS billing_type, + sl.code AS shipping_code, + sl.name AS shipping_name, + sl.type AS shipping_type, + v.vendor_code, + v.vendor_name, + v.vendor_type +FROM purchase_orders po +JOIN locations bl ON bl.id = po.billing_id +JOIN locations sl ON sl.id = po.shipping_id +JOIN vendors v ON v.id = po.vendor_id +WHERE po.deleted_at IS NULL; + +COMMIT; + +-- Verify: +-- SELECT column_name FROM information_schema.columns +-- WHERE table_name = 'purchase_orders' +-- AND column_name IN ('billing_id','shipping_id','cgst','sgst','igst','plant_id','warehouse_id'); diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 463973e..22c9a05 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -5,18 +5,17 @@ components: schemas: AssetsCreateBody: type: object - required: [asset_name, item_category_id, item_subcategory_id, plant_id] + required: [asset_name, item_category_id, item_subcategory_id, location_id] properties: asset_name: { type: string, example: 'CNC Lathe Machine' } item_category_id: { type: integer, example: 1 } item_subcategory_id: { type: integer, example: 1 } - plant_id: { type: integer, example: 1 } + location_id: { type: integer, example: 1, description: 'Location id (plant or warehouse)' } 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 } @@ -45,13 +44,12 @@ components: asset_name: { type: string } item_category_id: { type: integer } item_subcategory_id: { type: integer } - plant_id: { type: integer } + location_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 } @@ -89,12 +87,11 @@ components: required: [transfer_date] properties: transfer_date: { type: string, format: date } - to_plant_id: { type: integer, nullable: true } + to_location_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 + description: At least one of to_location_id, to_department_id, to_user_id is required AmcContractBody: type: object required: [vendor_id, start_date, end_date] @@ -436,7 +433,7 @@ paths: - { name: condition, in: query, schema: { type: string } } - { name: item_category_id, in: query, schema: { type: integer } } - { name: item_subcategory_id, in: query, schema: { type: integer } } - - { name: plant_id, in: query, schema: { type: integer } } + - { name: location_id, in: query, schema: { type: integer } } - { name: department_id, in: query, schema: { type: integer } } - { name: is_active, in: query, schema: { type: boolean } } responses: @@ -457,7 +454,7 @@ paths: - { name: condition, in: query, schema: { type: string } } - { name: item_category_id, in: query, schema: { type: integer } } - { name: item_subcategory_id, in: query, schema: { type: integer } } - - { name: plant_id, in: query, schema: { type: integer } } + - { name: location_id, in: query, schema: { type: integer } } - { name: department_id, in: query, schema: { type: integer } } - { name: is_active, in: query, schema: { type: boolean } } responses: diff --git a/src/docs/purchase-orders-routes.yaml b/src/docs/purchase-orders-routes.yaml index 8257854..d69380f 100644 --- a/src/docs/purchase-orders-routes.yaml +++ b/src/docs/purchase-orders-routes.yaml @@ -19,12 +19,12 @@ components: remarks: { type: string, example: 'Urgent line' } PurchaseOrdersCreateBody: type: object - required: [po_date, vendor_id, plant_id, items] + required: [po_date, vendor_id, billing_id, shipping_id, items] properties: po_date: { type: string, format: date, example: '2026-06-18' } vendor_id: { type: integer, example: 1 } - plant_id: { type: integer, example: 1 } - warehouse_id: { type: integer, nullable: true, example: 1 } + billing_id: { type: integer, example: 1, description: 'Location id (plant or warehouse) used for billing / place of supply' } + shipping_id: { type: integer, example: 2, description: 'Location id (plant or warehouse) used for shipping / delivery' } payment_term_id: { type: integer, nullable: true, example: 1 } delivery_term_id: { type: integer, nullable: true, example: 1 } expected_delivery_date: { type: string, format: date, nullable: true, example: '2026-07-01' } @@ -45,8 +45,8 @@ components: properties: po_date: { type: string, format: date, example: '2026-06-18' } vendor_id: { type: integer, example: 1 } - plant_id: { type: integer, example: 1 } - warehouse_id: { type: integer, nullable: true } + billing_id: { type: integer, example: 1 } + shipping_id: { type: integer, example: 2 } payment_term_id: { type: integer, nullable: true } delivery_term_id: { type: integer, nullable: true } expected_delivery_date: { type: string, format: date, nullable: true } @@ -100,7 +100,8 @@ paths: - { name: status, in: query, schema: { type: string, example: DRAFT } } - { name: vendor_type, in: query, schema: { type: string, example: RAW_MATERIAL } } - { name: vendor_id, in: query, schema: { type: integer, example: 1 } } - - { name: plant_id, in: query, schema: { type: integer, example: 1 } } + - { name: billing_id, in: query, schema: { type: integer, example: 1 } } + - { name: shipping_id, in: query, schema: { type: integer, example: 2 } } - { name: date_from, in: query, schema: { type: string, format: date } } - { name: date_to, in: query, schema: { type: string, format: date } } responses: @@ -120,7 +121,8 @@ paths: - { name: status, in: query, schema: { type: string, example: DRAFT } } - { name: vendor_type, in: query, schema: { type: string, example: RAW_MATERIAL } } - { name: vendor_id, in: query, schema: { type: integer, example: 1 } } - - { name: plant_id, in: query, schema: { type: integer, example: 1 } } + - { name: billing_id, in: query, schema: { type: integer, example: 1 } } + - { name: shipping_id, in: query, schema: { type: integer, example: 2 } } - { name: date_from, in: query, schema: { type: string, format: date } } - { name: date_to, in: query, schema: { type: string, format: date } } responses: diff --git a/src/docs/reports-routes.yaml b/src/docs/reports-routes.yaml index d0359d5..e7c554e 100644 --- a/src/docs/reports-routes.yaml +++ b/src/docs/reports-routes.yaml @@ -150,7 +150,7 @@ paths: name: item_subcategory_id schema: { type: integer } - in: query - name: plant_id + name: location_id schema: { type: integer } - in: query name: department_id @@ -205,7 +205,7 @@ paths: name: item_subcategory_id schema: { type: integer } - in: query - name: plant_id + name: location_id schema: { type: integer } - in: query name: department_id diff --git a/src/modules/assets/assets.repository.js b/src/modules/assets/assets.repository.js index 44ea3cb..0bec1bc 100644 --- a/src/modules/assets/assets.repository.js +++ b/src/modules/assets/assets.repository.js @@ -6,14 +6,12 @@ const transferAsset = async ({ assetId, transfer, updates, userId }) => data: { asset_id: BigInt(assetId), transfer_date: transfer.transfer_date, - from_plant_id: transfer.from_plant_id, - to_plant_id: transfer.to_plant_id, + from_location_id: transfer.from_location_id, + to_location_id: transfer.to_location_id, from_department_id: transfer.from_department_id, to_department_id: transfer.to_department_id, from_user_id: transfer.from_user_id, to_user_id: transfer.to_user_id, - from_warehouse_id: transfer.from_warehouse_id, - to_warehouse_id: transfer.to_warehouse_id, reason: transfer.reason, transferred_by: userId ? BigInt(userId) : null, }, diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index 8646c61..2d0447e 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -5,7 +5,7 @@ const { getPagination } = require('../../utils/pagination'); const { nextDocumentNumber } = require('../../utils/generateCode'); const { rowsToCsv } = require('../../utils/csv'); const { DISPOSAL_STATUSES, getAssetDropdownOptions } = require('./assets.constants'); -const { assertPlant, assertWarehouse } = require('../../utils/locations'); +const { assertAnyLocation } = require('../../utils/locations'); const repository = require('./assets.repository'); const { DEPRECIATION_METHOD_OPTIONS, @@ -27,9 +27,8 @@ const assetInclude = { }, }, item_subcategories: { select: { id: true, code: true, name: true, item_category_id: true } }, - plant: { select: { id: true, code: true, name: true } }, + location: { select: { id: true, code: true, name: true, type: true } }, departments: { select: { id: 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 }, }, @@ -51,14 +50,12 @@ const assetDetailInclude = { }; const transferInclude = { - from_plant: { select: { id: true, code: true, name: true } }, - to_plant: { select: { id: true, code: true, name: true } }, + from_location: { select: { id: true, code: true, name: true, type: true } }, + to_location: { select: { id: true, code: true, name: true, type: 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 } }, - 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 } }, }; @@ -69,9 +66,8 @@ const sanitizeAsset = (asset) => { const { item_categories, item_subcategories, - plant, + location, departments, - warehouse, users_assets_assigned_to_user_idTousers, vendors_assets_vendor_idTovendors, purchase_orders, @@ -102,9 +98,8 @@ const sanitizeAsset = (asset) => { depreciation_rate: depreciationRate, item_category: item_categories || null, item_subcategory: item_subcategories || null, - plant: plant || null, + location: location || null, department: departments || 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, @@ -135,28 +130,24 @@ const sanitizeAsset = (asset) => { const sanitizeTransfer = (row) => { if (!row) return null; const { - from_plant, - to_plant, + from_location, + to_location, departments_asset_transfers_from_department_idTodepartments, departments_asset_transfers_to_department_idTodepartments, users_asset_transfers_from_user_idTousers, users_asset_transfers_to_user_idTousers, - from_warehouse, - to_warehouse, users_asset_transfers_transferred_byTousers, ...rest } = row; return { ...rest, - from_plant: from_plant || null, - to_plant: to_plant || null, + from_location: from_location || null, + to_location: to_location || 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: from_warehouse || null, - to_warehouse: to_warehouse || null, transferred_by_user: users_asset_transfers_transferred_byTousers || null, }; }; @@ -191,10 +182,9 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { if (payload.item_subcategory_id) { await assertItemSubcategory(payload.item_subcategory_id, payload.item_category_id); } - await assertPlant(payload.plant_id, 'plant_id'); + await assertAnyLocation(payload.location_id, 'location_id'); if (payload.department_id) await assertReference('departments', payload.department_id, 'department_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'); @@ -240,9 +230,8 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { manufacturer: payload.manufacturer || null, serial_number: payload.serial_number || null, part_number: payload.part_number || null, - plant_id: BigInt(payload.plant_id), + location_id: BigInt(payload.location_id), department_id: payload.department_id ? BigInt(payload.department_id) : null, - warehouse_id: payload.warehouse_id ? BigInt(payload.warehouse_id) : null, location_detail: payload.location_detail || null, assigned_to_user_id: payload.assigned_to_user_id ? BigInt(payload.assigned_to_user_id) : null, vendor_id: payload.vendor_id ? BigInt(payload.vendor_id) : null, @@ -316,7 +305,7 @@ const buildAssetsWhere = (query) => ({ ...(query.item_subcategory_id ? { item_subcategory_id: BigInt(query.item_subcategory_id) } : {}), - ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), + ...(query.location_id ? { location_id: BigInt(query.location_id) } : {}), ...(query.department_id ? { department_id: BigInt(query.department_id) } : {}), ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), ...(query.search @@ -365,9 +354,8 @@ const exportAssets = async (query) => { { key: 'serial_number', header: 'Serial Number' }, { key: 'condition', header: 'Condition' }, { key: 'status', header: 'Status' }, - { key: (row) => row.plant?.name || '', header: 'Plant' }, + { key: (row) => row.location?.name || '', header: 'Location' }, { key: (row) => row.department?.name || '', header: 'Department' }, - { key: (row) => row.warehouse?.name || '', header: 'Warehouse' }, { key: (row) => row.assigned_to_user?.full_name || '', header: 'Assigned To' }, { key: (row) => row.vendor?.vendor_name || '', header: 'Vendor' }, { key: (row) => row.purchase_order?.po_number || '', header: 'PO Number' }, @@ -398,10 +386,9 @@ const updateAsset = async (id, payload, userId, requestId) => { serial_number: payload.serial_number !== undefined ? payload.serial_number : existing.serial_number, part_number: payload.part_number !== undefined ? payload.part_number : existing.part_number, - plant_id: payload.plant_id ?? existing.plant_id, + location_id: payload.location_id ?? existing.location_id, department_id: payload.department_id !== undefined ? payload.department_id : existing.department_id, - warehouse_id: payload.warehouse_id !== undefined ? payload.warehouse_id : existing.warehouse_id, location_detail: payload.location_detail !== undefined ? payload.location_detail : existing.location_detail, assigned_to_user_id: @@ -506,37 +493,31 @@ const transferAsset = async (id, payload, userId, requestId) => { throw new ApiError(409, 'Cannot transfer disposed or scrapped assets'); } - if (payload.to_plant_id) await assertPlant(payload.to_plant_id, 'to_plant_id'); + if (payload.to_location_id) await assertAnyLocation(payload.to_location_id, 'to_location_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 assertWarehouse(payload.to_warehouse_id, 'to_warehouse_id'); const transfer = { transfer_date: toDateOnly(payload.transfer_date), - from_plant_id: existing.plant_id, - to_plant_id: payload.to_plant_id ? BigInt(payload.to_plant_id) : null, + from_location_id: existing.location_id, + to_location_id: payload.to_location_id ? BigInt(payload.to_location_id) : null, from_department_id: existing.department_id, to_department_id: payload.to_department_id ? BigInt(payload.to_department_id) : null, from_user_id: existing.assigned_to_user_id, to_user_id: payload.to_user_id ? BigInt(payload.to_user_id) : null, - from_warehouse_id: existing.warehouse_id, - to_warehouse_id: payload.to_warehouse_id ? BigInt(payload.to_warehouse_id) : null, reason: payload.reason || null, }; const updates = { - ...(payload.to_plant_id ? { plant_id: BigInt(payload.to_plant_id) } : {}), + ...(payload.to_location_id ? { location_id: BigInt(payload.to_location_id) } : {}), ...(payload.to_department_id !== undefined ? { department_id: payload.to_department_id ? BigInt(payload.to_department_id) : null } : {}), ...(payload.to_user_id !== undefined ? { assigned_to_user_id: payload.to_user_id ? BigInt(payload.to_user_id) : null } : {}), - ...(payload.to_warehouse_id !== undefined - ? { warehouse_id: payload.to_warehouse_id ? BigInt(payload.to_warehouse_id) : null } - : {}), }; const { transferRow } = await repository.transferAsset({ diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index 55c6a21..f1377d5 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -22,9 +22,8 @@ const assetFields = { manufacturer: Joi.string().max(200).allow(null, '').optional(), serial_number: Joi.string().max(100).allow(null, '').optional(), part_number: Joi.string().max(100).allow(null, '').optional(), - plant_id: Joi.number().integer().positive().required(), + location_id: Joi.number().integer().positive().required(), department_id: Joi.number().integer().positive().allow(null).optional(), - warehouse_id: Joi.number().integer().positive().allow(null).optional(), location_detail: Joi.string().max(200).allow(null, '').optional(), assigned_to_user_id: Joi.number().integer().positive().allow(null).optional(), vendor_id: Joi.number().integer().positive().allow(null).optional(), @@ -65,9 +64,8 @@ const updateAssetSchema = Joi.object({ manufacturer: assetFields.manufacturer, serial_number: assetFields.serial_number, part_number: assetFields.part_number, - plant_id: assetFields.plant_id.optional(), + location_id: assetFields.location_id.optional(), department_id: assetFields.department_id, - warehouse_id: assetFields.warehouse_id, location_detail: assetFields.location_detail, assigned_to_user_id: assetFields.assigned_to_user_id, vendor_id: assetFields.vendor_id, @@ -103,7 +101,7 @@ const listAssetsQuerySchema = Joi.object({ .optional(), item_category_id: Joi.number().integer().positive().optional(), item_subcategory_id: Joi.number().integer().positive().optional(), - plant_id: Joi.number().integer().positive().optional(), + location_id: Joi.number().integer().positive().optional(), department_id: Joi.number().integer().positive().optional(), is_active: Joi.boolean().optional(), }); @@ -115,12 +113,11 @@ const exportAssetsQuerySchema = listAssetsQuerySchema.keys({ const transferAssetSchema = Joi.object({ transfer_date: Joi.date().iso().required(), - to_plant_id: Joi.number().integer().positive().allow(null).optional(), + to_location_id: Joi.number().integer().positive().allow(null).optional(), to_department_id: Joi.number().integer().positive().allow(null).optional(), to_user_id: Joi.number().integer().positive().allow(null).optional(), - to_warehouse_id: Joi.number().integer().positive().allow(null).optional(), reason: Joi.string().allow(null, '').optional(), -}).or('to_plant_id', 'to_department_id', 'to_user_id', 'to_warehouse_id'); +}).or('to_location_id', 'to_department_id', 'to_user_id'); const amcContractSchema = Joi.object({ vendor_id: Joi.number().integer().positive().required(), diff --git a/src/modules/grn/grn.repository.js b/src/modules/grn/grn.repository.js index a4df026..c80a80d 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, itemCategory, itemSubcategory, plantId, assetCodes, userId } + { grn, grnItem, item, itemCategory, itemSubcategory, locationId, assetCodes, userId } ) => { const createdAssets = []; @@ -15,8 +15,7 @@ const createAssetsForLine = async ( asset_name: assetCodes.length > 1 ? `${item.item_name} #${index + 1}` : item.item_name, item_category_id: itemCategory.id, item_subcategory_id: itemSubcategory.id, - plant_id: BigInt(plantId), - warehouse_id: grn.warehouse_id, + location_id: BigInt(locationId), vendor_id: grn.vendor_id, po_id: grn.po_id, grn_id: grn.id, @@ -69,7 +68,7 @@ const createGrnWithReceipt = async ({ grnNumber, header, items, assetPlans, user item: plan.item, itemCategory: plan.itemCategory, itemSubcategory: plan.itemSubcategory, - plantId: plan.plantId, + locationId: plan.locationId, assetCodes: plan.assetCodes, userId, }); diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js index dee3333..4fa2d18 100644 --- a/src/modules/grn/grn.service.js +++ b/src/modules/grn/grn.service.js @@ -356,7 +356,7 @@ const validateAndBuildItems = async (po, payloadItems) => { item, itemCategory, itemSubcategory, - plantId: po.plant_id, + locationId: po.shipping_id, assetCodes, }); } diff --git a/src/modules/purchase-orders/purchase-orders.calculations.js b/src/modules/purchase-orders/purchase-orders.calculations.js index 86558f9..0c6fbb5 100644 --- a/src/modules/purchase-orders/purchase-orders.calculations.js +++ b/src/modules/purchase-orders/purchase-orders.calculations.js @@ -60,4 +60,19 @@ const computeHeaderTotals = ( }; }; -module.exports = { computeLineAmounts, computeHeaderTotals, round4, toNum }; +/** + * Split a total GST amount into CGST/SGST (intra-state) or IGST (inter-state). + * For intra-state, CGST and SGST each take half of the tax; SGST absorbs any + * rounding remainder so cgst + sgst === taxTotal. + */ +const splitGst = (taxTotal, isInterState) => { + const total = round4(taxTotal); + if (isInterState) { + return { cgst: 0, sgst: 0, igst: total }; + } + const cgst = round4(total / 2); + const sgst = round4(total - cgst); + return { cgst, sgst, igst: 0 }; +}; + +module.exports = { computeLineAmounts, computeHeaderTotals, splitGst, round4, toNum }; diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js index 9654db1..aa24030 100644 --- a/src/modules/purchase-orders/purchase-orders.service.js +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -16,15 +16,22 @@ const { AMENDABLE_STATUSES, DELETABLE_STATUSES, } = require('./purchase-orders.constants'); -const { computeLineAmounts, computeHeaderTotals, toNum } = require('./purchase-orders.calculations'); +const { + computeLineAmounts, + computeHeaderTotals, + splitGst, + toNum, +} = require('./purchase-orders.calculations'); const repository = require('./purchase-orders.repository'); -const { assertPlant, assertWarehouse } = require('../../utils/locations'); +const { assertAnyLocation } = require('../../utils/locations'); const { sanitizeAttachment } = require('./po.attachments.service'); +const locationSummarySelect = { id: true, code: true, name: true, type: true }; + const poListInclude = { vendors: { select: { id: true, vendor_code: true, vendor_name: true, vendor_type: true } }, - plant: { select: { id: true, code: true, name: true } }, - warehouse: { select: { id: true, code: true, name: true } }, + billing_location: { select: locationSummarySelect }, + shipping_location: { select: locationSummarySelect }, users_purchase_orders_created_byTousers: { select: { id: true, full_name: true } }, }; @@ -83,11 +90,26 @@ const poPdfInclude = { }, }, }, - plant: { + billing_location: { select: { id: true, code: true, name: true, + type: true, + gstin: true, + address: true, + city: true, + state: true, + pincode: true, + }, + }, + shipping_location: { + select: { + id: true, + code: true, + name: true, + type: true, + gstin: true, address: true, city: true, state: true, @@ -106,8 +128,8 @@ const sanitizePo = (po) => { if (!po) return null; const { vendors, - plant, - warehouse, + billing_location, + shipping_location, payment_terms, delivery_terms, users_purchase_orders_created_byTousers, @@ -122,8 +144,8 @@ const sanitizePo = (po) => { return { ...rest, vendor: vendors || null, - plant: plant || null, - warehouse: warehouse || null, + billing: billing_location || null, + shipping: shipping_location || null, payment_term: payment_terms || null, delivery_term: delivery_terms || null, created_by_user: users_purchase_orders_created_byTousers || null, @@ -151,8 +173,8 @@ const sanitizePo = (po) => { po_approvals: undefined, po_attachments: undefined, vendors: undefined, - plants: undefined, - warehouses: undefined, + billing_location: undefined, + shipping_location: undefined, payment_terms: undefined, delivery_terms: undefined, users_purchase_orders_created_byTousers: undefined, @@ -252,10 +274,25 @@ const validateAndBuildItems = async (items) => { return builtItems; }; +const normalizeState = (value) => + value === null || value === undefined ? '' : String(value).trim().toLowerCase(); + +/** + * Inter-state (IGST) when the place of supply (billing location state) differs + * from the vendor's source of supply. When either state is unknown we default + * to intra-state (CGST/SGST). + */ +const isInterStateSupply = (billingLocation, vendor) => { + const placeOfSupply = normalizeState(billingLocation?.state); + const vendorState = normalizeState(vendor?.source_of_supply); + if (!placeOfSupply || !vendorState) return false; + return placeOfSupply !== vendorState; +}; + const buildHeaderData = async (payload, builtItems, userId) => { - await assertReference('vendors', payload.vendor_id, 'vendor_id'); - await assertPlant(payload.plant_id, 'plant_id'); - if (payload.warehouse_id) await assertWarehouse(payload.warehouse_id, 'warehouse_id'); + const vendor = await assertReference('vendors', payload.vendor_id, 'vendor_id'); + const billing = await assertAnyLocation(payload.billing_id, 'billing_id'); + await assertAnyLocation(payload.shipping_id, 'shipping_id'); if (payload.payment_term_id) await assertReference('payment_terms', payload.payment_term_id, 'payment_term_id'); if (payload.delivery_term_id) @@ -276,11 +313,13 @@ const buildHeaderData = async (payload, builtItems, userId) => { } ); + const gstSplit = splitGst(totals.tax_total, isInterStateSupply(billing, vendor)); + return { po_date: toDateOnly(payload.po_date), vendor_id: BigInt(payload.vendor_id), - plant_id: BigInt(payload.plant_id), - warehouse_id: payload.warehouse_id ? BigInt(payload.warehouse_id) : null, + billing_id: BigInt(payload.billing_id), + shipping_id: BigInt(payload.shipping_id), payment_term_id: payload.payment_term_id ? BigInt(payload.payment_term_id) : null, delivery_term_id: payload.delivery_term_id ? BigInt(payload.delivery_term_id) : null, expected_delivery_date: payload.expected_delivery_date @@ -291,6 +330,7 @@ const buildHeaderData = async (payload, builtItems, userId) => { tds_applicable: tdsApplicable, tds_section_pct: tdsApplicable ? tdsSectionPct : null, ...totals, + ...gstSplit, updated_by: userId ? BigInt(userId) : null, }; }; @@ -357,14 +397,22 @@ const buildPoPdfPayload = (po, company) => { state: vendorAddress?.state || '', pincode: vendorAddress?.pincode || '', }, - ship_to: { - name: po.plant?.name || '-', - address: po.plant?.address || '', - city: po.plant?.city || '', - state: po.plant?.state || '', - pincode: po.plant?.pincode || '', + bill_to: { + name: po.billing?.name || '-', + gstin: po.billing?.gstin || '', + address: po.billing?.address || '', + city: po.billing?.city || '', + state: po.billing?.state || '', + pincode: po.billing?.pincode || '', + }, + ship_to: { + name: po.shipping?.name || '-', + gstin: po.shipping?.gstin || '', + address: po.shipping?.address || '', + city: po.shipping?.city || '', + state: po.shipping?.state || '', + pincode: po.shipping?.pincode || '', }, - warehouse: { name: po.warehouse?.name || '-' }, items: (po.items || []).map((line) => { const orderedQty = toNum(line.ordered_qty); const rate = toNum(line.rate); @@ -384,6 +432,9 @@ const buildPoPdfPayload = (po, company) => { totals: { sub_total: toNum(po.sub_total), tax_total: toNum(po.tax_total), + cgst: toNum(po.cgst), + sgst: toNum(po.sgst), + igst: toNum(po.igst), freight_charges: toNum(po.freight_charges), other_charges: toNum(po.other_charges), discount_amount: toNum(po.discount_amount), @@ -427,7 +478,8 @@ const buildPurchaseOrdersWhere = (query) => ({ ...(query.status ? { status: query.status } : {}), ...(query.vendor_id ? { vendor_id: BigInt(query.vendor_id) } : {}), ...(query.vendor_type ? { vendors: { vendor_type: query.vendor_type } } : {}), - ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), + ...(query.billing_id ? { billing_id: BigInt(query.billing_id) } : {}), + ...(query.shipping_id ? { shipping_id: BigInt(query.shipping_id) } : {}), ...(query.search ? { po_number: { contains: query.search, mode: 'insensitive' } } : {}), ...(query.date_from || query.date_to ? { @@ -473,11 +525,14 @@ const exportPurchaseOrders = async (query) => { { key: 'revision_no', header: 'Revision' }, { key: (row) => row.vendor?.vendor_code || '', header: 'Vendor Code' }, { key: (row) => row.vendor?.vendor_name || '', header: 'Vendor Name' }, - { key: (row) => row.plant?.name || '', header: 'Plant' }, - { key: (row) => row.warehouse?.name || '', header: 'Warehouse' }, + { key: (row) => row.billing?.name || '', header: 'Billing Location' }, + { key: (row) => row.shipping?.name || '', header: 'Shipping Location' }, { key: 'expected_delivery_date', header: 'Expected Delivery', type: 'date' }, { key: 'sub_total', header: 'Sub Total' }, { key: 'tax_total', header: 'Tax Total' }, + { key: 'cgst', header: 'CGST' }, + { key: 'sgst', header: 'SGST' }, + { key: 'igst', header: 'IGST' }, { key: 'grand_total', header: 'Grand Total' }, { key: (row) => row.created_by_user?.full_name || '', header: 'Created By' }, { key: 'created_at', header: 'Created At', type: 'datetime' }, @@ -496,8 +551,8 @@ const updatePurchaseOrder = async (id, payload, userId, requestId) => { const merged = { po_date: payload.po_date ?? existing.po_date, vendor_id: payload.vendor_id ?? existing.vendor_id, - plant_id: payload.plant_id ?? existing.plant_id, - warehouse_id: payload.warehouse_id !== undefined ? payload.warehouse_id : existing.warehouse_id, + billing_id: payload.billing_id ?? existing.billing_id, + shipping_id: payload.shipping_id ?? existing.shipping_id, payment_term_id: payload.payment_term_id !== undefined ? payload.payment_term_id : existing.payment_term_id, delivery_term_id: @@ -765,8 +820,8 @@ const amendPurchaseOrder = async (id, payload, userId, requestId) => { const merged = { po_date: payload.po_date ?? existing.po_date, vendor_id: payload.vendor_id ?? existing.vendor_id, - plant_id: payload.plant_id ?? existing.plant_id, - warehouse_id: payload.warehouse_id !== undefined ? payload.warehouse_id : existing.warehouse_id, + billing_id: payload.billing_id ?? existing.billing_id, + shipping_id: payload.shipping_id ?? existing.shipping_id, payment_term_id: payload.payment_term_id !== undefined ? payload.payment_term_id : existing.payment_term_id, delivery_term_id: diff --git a/src/modules/purchase-orders/purchase-orders.validation.js b/src/modules/purchase-orders/purchase-orders.validation.js index e36311e..82a714e 100644 --- a/src/modules/purchase-orders/purchase-orders.validation.js +++ b/src/modules/purchase-orders/purchase-orders.validation.js @@ -17,8 +17,8 @@ const poItemSchema = Joi.object({ const poHeaderFields = { po_date: Joi.date().iso().required(), vendor_id: Joi.number().integer().positive().required(), - plant_id: Joi.number().integer().positive().required(), - warehouse_id: Joi.number().integer().positive().allow(null).optional(), + billing_id: Joi.number().integer().positive().required(), + shipping_id: Joi.number().integer().positive().required(), payment_term_id: Joi.number().integer().positive().allow(null).optional(), delivery_term_id: Joi.number().integer().positive().allow(null).optional(), expected_delivery_date: Joi.date().iso().allow(null).optional(), @@ -48,8 +48,8 @@ const createPurchaseOrderSchema = Joi.object({ const updatePurchaseOrderSchema = Joi.object({ po_date: poHeaderFields.po_date.optional(), vendor_id: poHeaderFields.vendor_id.optional(), - plant_id: poHeaderFields.plant_id.optional(), - warehouse_id: poHeaderFields.warehouse_id.optional(), + billing_id: poHeaderFields.billing_id.optional(), + shipping_id: poHeaderFields.shipping_id.optional(), payment_term_id: poHeaderFields.payment_term_id.optional(), delivery_term_id: poHeaderFields.delivery_term_id.optional(), expected_delivery_date: poHeaderFields.expected_delivery_date.optional(), @@ -67,8 +67,8 @@ const updatePurchaseOrderSchema = Joi.object({ const amendPurchaseOrderSchema = Joi.object({ po_date: poHeaderFields.po_date.optional(), vendor_id: poHeaderFields.vendor_id.optional(), - plant_id: poHeaderFields.plant_id.optional(), - warehouse_id: poHeaderFields.warehouse_id.optional(), + billing_id: poHeaderFields.billing_id.optional(), + shipping_id: poHeaderFields.shipping_id.optional(), payment_term_id: poHeaderFields.payment_term_id.optional(), delivery_term_id: poHeaderFields.delivery_term_id.optional(), expected_delivery_date: poHeaderFields.expected_delivery_date.optional(), @@ -94,7 +94,8 @@ const listPurchaseOrdersQuerySchema = Joi.object({ .valid(...VENDOR_TYPES) .optional(), vendor_id: Joi.number().integer().positive().optional(), - plant_id: Joi.number().integer().positive().optional(), + billing_id: Joi.number().integer().positive().optional(), + shipping_id: Joi.number().integer().positive().optional(), date_from: Joi.date().iso().optional(), date_to: Joi.date().iso().optional(), }); diff --git a/src/modules/reports/reports-assets-depreciation.service.js b/src/modules/reports/reports-assets-depreciation.service.js index 5590cd7..3c21671 100644 --- a/src/modules/reports/reports-assets-depreciation.service.js +++ b/src/modules/reports/reports-assets-depreciation.service.js @@ -6,7 +6,7 @@ const { calculateDepreciation, round4 } = require('../assets/assets.depreciation const depreciationInclude = { item_categories: { select: { id: true, code: true, name: true } }, item_subcategories: { select: { id: true, code: true, name: true } }, - plant: { select: { id: true, code: true, name: true } }, + location: { select: { id: true, code: true, name: true, type: true } }, departments: { select: { id: true, name: true } }, }; @@ -19,7 +19,7 @@ const buildDepreciationWhere = (query) => { ...(query.item_subcategory_id ? { item_subcategory_id: BigInt(query.item_subcategory_id) } : {}), - ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), + ...(query.location_id ? { location_id: BigInt(query.location_id) } : {}), ...(query.department_id ? { department_id: BigInt(query.department_id) } : {}), ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), ...(query.search @@ -73,7 +73,7 @@ const sanitizeDepreciationRow = (asset, asOfDate) => { depreciation_rate: depreciationRate, item_category: asset.item_categories || null, item_subcategory: asset.item_subcategories || null, - plant: asset.plant || null, + location: asset.location || null, department: asset.departments || null, depreciation, }; @@ -141,10 +141,10 @@ const listAssetDepreciation = async (query) => { }; const getDepreciationFilterOptions = async () => { - const [plants, departments, categories, subcategories] = await Promise.all([ + const [locations, departments, categories, subcategories] = await Promise.all([ prisma.locations.findMany({ - where: { deleted_at: null, is_active: true, type: 'plant' }, - select: { id: true, code: true, name: true }, + where: { deleted_at: null, is_active: true }, + select: { id: true, code: true, name: true, type: true }, orderBy: { name: 'asc' }, }), prisma.departments.findMany({ @@ -165,7 +165,7 @@ const getDepreciationFilterOptions = async () => { ]); return { - plants, + locations, departments, item_categories: categories, item_subcategories: subcategories, @@ -201,7 +201,7 @@ const exportAssetDepreciation = async (query) => { { key: 'status', header: 'Status' }, { key: (row) => row.item_category?.name || '', header: 'Category' }, { key: (row) => row.item_subcategory?.name || '', header: 'Subcategory' }, - { key: (row) => row.plant?.name || '', header: 'Plant' }, + { key: (row) => row.location?.name || '', header: 'Location' }, { key: (row) => row.department?.name || '', header: 'Department' }, { key: 'purchase_date', header: 'Purchase Date', type: 'date' }, { key: 'purchase_cost', header: 'Purchase Cost' }, diff --git a/src/modules/reports/reports.validation.js b/src/modules/reports/reports.validation.js index afbb5ad..c636184 100644 --- a/src/modules/reports/reports.validation.js +++ b/src/modules/reports/reports.validation.js @@ -11,7 +11,7 @@ const depreciationReportQuerySchema = Joi.object({ depreciation_method: Joi.string().valid('SLM', 'WDV', 'OTHER').optional(), item_category_id: Joi.number().integer().positive().optional(), item_subcategory_id: Joi.number().integer().positive().optional(), - plant_id: Joi.number().integer().positive().optional(), + location_id: Joi.number().integer().positive().optional(), department_id: Joi.number().integer().positive().optional(), is_active: Joi.boolean().optional(), as_of_date: Joi.date().iso().optional(), diff --git a/src/utils/locations.js b/src/utils/locations.js index 585d0eb..cc40f50 100644 --- a/src/utils/locations.js +++ b/src/utils/locations.js @@ -31,6 +31,20 @@ const assertPlant = (id, label = 'plant_id') => const assertWarehouse = (id, label = 'warehouse_id') => assertLocation(id, LOCATION_TYPES.WAREHOUSE, label); +/** Assert a location of any type (plant or warehouse). Returns the row (with parent). */ +const assertAnyLocation = async (id, label, { requireActive = true } = {}) => { + if (!id) return null; + + const row = await prisma.locations.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + include: { parent: { select: { id: true, code: true, name: true } } }, + }); + + if (!row) throw new ApiError(422, `Invalid ${label}`); + if (requireActive && !row.is_active) throw new ApiError(422, `${label} is inactive`); + return row; +}; + const locationListSelect = { id: true, code: true, name: true, type: true }; const plantListSelect = { id: true, code: true, name: true }; @@ -85,6 +99,7 @@ const toLocationResponse = (row) => { module.exports = { LOCATION_TYPES, assertLocation, + assertAnyLocation, assertPlant, assertWarehouse, locationListSelect, diff --git a/src/utils/pdf/templates/asset.template.js b/src/utils/pdf/templates/asset.template.js index 80e6469..96dd141 100644 --- a/src/utils/pdf/templates/asset.template.js +++ b/src/utils/pdf/templates/asset.template.js @@ -33,17 +33,14 @@ const getDummyAssetData = () => ({ code: 'PLT', name: 'Plant & Machinery', }, - plant: { - code: 'PLT-NOI-01', - name: 'Noida Manufacturing Plant', + location: { + code: 'WH-NOI-01', + name: 'Noida Main Warehouse', + type: 'warehouse', }, department: { name: 'Production Engineering', }, - warehouse: { - code: 'WH-NOI-01', - name: 'Noida Main Warehouse', - }, assigned_to: { full_name: 'Vikram Singh', employee_code: 'EMP-1042', @@ -124,9 +121,8 @@ const generateAssetHtml = (inputData = getDummyAssetData()) => {
Location & Assignment
${renderInfoRows([ - ['Plant', `${escapeHtml(data.plant.code)} - ${escapeHtml(data.plant.name)}`], + ['Location', `${escapeHtml(data.location.code)} - ${escapeHtml(data.location.name)}`], ['Department', escapeHtml(data.department.name)], - ['Warehouse', `${escapeHtml(data.warehouse.code)} - ${escapeHtml(data.warehouse.name)}`], ['Location Detail', escapeHtml(data.asset.location_detail)], [ 'Assigned To', diff --git a/src/utils/pdf/templates/po.template.js b/src/utils/pdf/templates/po.template.js index eb02ebf..c97e513 100644 --- a/src/utils/pdf/templates/po.template.js +++ b/src/utils/pdf/templates/po.template.js @@ -34,14 +34,22 @@ const getDummyPoData = () => ({ state: 'Jharkhand', pincode: '831001', }, - ship_to: { + bill_to: { name: 'Chennai manufacturing plant', + gstin: '33AAACT1234A1Z5', address: 'Guindy Industrial Estate', city: 'Chennai', state: 'Tamil Nadu', pincode: '600032', }, - warehouse: { name: 'Main warehouse' }, + ship_to: { + name: 'Main warehouse', + gstin: '33AAACT1234A1Z5', + address: 'Ambattur Industrial Estate', + city: 'Chennai', + state: 'Tamil Nadu', + pincode: '600058', + }, items: [ { line_no: 1, @@ -81,6 +89,9 @@ const getDummyPoData = () => ({ totals: { sub_total: 119775, tax_total: 19768.75, + cgst: 9884.38, + sgst: 9884.37, + igst: 0, freight_charges: 750, other_charges: 150, discount_amount: 500, @@ -120,8 +131,8 @@ const generatePoHtml = (inputData = getDummyPoData()) => { const company = data.company || {}; const po = data.po || {}; const vendor = data.vendor || {}; + const billTo = data.bill_to || {}; const shipTo = data.ship_to || {}; - const warehouse = data.warehouse || {}; const totals = data.totals || {}; const itemsRows = (data.items || []) @@ -155,7 +166,28 @@ const generatePoHtml = (inputData = getDummyPoData()) => { const discountValue = Number(totals.discount_amount || 0); const discountFormatted = discountValue > 0 ? `-${formatCurrency(discountValue)}` : formatCurrency(0); + const cgst = Number(totals.cgst || 0); + const sgst = Number(totals.sgst || 0); + const igst = Number(totals.igst || 0); + const taxRowsHtml = + igst > 0 + ? ` +
+ IGST + ${formatCurrency(igst)} +
` + : ` +
+ CGST + ${formatCurrency(cgst)} +
+
+ SGST + ${formatCurrency(sgst)} +
`; + const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode); + const billAddress = joinParts(billTo.address, billTo.city, billTo.state, billTo.pincode); const shipAddress = joinParts(shipTo.address, shipTo.city, shipTo.state, shipTo.pincode); const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' }); @@ -453,10 +485,26 @@ const generatePoHtml = (inputData = getDummyPoData()) => { : '' }
+
+
Bill to
+
${escapeHtml(billTo.name || '-')}
+
+ ${escapeHtml(billAddress || '-')}
+ ${billTo.gstin ? `GSTIN: ${escapeHtml(billTo.gstin)}` : ''} +
+
+ + +
+ +
Ship to
${escapeHtml(shipTo.name || '-')}
-
${escapeHtml(shipAddress || '-')}
+
+ ${escapeHtml(shipAddress || '-')}
+ ${shipTo.gstin ? `GSTIN: ${escapeHtml(shipTo.gstin)}` : ''} +
${ po.delivery_term ? `
Delivery term: ${escapeHtml(po.delivery_term)}
` @@ -472,10 +520,6 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
Vendor type
${escapeHtml(po.vendor_type || '-')}
-
-
Warehouse
-
${escapeHtml(warehouse.name || '-')}
-
Revision
${escapeHtml(String(po.revision_no ?? 0))}
@@ -513,6 +557,7 @@ const generatePoHtml = (inputData = getDummyPoData()) => { Tax (GST) ${formatCurrency(totals.tax_total)}
+ ${taxRowsHtml}
Freight charges ${formatCurrency(totals.freight_charges)}