GWM : plant , warehouse removed shippment, billing added & igst,cgst,sgst added in po module

This commit is contained in:
Gowtham M 2026-07-16 15:53:17 +05:30
parent e0e3077019
commit a108c9c925
21 changed files with 589 additions and 168 deletions

View File

@ -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

View File

@ -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")

View File

@ -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');

View File

@ -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');

View File

@ -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');

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -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,
},

View File

@ -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({

View File

@ -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(),

View File

@ -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,
});

View File

@ -356,7 +356,7 @@ const validateAndBuildItems = async (po, payloadItems) => {
item,
itemCategory,
itemSubcategory,
plantId: po.plant_id,
locationId: po.shipping_id,
assetCodes,
});
}

View File

@ -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 };

View File

@ -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:

View File

@ -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(),
});

View File

@ -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' },

View File

@ -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(),

View File

@ -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,

View File

@ -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()) => {
<div class="card-title">Location & Assignment</div>
<div class="card-body">
${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',

View File

@ -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
? `
<div class="summary-row">
<span>IGST</span>
<span class="amount">${formatCurrency(igst)}</span>
</div>`
: `
<div class="summary-row">
<span>CGST</span>
<span class="amount">${formatCurrency(cgst)}</span>
</div>
<div class="summary-row">
<span>SGST</span>
<span class="amount">${formatCurrency(sgst)}</span>
</div>`;
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()) => {
: ''
}
</div>
<div>
<div class="label">Bill to</div>
<div class="party-name">${escapeHtml(billTo.name || '-')}</div>
<div class="party-body">
${escapeHtml(billAddress || '-')}<br />
${billTo.gstin ? `GSTIN: ${escapeHtml(billTo.gstin)}` : ''}
</div>
</div>
</div>
<hr class="divider-light" />
<div class="party-row">
<div>
<div class="label">Ship to</div>
<div class="party-name">${escapeHtml(shipTo.name || '-')}</div>
<div class="party-body">${escapeHtml(shipAddress || '-')}</div>
<div class="party-body">
${escapeHtml(shipAddress || '-')}<br />
${shipTo.gstin ? `GSTIN: ${escapeHtml(shipTo.gstin)}` : ''}
</div>
${
po.delivery_term
? `<div class="party-term">Delivery term: ${escapeHtml(po.delivery_term)}</div>`
@ -472,10 +520,6 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<div class="label">Vendor type</div>
<div class="value">${escapeHtml(po.vendor_type || '-')}</div>
</div>
<div>
<div class="label">Warehouse</div>
<div class="value">${escapeHtml(warehouse.name || '-')}</div>
</div>
<div>
<div class="label">Revision</div>
<div class="value">${escapeHtml(String(po.revision_no ?? 0))}</div>
@ -513,6 +557,7 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<span>Tax (GST)</span>
<span class="amount">${formatCurrency(totals.tax_total)}</span>
</div>
${taxRowsHtml}
<div class="summary-row">
<span>Freight charges</span>
<span class="amount">${formatCurrency(totals.freight_charges)}</span>