From 4bab5f885943dc8d70bf4bd22a00ead97cdc3544 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Fri, 10 Jul 2026 10:47:24 +0530 Subject: [PATCH] Asset category masters removed and items category mapped to assets --- BACKEND_SETUP.md | 5 +- BACKEND_TASKS.md | 10 +- docs/MODULE_DATA_FLOW.html | 468 +++++++++++------- docs/MODULE_DATA_FLOW.md | 36 +- prisma/schema.prisma | 59 +-- scripts/patch-assets-views.sql | 10 +- scripts/patch-unify-item-asset-categories.sql | 234 +++++++++ src/docs/assets-routes.yaml | 14 +- src/docs/completed-routes.yaml | 186 +------ src/docs/grn-routes.yaml | 4 +- src/modules/assets/assets.helpers.js | 4 +- src/modules/assets/assets.service.js | 71 +-- src/modules/assets/assets.validation.js | 12 +- src/modules/grn/grn.repository.js | 14 +- src/modules/grn/grn.service.js | 37 +- src/modules/grn/grn.validation.js | 4 +- .../asset-categories.controller.js | 30 -- .../asset-categories.routes.js | 17 - .../asset-categories.service.js | 68 --- .../asset-categories.validation.js | 29 -- .../asset-subcategories.controller.js | 35 -- .../asset-subcategories.routes.js | 17 - .../asset-subcategories.service.js | 158 ------ .../asset-subcategories.validation.js | 26 - src/modules/masters/index.js | 2 - .../item-categories.service.js | 38 +- .../item-categories.validation.js | 13 + 27 files changed, 702 insertions(+), 899 deletions(-) create mode 100644 scripts/patch-unify-item-asset-categories.sql delete mode 100644 src/modules/masters/asset-categories/asset-categories.controller.js delete mode 100644 src/modules/masters/asset-categories/asset-categories.routes.js delete mode 100644 src/modules/masters/asset-categories/asset-categories.service.js delete mode 100644 src/modules/masters/asset-categories/asset-categories.validation.js delete mode 100644 src/modules/masters/asset-subcategories/asset-subcategories.controller.js delete mode 100644 src/modules/masters/asset-subcategories/asset-subcategories.routes.js delete mode 100644 src/modules/masters/asset-subcategories/asset-subcategories.service.js delete mode 100644 src/modules/masters/asset-subcategories/asset-subcategories.validation.js diff --git a/BACKEND_SETUP.md b/BACKEND_SETUP.md index b8d3de0..82fe63e 100644 --- a/BACKEND_SETUP.md +++ b/BACKEND_SETUP.md @@ -78,7 +78,6 @@ erp-backend/ │ │ │ ├── warehouses/ │ │ │ ├── payment-terms/ │ │ │ ├── delivery-terms/ -│ │ │ ├── asset-categories/ │ │ │ ├── departments/ │ │ │ ├── designations/ │ │ │ ├── plants/ @@ -1180,7 +1179,7 @@ module.exports = router; ### 9.4 Masters module pattern -Every master (UOM, Item Categories, Brands, GST Rates, Warehouses, Payment Terms, Delivery Terms, Asset Categories, Departments, Designations, Plants, etc.) follows the **same four-file pattern as Vendors**, scaled down — simple `name`/`code` fields, no document-number generation, RBAC permission code `MASTERS`. Build one master fully (UOM is simplest) as the template, then replicate for the rest. +Every master (UOM, Item Categories, Brands, GST Rates, Warehouses, Payment Terms, Delivery Terms, Departments, Designations, Plants, etc.) follows the **same four-file pattern as Vendors**, scaled down — simple `name`/`code` fields, no document-number generation, RBAC permission code `MASTERS`. Build one master fully (UOM is simplest) as the template, then replicate for the rest. Assets use the same item category / subcategory masters (no separate asset category APIs). `src/modules/masters/index.js` aggregates all sub-routers: @@ -1197,7 +1196,6 @@ router.use('/gst-rates', require('./gst-rates/gst-rates.routes')); router.use('/warehouses', require('./warehouses/warehouses.routes')); router.use('/payment-terms', require('./payment-terms/payment-terms.routes')); router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes')); -router.use('/asset-categories', require('./asset-categories/asset-categories.routes')); router.use('/departments', require('./departments/departments.routes')); router.use('/designations', require('./designations/designations.routes')); router.use('/plants', require('./plants/plants.routes')); @@ -1276,7 +1274,6 @@ BigInt.prototype.toJSON = function () { /api/v1/masters/warehouses /api/v1/masters/payment-terms /api/v1/masters/delivery-terms -/api/v1/masters/asset-categories /api/v1/masters/departments /api/v1/masters/designations /api/v1/masters/plants diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index b7dfedc..cd3082c 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -162,15 +162,15 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | Status | Sub-master | Base path | |--------|------------|-----------| | [x] | UOM | `/masters/uom` | -| [x] | Item Categories | `/masters/item-categories` | -| [x] | Item Subcategories | `/masters/item-subcategories` | +| [x] | Item Categories | `/masters/item-categories` | Optional asset defaults: `code_prefix`, `default_useful_life_years`, `default_depreciation_method` | +| [x] | Item Subcategories | `/masters/item-subcategories` | Filter: `item_category_id` — shared by items + assets | | [x] | Items | `/masters/items` | | [x] | Brands | `/masters/brands` | | [x] | GST Rates | `/masters/gst-rates` | | [x] | Payment Terms | `/masters/payment-terms` | | [x] | Delivery Terms | `/masters/delivery-terms` | -| [x] | Asset Categories | `/masters/asset-categories` | -| [x] | Asset Subcategories | `/masters/asset-subcategories` | Filter: `asset_category_id` | +| [~] | Asset Categories | removed — use Item Categories | +| [~] | Asset Subcategories | removed — use Item Subcategories | | [x] | Departments | `/masters/departments` | | [x] | Designations | `/masters/designations` | | [x] | Locations | `/masters/locations` | Unified plants + warehouses (`type`: `plant` \| `warehouse`) | @@ -178,7 +178,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | [x] | Warehouses | `/masters/warehouses` | Alias — warehouse locations only | | [x] | Document Series | `/masters/document-series` | -**Masters total:** 14 modules × 5 endpoints = **70 APIs** [x] +**Masters total:** 12 modules × 5 endpoints (+ plants/warehouses aliases) — asset category masters retired; assets use item categories --- diff --git a/docs/MODULE_DATA_FLOW.html b/docs/MODULE_DATA_FLOW.html index 2dbff02..bd10b60 100644 --- a/docs/MODULE_DATA_FLOW.html +++ b/docs/MODULE_DATA_FLOW.html @@ -210,27 +210,36 @@ } .diagram-viewport { - overflow: auto; - max-height: 70vh; - padding: 1rem; - cursor: grab; + position: relative; + overflow: hidden; + height: min(70vh, 560px); + min-height: 280px; background: #fafbfc; + touch-action: none; + user-select: none; } + .diagram-viewport.can-pan { cursor: grab; } .diagram-viewport.is-panning { cursor: grabbing; } + .diagram-stage { + position: absolute; + inset: 0; + overflow: hidden; + } + .diagram-canvas { display: inline-block; - min-width: 100%; transform-origin: 0 0; - transition: transform 0.12s ease-out; + will-change: transform; } .diagram-canvas .mermaid, .diagram-canvas svg { display: block; - margin: 0 auto; + margin: 0; max-width: none !important; + pointer-events: none; } .diagram-fullscreen { @@ -302,25 +311,36 @@ .diagram-fullscreen-viewport { flex: 1; - overflow: auto; - padding: 2rem; - cursor: grab; - display: flex; - align-items: flex-start; - justify-content: center; + position: relative; + overflow: hidden; + background: #0f172a; + touch-action: none; + user-select: none; } + .diagram-fullscreen-viewport.can-pan { cursor: grab; } .diagram-fullscreen-viewport.is-panning { cursor: grabbing; } + .diagram-fullscreen-stage { + position: absolute; + inset: 0; + overflow: hidden; + } + .diagram-fullscreen-canvas { display: inline-block; - transform-origin: center center; + transform-origin: 0 0; + will-change: transform; background: var(--surface); border-radius: 8px; padding: 1.5rem; box-shadow: 0 25px 50px rgba(0, 0, 0, 0.35); } + .diagram-fullscreen-canvas svg { + pointer-events: none; + } + .diagram-fullscreen-hint { position: absolute; bottom: 1rem; @@ -357,7 +377,9 @@ main { margin-left: 0; max-width: 100%; } .diagram { break-inside: avoid; } .diagram-toolbar { display: none; } - .diagram-viewport { max-height: none; overflow: visible; } + .diagram-viewport { height: auto; min-height: 0; overflow: visible; } + .diagram-stage { position: static; overflow: visible; } + .diagram-canvas { transform: none !important; } } @@ -462,7 +484,7 @@ flowchart LR M2[item-categories / subcategories / items] M3[brands / gst-rates] M4[payment-terms / delivery-terms] - M5[asset-categories / subcategories] + M5[item-categories / subcategories] M6[departments / designations] M7[locations / plants / warehouses] M8[document-series] @@ -565,7 +587,10 @@ flowchart TD PT --> PO DT[delivery_terms] --> PO - ACAT[asset_categories] --> ASUB[asset_subcategories] + ICAT[item_categories] --> ISUB[item_subcategories] + ISUB --> ITEMS[items] + ICAT --> ASSETS[assets] + ISUB --> ASSETS ACAT --> ASSET ACAT --> GRN_AUTO[GRN auto-asset creation] ASUB --> ASSET @@ -646,12 +671,12 @@ stateDiagram-v2 asset_codedocument_series via ASSET_{category.code} asset_nameItem name (+ #N if qty > 1) - asset_category_id / asset_subcategory_idFrom GRN line payload + item_category_id / item_subcategory_idFrom item master (optional GRN line override) plant_idFrom PO warehouse_id, vendor_id, po_id, grn_id, grn_item_idFrom GRN purchase_dategrn.grn_date purchase_costgrn_items.rate - useful_life_years, depreciation_methodFrom asset_categories defaults + useful_life_years, depreciation_methodFrom item_categories defaults conditionNEW statusIN_USE @@ -667,7 +692,7 @@ flowchart TB end subgraph Validate["normalizeAssetPayload validations"] - V1[asset_category + subcategory match] + V1[item_category + subcategory match] V2[plant / dept / warehouse / user refs] V3[vendor / PO / GRN / grn_item refs] V4[disposal_date required if DISPOSED/SCRAPPED] @@ -801,7 +826,7 @@ flowchart TD
 flowchart TD
     subgraph Inputs
-        CAT[asset_categories defaults]
+        CAT[item_categories defaults]
         FORM[User form fields]
     end
 
@@ -951,9 +976,11 @@ erDiagram
     items ||--o{ purchase_order_items : ordered
     items ||--o{ grn_items : received
 
-    asset_categories ||--o{ asset_subcategories : has
-    asset_categories ||--o{ assets : classifies
-    asset_subcategories ||--o{ assets : sub_classifies
+    item_categories ||--o{ item_subcategories : has
+    item_categories ||--o{ items : classifies
+    item_categories ||--o{ assets : classifies
+    item_subcategories ||--o{ items : sub_classifies
+    item_subcategories ||--o{ assets : sub_classifies
 
     assets ||--o{ asset_amc_contracts : has
     assets ||--o{ asset_insurance_policies : insured_by
@@ -1039,15 +1066,17 @@ erDiagram
       
     
     
-
+
+
+
-
Fullscreen: scroll to zoom · drag to pan · + / − / 0 keys · Esc to exit
+
Drag to move · Scroll or Ctrl+scroll to zoom · + / − / 0 keys · Esc to exit
diff --git a/docs/MODULE_DATA_FLOW.md b/docs/MODULE_DATA_FLOW.md index 67699bc..2a78eb0 100644 --- a/docs/MODULE_DATA_FLOW.md +++ b/docs/MODULE_DATA_FLOW.md @@ -69,15 +69,14 @@ flowchart LR ROLES["/roles CRUD + permissions matrix"] end - subgraph Masters["/masters/* (14 sub-masters)"] + subgraph Masters["/masters/* (item categories shared with assets)"] M1[uom] M2[item-categories / subcategories / items] M3[brands / gst-rates] M4[payment-terms / delivery-terms] - M5[asset-categories / subcategories] - M6[departments / designations] - M7[locations / plants / warehouses] - M8[document-series] + M5[departments / designations] + M6[locations / plants / warehouses] + M7[document-series] end subgraph Procurement @@ -180,11 +179,12 @@ flowchart TD PT --> PO DT[delivery_terms] --> PO - ACAT[asset_categories] --> ASUB[asset_subcategories] - ACAT --> ASSET - ACAT --> GRN_AUTO[GRN auto-asset creation] - ASUB --> ASSET - ASUB --> GRN_AUTO + ICAT[item_categories] --> ISUB[item_subcategories] + ISUB --> ITEMS[items] + ICAT --> ASSET + ICAT --> GRN_AUTO[GRN auto-asset creation] + ISUB --> ASSET + ISUB --> GRN_AUTO ITEMS --> PO_ITEMS ITEMS --> GRN_ITEMS @@ -260,12 +260,12 @@ stateDiagram-v2 |---|---| | `asset_code` | `document_series` via `ASSET_{category.code}` | | `asset_name` | Item name (+ `#N` if qty > 1) | -| `asset_category_id` / `asset_subcategory_id` | From GRN line payload | +| `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 | | `purchase_date` | `grn.grn_date` | | `purchase_cost` | `grn_items.rate` | -| `useful_life_years`, `depreciation_method` | From `asset_categories` defaults | +| `useful_life_years`, `depreciation_method` | From `item_categories` defaults | | `condition` | `NEW` | | `status` | `IN_USE` | @@ -281,7 +281,7 @@ flowchart TB end subgraph Validate["normalizeAssetPayload validations"] - V1[asset_category + subcategory match] + V1[item_category + subcategory match] V2[plant / dept / warehouse / user refs] V3[vendor / PO / GRN / grn_item refs] V4[disposal_date required if DISPOSED/SCRAPPED] @@ -418,7 +418,7 @@ flowchart TD ```mermaid flowchart TD subgraph Inputs - CAT[asset_categories defaults] + CAT[item_categories defaults] FORM[User form fields] end @@ -573,9 +573,11 @@ erDiagram items ||--o{ purchase_order_items : ordered items ||--o{ grn_items : received - asset_categories ||--o{ asset_subcategories : has - asset_categories ||--o{ assets : classifies - asset_subcategories ||--o{ assets : sub_classifies + item_categories ||--o{ item_subcategories : has + item_categories ||--o{ items : classifies + item_categories ||--o{ assets : classifies + item_subcategories ||--o{ items : sub_classifies + item_subcategories ||--o{ assets : sub_classifies assets ||--o{ asset_amc_contracts : has assets ||--o{ asset_insurance_policies : insured_by diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba363ea..1778640 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,44 +136,6 @@ model asset_service_visits { } /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. -model asset_categories { - id BigInt @id @default(autoincrement()) - code String @unique @db.VarChar(20) - name String @db.VarChar(150) - code_prefix String @unique @db.VarChar(10) - default_useful_life_years Int? - default_depreciation_method String? @db.VarChar(20) - is_active Boolean @default(true) - created_by BigInt? - updated_by BigInt? - created_at DateTime @default(now()) @db.Timestamptz(6) - updated_at DateTime @default(now()) @db.Timestamptz(6) - deleted_at DateTime? @db.Timestamptz(6) - users_asset_categories_created_byTousers users? @relation("asset_categories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) - users_asset_categories_updated_byTousers users? @relation("asset_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) - asset_subcategories asset_subcategories[] - assets assets[] -} - -model asset_subcategories { - id BigInt @id @default(autoincrement()) - asset_category_id BigInt - code String @unique @db.VarChar(30) - name String @db.VarChar(150) - is_active Boolean @default(true) - created_by BigInt? - updated_by BigInt? - created_at DateTime @default(now()) @db.Timestamptz(6) - updated_at DateTime @default(now()) @db.Timestamptz(6) - deleted_at DateTime? @db.Timestamptz(6) - users_asset_subcategories_created_byTousers users? @relation("asset_subcategories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) - asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction) - users_asset_subcategories_updated_byTousers users? @relation("asset_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) - assets assets[] - - @@index([asset_category_id], map: "idx_asset_subcategories_category_id") -} - model asset_transfers { id BigInt @id @default(autoincrement()) asset_id BigInt @@ -208,8 +170,8 @@ model assets { id BigInt @id @default(autoincrement()) asset_code String @unique @db.VarChar(30) asset_name String @db.VarChar(200) - asset_category_id BigInt - asset_subcategory_id BigInt? + item_category_id BigInt + item_subcategory_id BigInt? brand_model String? @db.VarChar(200) manufacturer String? @db.VarChar(200) serial_number String? @db.VarChar(100) @@ -248,8 +210,8 @@ model assets { asset_insurance_policies asset_insurance_policies[] asset_service_visits asset_service_visits[] asset_transfers asset_transfers[] - asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction) - asset_subcategories asset_subcategories? @relation(fields: [asset_subcategory_id], references: [id], onUpdate: NoAction) + item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction) + item_subcategories item_subcategories? @relation(fields: [item_subcategory_id], references: [id], onUpdate: NoAction) users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction) users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction) departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction) @@ -261,8 +223,8 @@ model assets { 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([asset_category_id], map: "idx_assets_category_id") - @@index([asset_subcategory_id], map: "idx_assets_subcategory_id") + @@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") } @@ -480,6 +442,9 @@ model item_categories { id BigInt @id @default(autoincrement()) code String @unique @db.VarChar(30) name String @db.VarChar(100) + code_prefix String? @unique @db.VarChar(10) + default_useful_life_years Int? + default_depreciation_method String? @db.VarChar(20) is_active Boolean @default(true) created_by BigInt? updated_by BigInt? @@ -490,6 +455,7 @@ model item_categories { users_item_categories_updated_byTousers users? @relation("item_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) item_subcategories item_subcategories[] items items[] + assets assets[] } model item_subcategories { @@ -507,6 +473,7 @@ model item_subcategories { item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction) users_item_subcategories_updated_byTousers users? @relation("item_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction) items items[] + assets assets[] } model items { @@ -856,10 +823,6 @@ model users { asset_insurance_policies_asset_insurance_policies_updated_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_updated_byTousers") asset_service_visits_asset_service_visits_created_byTousers asset_service_visits[] @relation("asset_service_visits_created_byTousers") asset_service_visits_asset_service_visits_updated_byTousers asset_service_visits[] @relation("asset_service_visits_updated_byTousers") - asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers") - asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers") - asset_subcategories_asset_subcategories_created_byTousers asset_subcategories[] @relation("asset_subcategories_created_byTousers") - asset_subcategories_asset_subcategories_updated_byTousers asset_subcategories[] @relation("asset_subcategories_updated_byTousers") asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers") asset_transfers_asset_transfers_to_user_idTousers asset_transfers[] @relation("asset_transfers_to_user_idTousers") asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers") diff --git a/scripts/patch-assets-views.sql b/scripts/patch-assets-views.sql index 013bfb2..7699d79 100644 --- a/scripts/patch-assets-views.sql +++ b/scripts/patch-assets-views.sql @@ -26,7 +26,7 @@ CREATE VIEW v_asset_expiry_alerts AS END AS alert_level FROM asset_amc_contracts amc JOIN assets a ON a.id = amc.asset_id - JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id + JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id LEFT JOIN vendors v ON v.id = amc.vendor_id @@ -47,7 +47,7 @@ UNION ALL END FROM asset_insurance_policies ins JOIN assets a ON a.id = ins.asset_id - JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id + JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id WHERE ins.is_active = TRUE AND ins.deleted_at IS NULL AND a.deleted_at IS NULL @@ -66,7 +66,7 @@ UNION ALL WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 90 THEN 'INFO' END FROM assets a - JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id + JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id WHERE a.warranty_expiry_date IS NOT NULL AND a.deleted_at IS NULL @@ -95,7 +95,7 @@ SELECT amc.contract_no AS amc_contract_no, amc.end_date AS amc_end_date FROM assets a -JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id +JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id JOIN LATERAL ( @@ -144,7 +144,7 @@ SELECT (lsv.next_service_date - CURRENT_DATE) AS service_due_in_days, a.created_at FROM assets a -JOIN asset_categories ac ON ac.id = a.asset_category_id +JOIN item_categories ac ON ac.id = a.item_category_id JOIN locations p ON p.id = a.plant_id AND p.type = 'plant' LEFT JOIN departments d ON d.id = a.department_id LEFT JOIN locations w ON w.id = a.warehouse_id AND w.type = 'warehouse' diff --git a/scripts/patch-unify-item-asset-categories.sql b/scripts/patch-unify-item-asset-categories.sql new file mode 100644 index 0000000..64aed01 --- /dev/null +++ b/scripts/patch-unify-item-asset-categories.sql @@ -0,0 +1,234 @@ +-- Unify asset categories onto item_categories / item_subcategories +-- Run against PostgreSQL before deploying BE that expects item_* FKs on assets. +-- +-- Pre-check (optional): +-- SELECT ac.code, ac.name AS asset_cat, ic.name AS item_cat +-- FROM asset_categories ac +-- JOIN item_categories ic ON UPPER(ac.code) = UPPER(ic.code) +-- AND ac.deleted_at IS NULL AND ic.deleted_at IS NULL; + +BEGIN; + +-- ========================================================= +-- 1) Extend item_categories with asset defaults +-- ========================================================= +ALTER TABLE item_categories + ADD COLUMN IF NOT EXISTS code_prefix VARCHAR(10), + ADD COLUMN IF NOT EXISTS default_useful_life_years INT, + ADD COLUMN IF NOT EXISTS default_depreciation_method VARCHAR(20); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'item_categories_code_prefix_key' + ) THEN + ALTER TABLE item_categories + ADD CONSTRAINT item_categories_code_prefix_key UNIQUE (code_prefix); + END IF; +END $$; + +-- ========================================================= +-- 2) Mapping tables +-- ========================================================= +CREATE TEMP TABLE map_asset_category ( + old_asset_category_id BIGINT PRIMARY KEY, + new_item_category_id BIGINT NOT NULL +); + +CREATE TEMP TABLE map_asset_subcategory ( + old_asset_subcategory_id BIGINT PRIMARY KEY, + new_item_subcategory_id BIGINT NOT NULL +); + +-- ========================================================= +-- 3) Upsert asset_categories -> item_categories +-- ========================================================= +WITH src AS ( + SELECT * + FROM asset_categories + WHERE deleted_at IS NULL +), +matched AS ( + SELECT s.id AS old_id, ic.id AS new_id + FROM src s + JOIN item_categories ic + ON UPPER(ic.code) = UPPER(s.code) + AND ic.deleted_at IS NULL +), +inserted AS ( + INSERT INTO item_categories ( + code, name, code_prefix, + default_useful_life_years, default_depreciation_method, + is_active, created_by, updated_by, created_at, updated_at + ) + SELECT + s.code, + s.name, + s.code_prefix, + s.default_useful_life_years, + s.default_depreciation_method, + s.is_active, + s.created_by, + s.updated_by, + s.created_at, + s.updated_at + FROM src s + WHERE NOT EXISTS ( + SELECT 1 FROM matched m WHERE m.old_id = s.id + ) + RETURNING id, code +) +INSERT INTO map_asset_category (old_asset_category_id, new_item_category_id) +SELECT old_id, new_id FROM matched +UNION ALL +SELECT s.id, i.id +FROM src s +JOIN inserted i ON UPPER(i.code) = UPPER(s.code); + +UPDATE item_categories ic +SET + code_prefix = COALESCE(ic.code_prefix, ac.code_prefix), + default_useful_life_years = COALESCE(ic.default_useful_life_years, ac.default_useful_life_years), + default_depreciation_method = COALESCE(ic.default_depreciation_method, ac.default_depreciation_method), + updated_at = NOW() +FROM asset_categories ac +JOIN map_asset_category m ON m.old_asset_category_id = ac.id +WHERE ic.id = m.new_item_category_id + AND ac.deleted_at IS NULL; + +-- ========================================================= +-- 4) Upsert asset_subcategories -> item_subcategories +-- ========================================================= +WITH src AS ( + SELECT asub.*, m.new_item_category_id + FROM asset_subcategories asub + JOIN map_asset_category m ON m.old_asset_category_id = asub.asset_category_id + WHERE asub.deleted_at IS NULL +), +matched AS ( + SELECT s.id AS old_id, isub.id AS new_id + FROM src s + JOIN item_subcategories isub + ON UPPER(isub.code) = UPPER(s.code) + AND isub.deleted_at IS NULL +), +inserted AS ( + INSERT INTO item_subcategories ( + item_category_id, code, name, + is_active, created_by, updated_by, created_at, updated_at + ) + SELECT + s.new_item_category_id, + s.code, + s.name, + s.is_active, + s.created_by, + s.updated_by, + s.created_at, + s.updated_at + FROM src s + WHERE NOT EXISTS ( + SELECT 1 FROM matched m WHERE m.old_id = s.id + ) + RETURNING id, code +) +INSERT INTO map_asset_subcategory (old_asset_subcategory_id, new_item_subcategory_id) +SELECT old_id, new_id FROM matched +UNION ALL +SELECT s.id, i.id +FROM src s +JOIN inserted i ON UPPER(i.code) = UPPER(s.code); + +UPDATE item_subcategories isub +SET + item_category_id = mcat.new_item_category_id, + updated_at = NOW() +FROM map_asset_subcategory msub +JOIN asset_subcategories asub ON asub.id = msub.old_asset_subcategory_id +JOIN map_asset_category mcat ON mcat.old_asset_category_id = asub.asset_category_id +WHERE isub.id = msub.new_item_subcategory_id; + +-- ========================================================= +-- 5) Remap assets to item_* columns +-- ========================================================= +ALTER TABLE assets + ADD COLUMN IF NOT EXISTS item_category_id BIGINT, + ADD COLUMN IF NOT EXISTS item_subcategory_id BIGINT; + +UPDATE assets a +SET + item_category_id = mapped.new_item_category_id, + item_subcategory_id = mapped.new_item_subcategory_id, + updated_at = NOW() +FROM ( + SELECT + a2.id AS asset_id, + mcat.new_item_category_id, + msub.new_item_subcategory_id + FROM assets a2 + JOIN map_asset_category mcat + ON mcat.old_asset_category_id = a2.asset_category_id + LEFT JOIN map_asset_subcategory msub + ON msub.old_asset_subcategory_id = a2.asset_subcategory_id +) mapped +WHERE a.id = mapped.asset_id; + +DO $$ +DECLARE missing_count BIGINT; +BEGIN + SELECT COUNT(*) INTO missing_count + FROM assets + WHERE deleted_at IS NULL + AND item_category_id IS NULL; + + IF missing_count > 0 THEN + RAISE EXCEPTION '% active assets could not be mapped to item_category_id', missing_count; + END IF; +END $$; + +ALTER TABLE assets + ALTER COLUMN item_category_id SET NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assets_item_category_id_fkey' + ) THEN + ALTER TABLE assets + ADD CONSTRAINT assets_item_category_id_fkey + FOREIGN KEY (item_category_id) REFERENCES item_categories (id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'assets_item_subcategory_id_fkey' + ) THEN + ALTER TABLE assets + ADD CONSTRAINT assets_item_subcategory_id_fkey + FOREIGN KEY (item_subcategory_id) REFERENCES item_subcategories (id); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_assets_item_category_id ON assets (item_category_id); +CREATE INDEX IF NOT EXISTS idx_assets_item_subcategory_id ON assets (item_subcategory_id); + +-- ========================================================= +-- 6) Drop dependent views, then old asset category FKs / columns / tables +-- ========================================================= +DROP VIEW IF EXISTS v_asset_expiry_alerts CASCADE; +DROP VIEW IF EXISTS v_asset_next_service CASCADE; +DROP VIEW IF EXISTS v_assets CASCADE; + +ALTER TABLE assets DROP CONSTRAINT IF EXISTS assets_asset_category_id_fkey; +ALTER TABLE assets DROP CONSTRAINT IF EXISTS assets_asset_subcategory_id_fkey; +DROP INDEX IF EXISTS idx_assets_category_id; +DROP INDEX IF EXISTS idx_assets_subcategory_id; +ALTER TABLE assets DROP COLUMN IF EXISTS asset_category_id; +ALTER TABLE assets DROP COLUMN IF EXISTS asset_subcategory_id; + +DROP TABLE IF EXISTS asset_subcategories; +DROP TABLE IF EXISTS asset_categories; + +COMMIT; + +-- Recreate reporting views after this script: +-- psql ... -f scripts/patch-assets-views.sql diff --git a/src/docs/assets-routes.yaml b/src/docs/assets-routes.yaml index 56c7abd..97cee28 100644 --- a/src/docs/assets-routes.yaml +++ b/src/docs/assets-routes.yaml @@ -5,11 +5,11 @@ components: schemas: AssetsCreateBody: type: object - required: [asset_name, asset_category_id, asset_subcategory_id, plant_id] + required: [asset_name, item_category_id, item_subcategory_id, plant_id] properties: asset_name: { type: string, example: 'CNC Lathe Machine' } - asset_category_id: { type: integer, example: 1 } - asset_subcategory_id: { type: integer, example: 1 } + item_category_id: { type: integer, example: 1 } + item_subcategory_id: { type: integer, example: 1 } plant_id: { type: integer, example: 1 } brand_model: { type: string } manufacturer: { type: string } @@ -43,8 +43,8 @@ components: minProperties: 1 properties: asset_name: { type: string } - asset_category_id: { type: integer } - asset_subcategory_id: { type: integer } + item_category_id: { type: integer } + item_subcategory_id: { type: integer } plant_id: { type: integer } brand_model: { type: string } manufacturer: { type: string } @@ -410,8 +410,8 @@ paths: - { name: search, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } } - { name: condition, in: query, schema: { type: string } } - - { name: asset_category_id, in: query, schema: { type: integer } } - - { name: asset_subcategory_id, in: query, schema: { type: integer } } + - { name: 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: department_id, in: query, schema: { type: integer } } - { name: is_active, in: query, schema: { type: boolean } } diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 75c32ca..692b078 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -3,13 +3,11 @@ tags: - name: UOM - name: Item Categories - name: Item Subcategories - - name: Asset Subcategories - name: Items - name: Brands - name: GST Rates - name: Payment Terms - name: Delivery Terms - - name: Asset Categories - name: Departments - name: Designations - name: Locations @@ -85,6 +83,9 @@ components: properties: code: { type: string, example: 'RAW' } name: { type: string, example: 'Raw Material' } + code_prefix: { type: string, nullable: true, example: 'MCH', description: Optional; used for asset code series when category is used for assets } + default_useful_life_years: { type: integer, nullable: true, example: 15 } + default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, OTHER], example: SLM } is_active: { type: boolean, example: true } ItemCategoriesUpdateBody: type: object @@ -92,6 +93,9 @@ components: properties: code: { type: string, example: 'RAW' } name: { type: string, example: 'Raw Material' } + code_prefix: { type: string, nullable: true, example: 'MCH' } + default_useful_life_years: { type: integer, nullable: true, example: 15 } + default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, OTHER], example: SLM } is_active: { type: boolean, example: true } ItemSubcategoriesCreateBody: type: object @@ -215,42 +219,6 @@ components: name: { type: string, example: 'Free on Road' } description: { type: string, example: 'Delivered to site' } is_active: { type: boolean, example: true } - AssetCategoriesCreateBody: - type: object - required: [code, name, code_prefix, default_useful_life_years, default_depreciation_method] - properties: - code: { type: string, example: 'MCH' } - name: { type: string, example: 'Plant & Machinery' } - code_prefix: { type: string, example: 'MCH' } - default_useful_life_years: { type: integer, example: 15 } - default_depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: WDV } - is_active: { type: boolean, example: true } - AssetCategoriesUpdateBody: - type: object - minProperties: 1 - properties: - code: { type: string, example: 'MCH' } - name: { type: string, example: 'Plant & Machinery' } - code_prefix: { type: string, example: 'MCH' } - default_useful_life_years: { type: integer, example: 15 } - default_depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: WDV } - is_active: { type: boolean, example: true } - AssetSubcategoriesCreateBody: - type: object - required: [asset_category_id, code, name] - properties: - asset_category_id: { type: integer, example: 1 } - code: { type: string, example: 'CNC' } - name: { type: string, example: 'CNC Machines' } - is_active: { type: boolean, example: true } - AssetSubcategoriesUpdateBody: - type: object - minProperties: 1 - properties: - asset_category_id: { type: integer, example: 1 } - code: { type: string, example: 'CNC' } - name: { type: string, example: 'CNC Machines' } - is_active: { type: boolean, example: true } DepartmentsCreateBody: type: object required: [name] @@ -1079,148 +1047,6 @@ paths: description: Deleted content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } "404": { description: Not found } - /masters/asset-categories: - get: - tags: [Asset Categories] - summary: List Asset Categories - parameters: - - in: query - name: page - schema: { type: integer, default: 1 } - - in: query - name: limit - schema: { type: integer, default: 20, maximum: 100 } - - in: query - name: search - schema: { type: string } - - in: query - name: is_active - schema: { type: boolean } - responses: - "200": - description: List fetched - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - post: - tags: [Asset Categories] - summary: Create Asset Categorie - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/AssetCategoriesCreateBody" } - responses: - "201": - description: Created - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "409": { description: Duplicate key } - /masters/asset-categories/{id}: - parameters: - - in: path - name: id - required: true - schema: { type: string } - get: - tags: [Asset Categories] - summary: Get Asset Categorie by id - responses: - "200": - description: Fetched - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "404": { description: Not found } - put: - tags: [Asset Categories] - summary: Update Asset Categorie - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/AssetCategoriesUpdateBody" } - responses: - "200": - description: Updated - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "404": { description: Not found } - delete: - tags: [Asset Categories] - summary: Delete Asset Categorie - responses: - "200": - description: Deleted - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "404": { description: Not found } - /masters/asset-subcategories: - get: - tags: [Asset Subcategories] - summary: List Asset Subcategories - parameters: - - in: query - name: page - schema: { type: integer, default: 1 } - - in: query - name: limit - schema: { type: integer, default: 20, maximum: 100 } - - in: query - name: search - schema: { type: string } - - in: query - name: is_active - schema: { type: boolean } - - in: query - name: asset_category_id - schema: { type: integer } - description: Filter by parent asset category - responses: - "200": - description: List fetched - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - post: - tags: [Asset Subcategories] - summary: Create Asset Subcategory - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/AssetSubcategoriesCreateBody" } - responses: - "201": - description: Created - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "409": { description: Duplicate key } - /masters/asset-subcategories/{id}: - parameters: - - in: path - name: id - required: true - schema: { type: string } - get: - tags: [Asset Subcategories] - summary: Get Asset Subcategory by id - responses: - "200": - description: Fetched - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "404": { description: Not found } - put: - tags: [Asset Subcategories] - summary: Update Asset Subcategory - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/AssetSubcategoriesUpdateBody" } - responses: - "200": - description: Updated - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "404": { description: Not found } - delete: - tags: [Asset Subcategories] - summary: Delete Asset Subcategory - responses: - "200": - description: Deleted - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } - "404": { description: Not found } /masters/departments: get: tags: [Departments] diff --git a/src/docs/grn-routes.yaml b/src/docs/grn-routes.yaml index 42332df..85e7799 100644 --- a/src/docs/grn-routes.yaml +++ b/src/docs/grn-routes.yaml @@ -19,8 +19,8 @@ components: expiry_date: { type: string, format: date, nullable: true } storage_location: { type: string, example: 'Rack A-12' } remarks: { type: string, example: 'Partial receipt' } - asset_category_id: { type: integer, nullable: true, example: 1, description: Required when PO item is an asset item } - asset_subcategory_id: { type: integer, nullable: true, example: 1, description: Required when PO item is an asset item } + item_category_id: { type: integer, nullable: true, example: 1, description: Optional override; defaults from PO item master when omitted } + item_subcategory_id: { type: integer, nullable: true, example: 1, description: Optional override; defaults from PO item master when omitted } GrnCreateBody: type: object required: [grn_date, po_id, warehouse_id, items] diff --git a/src/modules/assets/assets.helpers.js b/src/modules/assets/assets.helpers.js index 545f539..ec8d611 100644 --- a/src/modules/assets/assets.helpers.js +++ b/src/modules/assets/assets.helpers.js @@ -8,8 +8,8 @@ const SOFT_DELETE_TABLES = new Set([ 'users', 'purchase_orders', 'grn', - 'asset_categories', - 'asset_subcategories', + 'item_categories', + 'item_subcategories', ]); const toDateOnly = (value) => { diff --git a/src/modules/assets/assets.service.js b/src/modules/assets/assets.service.js index 29edd67..6778b45 100644 --- a/src/modules/assets/assets.service.js +++ b/src/modules/assets/assets.service.js @@ -14,8 +14,17 @@ const { const { assertReference, toDateOnly } = require('./assets.helpers'); const assetInclude = { - asset_categories: { select: { id: true, code: true, name: true, code_prefix: true } }, - asset_subcategories: { select: { id: true, code: true, name: true, asset_category_id: true } }, + item_categories: { + select: { + id: true, + code: true, + name: true, + code_prefix: true, + default_useful_life_years: true, + default_depreciation_method: true, + }, + }, + item_subcategories: { select: { id: true, code: true, name: true, item_category_id: true } }, plant: { select: { id: true, code: true, name: true } }, departments: { select: { id: true, name: true } }, warehouse: { select: { id: true, code: true, name: true } }, @@ -50,8 +59,8 @@ const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`; const sanitizeAsset = (asset) => { if (!asset) return null; const { - asset_categories, - asset_subcategories, + item_categories, + item_subcategories, plant, departments, warehouse, @@ -82,8 +91,8 @@ const sanitizeAsset = (asset) => { purchase_cost: purchaseCost, salvage_value: salvageValue, depreciation_rate: depreciationRate, - asset_category: asset_categories || null, - asset_subcategory: asset_subcategories || null, + item_category: item_categories || null, + item_subcategory: item_subcategories || null, plant: plant || null, department: departments || null, warehouse: warehouse || null, @@ -101,8 +110,8 @@ const sanitizeAsset = (asset) => { useful_life_years: rest.useful_life_years, purchase_date: rest.purchase_date, }), - asset_categories: undefined, - asset_subcategories: undefined, + item_categories: undefined, + item_subcategories: undefined, departments: undefined, users_assets_assigned_to_user_idTousers: undefined, vendors_assets_vendor_idTovendors: undefined, @@ -147,29 +156,29 @@ const assertDisposalFields = (status, disposalDate) => { } }; -const assertAssetSubcategory = async (subcategoryId, categoryId) => { +const assertItemSubcategory = async (subcategoryId, categoryId) => { const subcategory = await assertReference( - 'asset_subcategories', + 'item_subcategories', subcategoryId, - 'asset_subcategory_id' + 'item_subcategory_id' ); - if (subcategory.asset_category_id.toString() !== BigInt(categoryId).toString()) { - throw new ApiError(422, 'asset_subcategory_id does not belong to the selected asset_category_id'); + if (subcategory.item_category_id.toString() !== BigInt(categoryId).toString()) { + throw new ApiError(422, 'item_subcategory_id does not belong to the selected item_category_id'); } return subcategory; }; const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { const category = await assertReference( - 'asset_categories', - payload.asset_category_id, - 'asset_category_id' + 'item_categories', + payload.item_category_id, + 'item_category_id' ); - if (isCreate && !payload.asset_subcategory_id) { - throw new ApiError(422, 'asset_subcategory_id is required'); + if (isCreate && !payload.item_subcategory_id) { + throw new ApiError(422, 'item_subcategory_id is required'); } - if (payload.asset_subcategory_id) { - await assertAssetSubcategory(payload.asset_subcategory_id, payload.asset_category_id); + if (payload.item_subcategory_id) { + await assertItemSubcategory(payload.item_subcategory_id, payload.item_category_id); } await assertPlant(payload.plant_id, 'plant_id'); if (payload.department_id) @@ -212,9 +221,9 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { return { asset_name: payload.asset_name, - asset_category_id: BigInt(payload.asset_category_id), - asset_subcategory_id: payload.asset_subcategory_id - ? BigInt(payload.asset_subcategory_id) + item_category_id: BigInt(payload.item_category_id), + item_subcategory_id: payload.item_subcategory_id + ? BigInt(payload.item_subcategory_id) : null, brand_model: payload.brand_model || null, manufacturer: payload.manufacturer || null, @@ -294,9 +303,9 @@ const listAssets = async (query) => { deleted_at: null, ...(query.status ? { status: query.status } : {}), ...(query.condition ? { condition: query.condition } : {}), - ...(query.asset_category_id ? { asset_category_id: BigInt(query.asset_category_id) } : {}), - ...(query.asset_subcategory_id - ? { asset_subcategory_id: BigInt(query.asset_subcategory_id) } + ...(query.item_category_id ? { item_category_id: BigInt(query.item_category_id) } : {}), + ...(query.item_subcategory_id + ? { item_subcategory_id: BigInt(query.item_subcategory_id) } : {}), ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), ...(query.department_id ? { department_id: BigInt(query.department_id) } : {}), @@ -333,11 +342,11 @@ const updateAsset = async (id, payload, userId, requestId) => { const existing = await getAssetOrThrow(id); const merged = { asset_name: payload.asset_name ?? existing.asset_name, - asset_category_id: payload.asset_category_id ?? existing.asset_category_id, - asset_subcategory_id: - payload.asset_subcategory_id !== undefined - ? payload.asset_subcategory_id - : existing.asset_subcategory_id, + item_category_id: payload.item_category_id ?? existing.item_category_id, + item_subcategory_id: + payload.item_subcategory_id !== undefined + ? payload.item_subcategory_id + : existing.item_subcategory_id, brand_model: payload.brand_model !== undefined ? payload.brand_model : existing.brand_model, manufacturer: payload.manufacturer !== undefined ? payload.manufacturer : existing.manufacturer, serial_number: diff --git a/src/modules/assets/assets.validation.js b/src/modules/assets/assets.validation.js index f3732ad..9574386 100644 --- a/src/modules/assets/assets.validation.js +++ b/src/modules/assets/assets.validation.js @@ -16,8 +16,8 @@ const { const assetFields = { asset_name: Joi.string().max(200).required(), - asset_category_id: Joi.number().integer().positive().required(), - asset_subcategory_id: Joi.number().integer().positive().required(), + item_category_id: Joi.number().integer().positive().required(), + item_subcategory_id: Joi.number().integer().positive().required(), brand_model: Joi.string().max(200).allow(null, '').optional(), manufacturer: Joi.string().max(200).allow(null, '').optional(), serial_number: Joi.string().max(100).allow(null, '').optional(), @@ -59,8 +59,8 @@ const createAssetSchema = Joi.object(assetFields); const updateAssetSchema = Joi.object({ asset_name: assetFields.asset_name.optional(), - asset_category_id: assetFields.asset_category_id.optional(), - asset_subcategory_id: assetFields.asset_subcategory_id.optional(), + item_category_id: assetFields.item_category_id.optional(), + item_subcategory_id: assetFields.item_subcategory_id.optional(), brand_model: assetFields.brand_model, manufacturer: assetFields.manufacturer, serial_number: assetFields.serial_number, @@ -101,8 +101,8 @@ const listAssetsQuerySchema = Joi.object({ condition: Joi.string() .valid(...ASSET_CONDITIONS) .optional(), - asset_category_id: Joi.number().integer().positive().optional(), - asset_subcategory_id: Joi.number().integer().positive().optional(), + item_category_id: Joi.number().integer().positive().optional(), + item_subcategory_id: Joi.number().integer().positive().optional(), plant_id: Joi.number().integer().positive().optional(), department_id: Joi.number().integer().positive().optional(), is_active: Joi.boolean().optional(), diff --git a/src/modules/grn/grn.repository.js b/src/modules/grn/grn.repository.js index 84758ff..a4df026 100644 --- a/src/modules/grn/grn.repository.js +++ b/src/modules/grn/grn.repository.js @@ -4,7 +4,7 @@ const { recalculatePoStatus } = require('./grn.poStatus'); const createAssetsForLine = async ( tx, - { grn, grnItem, item, assetCategory, assetSubcategory, plantId, assetCodes, userId } + { grn, grnItem, item, itemCategory, itemSubcategory, plantId, assetCodes, userId } ) => { const createdAssets = []; @@ -13,8 +13,8 @@ const createAssetsForLine = async ( data: { asset_code: assetCodes[index], asset_name: assetCodes.length > 1 ? `${item.item_name} #${index + 1}` : item.item_name, - asset_category_id: assetCategory.id, - asset_subcategory_id: assetSubcategory.id, + item_category_id: itemCategory.id, + item_subcategory_id: itemSubcategory.id, plant_id: BigInt(plantId), warehouse_id: grn.warehouse_id, vendor_id: grn.vendor_id, @@ -23,8 +23,8 @@ const createAssetsForLine = async ( grn_item_id: grnItem.id, purchase_date: grn.grn_date, purchase_cost: grnItem.rate, - useful_life_years: assetCategory.default_useful_life_years, - depreciation_method: assetCategory.default_depreciation_method, + useful_life_years: itemCategory.default_useful_life_years, + depreciation_method: itemCategory.default_depreciation_method, condition: 'NEW', status: 'IN_USE', created_by: userId ? BigInt(userId) : null, @@ -67,8 +67,8 @@ const createGrnWithReceipt = async ({ grnNumber, header, items, assetPlans, user grn, grnItem, item: plan.item, - assetCategory: plan.assetCategory, - assetSubcategory: plan.assetSubcategory, + itemCategory: plan.itemCategory, + itemSubcategory: plan.itemSubcategory, plantId: plan.plantId, assetCodes: plan.assetCodes, userId, diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js index 57cbcea..ea3aac7 100644 --- a/src/modules/grn/grn.service.js +++ b/src/modules/grn/grn.service.js @@ -108,6 +108,8 @@ const getReceivablePoOrThrow = async (poId) => { item_code: true, item_name: true, is_asset_item: true, + item_category_id: true, + item_subcategory_id: true, }, }, }, @@ -175,48 +177,51 @@ const validateAndBuildItems = async (po, payloadItems) => { const item = poItem.items; if (item.is_asset_item && acceptedQty > 0) { - if (!row.asset_category_id) { + const categoryId = row.item_category_id ?? item.item_category_id; + const subcategoryId = row.item_subcategory_id ?? item.item_subcategory_id; + + if (!categoryId) { throw new ApiError( 422, - `Line ${row.line_no}: asset_category_id is required for asset items` + `Line ${row.line_no}: item_category_id is required for asset items (set on item master or GRN line)` ); } - if (!row.asset_subcategory_id) { + if (!subcategoryId) { throw new ApiError( 422, - `Line ${row.line_no}: asset_subcategory_id is required for asset items` + `Line ${row.line_no}: item_subcategory_id is required for asset items (set on item master or GRN line)` ); } - const assetCategory = await prisma.asset_categories.findFirst({ - where: { id: BigInt(row.asset_category_id), deleted_at: null, is_active: true }, + const itemCategory = await prisma.item_categories.findFirst({ + where: { id: BigInt(categoryId), deleted_at: null, is_active: true }, }); - if (!assetCategory) throw new ApiError(422, `Line ${row.line_no}: invalid asset_category_id`); + if (!itemCategory) throw new ApiError(422, `Line ${row.line_no}: invalid item_category_id`); - const assetSubcategory = await prisma.asset_subcategories.findFirst({ - where: { id: BigInt(row.asset_subcategory_id), deleted_at: null, is_active: true }, + const itemSubcategory = await prisma.item_subcategories.findFirst({ + where: { id: BigInt(subcategoryId), deleted_at: null, is_active: true }, }); - if (!assetSubcategory) { - throw new ApiError(422, `Line ${row.line_no}: invalid asset_subcategory_id`); + if (!itemSubcategory) { + throw new ApiError(422, `Line ${row.line_no}: invalid item_subcategory_id`); } - if (assetSubcategory.asset_category_id.toString() !== assetCategory.id.toString()) { + if (itemSubcategory.item_category_id.toString() !== itemCategory.id.toString()) { throw new ApiError( 422, - `Line ${row.line_no}: asset_subcategory_id does not belong to asset_category_id` + `Line ${row.line_no}: item_subcategory_id does not belong to item_category_id` ); } const units = Math.floor(acceptedQty); const assetCodes = []; for (let unit = 0; unit < units; unit += 1) { - assetCodes.push(await nextDocumentNumber(assetSeriesCode(assetCategory.code))); + assetCodes.push(await nextDocumentNumber(assetSeriesCode(itemCategory.code))); } assetPlans.push({ po_item_id: poItem.id, item, - assetCategory, - assetSubcategory, + itemCategory, + itemSubcategory, plantId: po.plant_id, assetCodes, }); diff --git a/src/modules/grn/grn.validation.js b/src/modules/grn/grn.validation.js index a03474a..3f7db54 100644 --- a/src/modules/grn/grn.validation.js +++ b/src/modules/grn/grn.validation.js @@ -14,8 +14,8 @@ const grnItemSchema = Joi.object({ expiry_date: Joi.date().iso().allow(null).optional(), storage_location: Joi.string().max(100).allow(null, '').optional(), remarks: Joi.string().allow(null, '').optional(), - asset_category_id: Joi.number().integer().positive().allow(null).optional(), - asset_subcategory_id: Joi.number().integer().positive().allow(null).optional(), + item_category_id: Joi.number().integer().positive().allow(null).optional(), + item_subcategory_id: Joi.number().integer().positive().allow(null).optional(), }); const createGrnSchema = Joi.object({ diff --git a/src/modules/masters/asset-categories/asset-categories.controller.js b/src/modules/masters/asset-categories/asset-categories.controller.js deleted file mode 100644 index f307ab4..0000000 --- a/src/modules/masters/asset-categories/asset-categories.controller.js +++ /dev/null @@ -1,30 +0,0 @@ -const asyncHandler = require('../../../utils/asyncHandler'); -const ApiResponse = require('../../../utils/ApiResponse'); -const service = require('./asset-categories.service'); - -const create = asyncHandler(async (req, res) => { - const data = await service.createAssetCategories(req.body, req.user?.id, req.id); - res.status(201).json(new ApiResponse(201, data, 'asset_categories created successfully')); -}); - -const list = asyncHandler(async (req, res) => { - const result = await service.listAssetCategories(req.query); - res.json(new ApiResponse(200, result.data, 'asset_categories list fetched', result.meta)); -}); - -const getOne = asyncHandler(async (req, res) => { - const data = await service.getAssetCategoriesById(req.params.id); - res.json(new ApiResponse(200, data, 'asset_categories fetched')); -}); - -const update = asyncHandler(async (req, res) => { - const data = await service.updateAssetCategories(req.params.id, req.body, req.user?.id, req.id); - res.json(new ApiResponse(200, data, 'asset_categories updated successfully')); -}); - -const remove = asyncHandler(async (req, res) => { - await service.deleteAssetCategories(req.params.id, req.user?.id, req.id); - res.json(new ApiResponse(200, null, 'asset_categories deleted successfully')); -}); - -module.exports = { create, list, getOne, update, remove }; diff --git a/src/modules/masters/asset-categories/asset-categories.routes.js b/src/modules/masters/asset-categories/asset-categories.routes.js deleted file mode 100644 index 38e5684..0000000 --- a/src/modules/masters/asset-categories/asset-categories.routes.js +++ /dev/null @@ -1,17 +0,0 @@ -const express = require('express'); -const authenticate = require('../../../middlewares/auth.middleware'); -const authorize = require('../../../middlewares/rbac.middleware'); -const validate = require('../../../middlewares/validate.middleware'); -const controller = require('./asset-categories.controller'); -const { createSchema, updateSchema, listQuerySchema } = require('./asset-categories.validation'); - -const router = express.Router(); - -router.use(authenticate); -router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list); -router.get('/:id', authorize('MASTERS', 'view'), controller.getOne); -router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create); -router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update); -router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove); - -module.exports = router; diff --git a/src/modules/masters/asset-categories/asset-categories.service.js b/src/modules/masters/asset-categories/asset-categories.service.js deleted file mode 100644 index eafcd5f..0000000 --- a/src/modules/masters/asset-categories/asset-categories.service.js +++ /dev/null @@ -1,68 +0,0 @@ -const { buildMasterService } = require('../_shared/master.factory'); - -const config = { - modelName: 'asset_categories', - tableName: 'asset_categories', - uniqueField: 'code', - softDelete: true, - fields: [ - { - name: 'code', - type: 'string', - uppercase: true, - searchable: true, - }, - { - name: 'name', - type: 'string', - uppercase: false, - searchable: true, - }, - { - name: 'code_prefix', - type: 'string', - uppercase: true, - searchable: false, - }, - { - name: 'default_useful_life_years', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'default_depreciation_method', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'is_active', - type: 'boolean', - uppercase: false, - searchable: false, - }, - { - name: 'created_by', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'updated_by', - type: 'int', - uppercase: false, - searchable: false, - }, - ], -}; - -const service = buildMasterService(config); - -module.exports = { - createAssetCategories: service.createOne, - listAssetCategories: service.list, - getAssetCategoriesById: service.getOne, - updateAssetCategories: service.updateOne, - deleteAssetCategories: service.removeOne, -}; diff --git a/src/modules/masters/asset-categories/asset-categories.validation.js b/src/modules/masters/asset-categories/asset-categories.validation.js deleted file mode 100644 index 7e34fe6..0000000 --- a/src/modules/masters/asset-categories/asset-categories.validation.js +++ /dev/null @@ -1,29 +0,0 @@ -const Joi = require('joi'); -const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation'); -const { DEPRECIATION_METHODS } = require('../../assets/assets.constants'); - -const createSchema = Joi.object({ - code: masterCode({ max: 30 }), - name: masterName({ max: 150 }), - code_prefix: masterCode({ max: 20 }), - default_useful_life_years: Joi.number().integer().optional(), - default_depreciation_method: Joi.string() - .valid(...DEPRECIATION_METHODS) - .allow(null, '') - .optional(), - is_active: Joi.boolean().optional(), -}); - -const updateSchema = Joi.object({ - code: masterCode({ max: 30 }), - name: masterName({ max: 150 }), - code_prefix: masterCode({ max: 20 }), - default_useful_life_years: Joi.number().integer().optional(), - default_depreciation_method: Joi.string() - .valid(...DEPRECIATION_METHODS) - .allow(null, '') - .optional(), - is_active: Joi.boolean().optional(), -}).min(1); - -module.exports = { createSchema, updateSchema, listQuerySchema }; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.controller.js b/src/modules/masters/asset-subcategories/asset-subcategories.controller.js deleted file mode 100644 index 6308aab..0000000 --- a/src/modules/masters/asset-subcategories/asset-subcategories.controller.js +++ /dev/null @@ -1,35 +0,0 @@ -const asyncHandler = require('../../../utils/asyncHandler'); -const ApiResponse = require('../../../utils/ApiResponse'); -const service = require('./asset-subcategories.service'); - -const create = asyncHandler(async (req, res) => { - const data = await service.createAssetSubcategories(req.body, req.user?.id, req.id); - res.status(201).json(new ApiResponse(201, data, 'asset_subcategories created successfully')); -}); - -const list = asyncHandler(async (req, res) => { - const result = await service.listAssetSubcategories(req.query); - res.json(new ApiResponse(200, result.data, 'asset_subcategories list fetched', result.meta)); -}); - -const getOne = asyncHandler(async (req, res) => { - const data = await service.getAssetSubcategoriesById(req.params.id); - res.json(new ApiResponse(200, data, 'asset_subcategories fetched')); -}); - -const update = asyncHandler(async (req, res) => { - const data = await service.updateAssetSubcategories( - req.params.id, - req.body, - req.user?.id, - req.id - ); - res.json(new ApiResponse(200, data, 'asset_subcategories updated successfully')); -}); - -const remove = asyncHandler(async (req, res) => { - await service.deleteAssetSubcategories(req.params.id, req.user?.id, req.id); - res.json(new ApiResponse(200, null, 'asset_subcategories deleted successfully')); -}); - -module.exports = { create, list, getOne, update, remove }; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.routes.js b/src/modules/masters/asset-subcategories/asset-subcategories.routes.js deleted file mode 100644 index 7c5dad1..0000000 --- a/src/modules/masters/asset-subcategories/asset-subcategories.routes.js +++ /dev/null @@ -1,17 +0,0 @@ -const express = require('express'); -const authenticate = require('../../../middlewares/auth.middleware'); -const authorize = require('../../../middlewares/rbac.middleware'); -const validate = require('../../../middlewares/validate.middleware'); -const controller = require('./asset-subcategories.controller'); -const { createSchema, updateSchema, listQuerySchema } = require('./asset-subcategories.validation'); - -const router = express.Router(); - -router.use(authenticate); -router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list); -router.get('/:id', authorize('MASTERS', 'view'), controller.getOne); -router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create); -router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update); -router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove); - -module.exports = router; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.service.js b/src/modules/masters/asset-subcategories/asset-subcategories.service.js deleted file mode 100644 index cf15f93..0000000 --- a/src/modules/masters/asset-subcategories/asset-subcategories.service.js +++ /dev/null @@ -1,158 +0,0 @@ -const prisma = require('../../../config/prisma'); -const ApiError = require('../../../utils/ApiError'); -const { getPagination } = require('../../../utils/pagination'); -const { buildMasterService } = require('../_shared/master.factory'); - -const config = { - modelName: 'asset_subcategories', - tableName: 'asset_subcategories', - uniqueField: 'code', - softDelete: true, - fields: [ - { - name: 'asset_category_id', - type: 'int', - asBigInt: true, - filterable: true, - uppercase: false, - searchable: false, - }, - { - name: 'code', - type: 'string', - uppercase: true, - searchable: true, - }, - { - name: 'name', - type: 'string', - uppercase: false, - searchable: true, - }, - { - name: 'is_active', - type: 'boolean', - uppercase: false, - searchable: false, - }, - { - name: 'created_by', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'updated_by', - type: 'int', - uppercase: false, - searchable: false, - }, - ], -}; - -const service = buildMasterService(config); - -const categoryInclude = { - asset_categories: { select: { id: true, code: true, name: true } }, -}; - -const sanitizeSubcategory = (row) => { - if (!row) return null; - const { asset_categories, ...rest } = row; - return { - ...rest, - asset_category: asset_categories || null, - asset_category_name: asset_categories?.name || null, - asset_categories: undefined, - }; -}; - -const assertAssetCategory = async (assetCategoryId) => { - const category = await prisma.asset_categories.findFirst({ - where: { id: BigInt(assetCategoryId), deleted_at: null }, - }); - if (!category) throw new ApiError(422, 'Invalid asset_category_id'); - if (category.is_active === false) throw new ApiError(422, 'asset_category_id is inactive'); - return category; -}; - -const withCategory = async (row) => { - if (!row) return null; - const full = await prisma.asset_subcategories.findFirst({ - where: { id: row.id }, - include: categoryInclude, - }); - return sanitizeSubcategory(full || row); -}; - -const listAssetSubcategories = async (query) => { - const { page, limit, skip } = getPagination(query); - - const where = { - deleted_at: null, - ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), - ...(query.asset_category_id - ? { asset_category_id: BigInt(query.asset_category_id) } - : {}), - ...(query.search - ? { - OR: [ - { code: { contains: query.search, mode: 'insensitive' } }, - { name: { contains: query.search, mode: 'insensitive' } }, - { - asset_categories: { - name: { contains: query.search, mode: 'insensitive' }, - }, - }, - ], - } - : {}), - }; - - const [rows, total] = await Promise.all([ - prisma.asset_subcategories.findMany({ - where, - include: categoryInclude, - orderBy: { created_at: 'desc' }, - skip, - take: limit, - }), - prisma.asset_subcategories.count({ where }), - ]); - - return { - data: rows.map(sanitizeSubcategory), - meta: { page, limit, total }, - }; -}; - -const getAssetSubcategoriesById = async (id) => { - const row = await prisma.asset_subcategories.findFirst({ - where: { id: BigInt(id), deleted_at: null }, - include: categoryInclude, - }); - if (!row) throw new ApiError(404, 'asset_subcategories not found'); - return sanitizeSubcategory(row); -}; - -const createAssetSubcategories = async (payload, userId, requestId) => { - await assertAssetCategory(payload.asset_category_id); - const created = await service.createOne(payload, userId, requestId); - return withCategory(created); -}; - -const updateAssetSubcategories = async (id, payload, userId, requestId) => { - if (payload.asset_category_id !== undefined) { - await assertAssetCategory(payload.asset_category_id); - } - const updated = await service.updateOne(id, payload, userId, requestId); - return withCategory(updated); -}; - -module.exports = { - createAssetSubcategories, - listAssetSubcategories, - getAssetSubcategoriesById, - updateAssetSubcategories, - deleteAssetSubcategories: service.removeOne, -}; diff --git a/src/modules/masters/asset-subcategories/asset-subcategories.validation.js b/src/modules/masters/asset-subcategories/asset-subcategories.validation.js deleted file mode 100644 index 480dc26..0000000 --- a/src/modules/masters/asset-subcategories/asset-subcategories.validation.js +++ /dev/null @@ -1,26 +0,0 @@ -const Joi = require('joi'); -const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation'); - -const createSchema = Joi.object({ - asset_category_id: Joi.number().integer().positive().required(), - code: masterCode({ max: 30, required: true }), - name: masterName({ max: 150, required: true }), - is_active: Joi.boolean().optional(), -}); - -const updateSchema = Joi.object({ - asset_category_id: Joi.number().integer().positive().optional(), - code: masterCode({ max: 30 }), - name: masterName({ max: 150 }), - is_active: Joi.boolean().optional(), -}).min(1); - -const listAssetSubcategoriesQuerySchema = listQuerySchema.keys({ - asset_category_id: Joi.number().integer().positive().optional(), -}); - -module.exports = { - createSchema, - updateSchema, - listQuerySchema: listAssetSubcategoriesQuerySchema, -}; diff --git a/src/modules/masters/index.js b/src/modules/masters/index.js index 62e0ab1..3f202e3 100644 --- a/src/modules/masters/index.js +++ b/src/modules/masters/index.js @@ -8,8 +8,6 @@ router.use('/brands', require('./brands/brands.routes')); router.use('/gst-rates', require('./gst-rates/gst-rates.routes')); router.use('/payment-terms', require('./payment-terms/payment-terms.routes')); router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes')); -router.use('/asset-categories', require('./asset-categories/asset-categories.routes')); -router.use('/asset-subcategories', require('./asset-subcategories/asset-subcategories.routes')); router.use('/departments', require('./departments/departments.routes')); router.use('/designations', require('./designations/designations.routes')); router.use('/document-series', require('./document-series/document-series.routes')); diff --git a/src/modules/masters/item-categories/item-categories.service.js b/src/modules/masters/item-categories/item-categories.service.js index 10844ad..daa0318 100644 --- a/src/modules/masters/item-categories/item-categories.service.js +++ b/src/modules/masters/item-categories/item-categories.service.js @@ -1,5 +1,7 @@ const { buildMasterService } = require('../_shared/master.factory'); +const emptyToNull = (value) => (value === '' ? null : value); + const config = { modelName: 'item_categories', tableName: 'item_categories', @@ -18,6 +20,24 @@ const config = { uppercase: false, searchable: true, }, + { + name: 'code_prefix', + type: 'string', + uppercase: true, + searchable: false, + }, + { + name: 'default_useful_life_years', + type: 'int', + uppercase: false, + searchable: false, + }, + { + name: 'default_depreciation_method', + type: 'string', + uppercase: false, + searchable: false, + }, { name: 'is_active', type: 'boolean', @@ -41,10 +61,24 @@ const config = { const service = buildMasterService(config); +const normalizeAssetDefaults = (payload) => { + if (!payload || typeof payload !== 'object') return payload; + const out = { ...payload }; + if (Object.prototype.hasOwnProperty.call(out, 'code_prefix')) { + out.code_prefix = emptyToNull(out.code_prefix); + } + if (Object.prototype.hasOwnProperty.call(out, 'default_depreciation_method')) { + out.default_depreciation_method = emptyToNull(out.default_depreciation_method); + } + return out; +}; + module.exports = { - createItemCategories: service.createOne, + createItemCategories: (payload, userId, requestId) => + service.createOne(normalizeAssetDefaults(payload), userId, requestId), listItemCategories: service.list, getItemCategoriesById: service.getOne, - updateItemCategories: service.updateOne, + updateItemCategories: (id, payload, userId, requestId) => + service.updateOne(id, normalizeAssetDefaults(payload), userId, requestId), deleteItemCategories: service.removeOne, }; diff --git a/src/modules/masters/item-categories/item-categories.validation.js b/src/modules/masters/item-categories/item-categories.validation.js index c90ee1a..84f3e48 100644 --- a/src/modules/masters/item-categories/item-categories.validation.js +++ b/src/modules/masters/item-categories/item-categories.validation.js @@ -1,15 +1,28 @@ const Joi = require('joi'); const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation'); +const { DEPRECIATION_METHODS } = require('../../assets/assets.constants'); const createSchema = Joi.object({ code: masterCode({ max: 30 }), name: masterName({ max: 150 }), + code_prefix: masterCode({ max: 10 }).allow(null, ''), + default_useful_life_years: Joi.number().integer().min(0).allow(null).optional(), + default_depreciation_method: Joi.string() + .valid(...DEPRECIATION_METHODS) + .allow(null, '') + .optional(), is_active: Joi.boolean().optional(), }); const updateSchema = Joi.object({ code: masterCode({ max: 30 }), name: masterName({ max: 150 }), + code_prefix: masterCode({ max: 10 }).allow(null, ''), + default_useful_life_years: Joi.number().integer().min(0).allow(null).optional(), + default_depreciation_method: Joi.string() + .valid(...DEPRECIATION_METHODS) + .allow(null, '') + .optional(), is_active: Joi.boolean().optional(), }).min(1);