Asset category masters removed and items category mapped to assets

This commit is contained in:
Gowtham M 2026-07-10 10:47:24 +05:30
parent 917917ad29
commit 4bab5f8859
27 changed files with 702 additions and 899 deletions

View File

@ -78,7 +78,6 @@ erp-backend/
│ │ │ ├── warehouses/ │ │ │ ├── warehouses/
│ │ │ ├── payment-terms/ │ │ │ ├── payment-terms/
│ │ │ ├── delivery-terms/ │ │ │ ├── delivery-terms/
│ │ │ ├── asset-categories/
│ │ │ ├── departments/ │ │ │ ├── departments/
│ │ │ ├── designations/ │ │ │ ├── designations/
│ │ │ ├── plants/ │ │ │ ├── plants/
@ -1180,7 +1179,7 @@ module.exports = router;
### 9.4 Masters module pattern ### 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: `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('/warehouses', require('./warehouses/warehouses.routes'));
router.use('/payment-terms', require('./payment-terms/payment-terms.routes')); router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-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('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes')); router.use('/designations', require('./designations/designations.routes'));
router.use('/plants', require('./plants/plants.routes')); router.use('/plants', require('./plants/plants.routes'));
@ -1276,7 +1274,6 @@ BigInt.prototype.toJSON = function () {
/api/v1/masters/warehouses /api/v1/masters/warehouses
/api/v1/masters/payment-terms /api/v1/masters/payment-terms
/api/v1/masters/delivery-terms /api/v1/masters/delivery-terms
/api/v1/masters/asset-categories
/api/v1/masters/departments /api/v1/masters/departments
/api/v1/masters/designations /api/v1/masters/designations
/api/v1/masters/plants /api/v1/masters/plants

View File

@ -162,15 +162,15 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Status | Sub-master | Base path | | Status | Sub-master | Base path |
|--------|------------|-----------| |--------|------------|-----------|
| [x] | UOM | `/masters/uom` | | [x] | UOM | `/masters/uom` |
| [x] | Item Categories | `/masters/item-categories` | | [x] | Item Categories | `/masters/item-categories` | Optional asset defaults: `code_prefix`, `default_useful_life_years`, `default_depreciation_method` |
| [x] | Item Subcategories | `/masters/item-subcategories` | | [x] | Item Subcategories | `/masters/item-subcategories` | Filter: `item_category_id` — shared by items + assets |
| [x] | Items | `/masters/items` | | [x] | Items | `/masters/items` |
| [x] | Brands | `/masters/brands` | | [x] | Brands | `/masters/brands` |
| [x] | GST Rates | `/masters/gst-rates` | | [x] | GST Rates | `/masters/gst-rates` |
| [x] | Payment Terms | `/masters/payment-terms` | | [x] | Payment Terms | `/masters/payment-terms` |
| [x] | Delivery Terms | `/masters/delivery-terms` | | [x] | Delivery Terms | `/masters/delivery-terms` |
| [x] | Asset Categories | `/masters/asset-categories` | | [~] | Asset Categories | removed — use Item Categories |
| [x] | Asset Subcategories | `/masters/asset-subcategories` | Filter: `asset_category_id` | | [~] | Asset Subcategories | removed — use Item Subcategories |
| [x] | Departments | `/masters/departments` | | [x] | Departments | `/masters/departments` |
| [x] | Designations | `/masters/designations` | | [x] | Designations | `/masters/designations` |
| [x] | Locations | `/masters/locations` | Unified plants + warehouses (`type`: `plant` \| `warehouse`) | | [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] | Warehouses | `/masters/warehouses` | Alias — warehouse locations only |
| [x] | Document Series | `/masters/document-series` | | [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
--- ---

View File

@ -210,27 +210,36 @@
} }
.diagram-viewport { .diagram-viewport {
overflow: auto; position: relative;
max-height: 70vh; overflow: hidden;
padding: 1rem; height: min(70vh, 560px);
cursor: grab; min-height: 280px;
background: #fafbfc; background: #fafbfc;
touch-action: none;
user-select: none;
} }
.diagram-viewport.can-pan { cursor: grab; }
.diagram-viewport.is-panning { cursor: grabbing; } .diagram-viewport.is-panning { cursor: grabbing; }
.diagram-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.diagram-canvas { .diagram-canvas {
display: inline-block; display: inline-block;
min-width: 100%;
transform-origin: 0 0; transform-origin: 0 0;
transition: transform 0.12s ease-out; will-change: transform;
} }
.diagram-canvas .mermaid, .diagram-canvas .mermaid,
.diagram-canvas svg { .diagram-canvas svg {
display: block; display: block;
margin: 0 auto; margin: 0;
max-width: none !important; max-width: none !important;
pointer-events: none;
} }
.diagram-fullscreen { .diagram-fullscreen {
@ -302,25 +311,36 @@
.diagram-fullscreen-viewport { .diagram-fullscreen-viewport {
flex: 1; flex: 1;
overflow: auto; position: relative;
padding: 2rem; overflow: hidden;
cursor: grab; background: #0f172a;
display: flex; touch-action: none;
align-items: flex-start; user-select: none;
justify-content: center;
} }
.diagram-fullscreen-viewport.can-pan { cursor: grab; }
.diagram-fullscreen-viewport.is-panning { cursor: grabbing; } .diagram-fullscreen-viewport.is-panning { cursor: grabbing; }
.diagram-fullscreen-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.diagram-fullscreen-canvas { .diagram-fullscreen-canvas {
display: inline-block; display: inline-block;
transform-origin: center center; transform-origin: 0 0;
will-change: transform;
background: var(--surface); background: var(--surface);
border-radius: 8px; border-radius: 8px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.35); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.35);
} }
.diagram-fullscreen-canvas svg {
pointer-events: none;
}
.diagram-fullscreen-hint { .diagram-fullscreen-hint {
position: absolute; position: absolute;
bottom: 1rem; bottom: 1rem;
@ -357,7 +377,9 @@
main { margin-left: 0; max-width: 100%; } main { margin-left: 0; max-width: 100%; }
.diagram { break-inside: avoid; } .diagram { break-inside: avoid; }
.diagram-toolbar { display: none; } .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; }
} }
</style> </style>
</head> </head>
@ -462,7 +484,7 @@ flowchart LR
M2[item-categories / subcategories / items] M2[item-categories / subcategories / items]
M3[brands / gst-rates] M3[brands / gst-rates]
M4[payment-terms / delivery-terms] M4[payment-terms / delivery-terms]
M5[asset-categories / subcategories] M5[item-categories / subcategories]
M6[departments / designations] M6[departments / designations]
M7[locations / plants / warehouses] M7[locations / plants / warehouses]
M8[document-series] M8[document-series]
@ -565,7 +587,10 @@ flowchart TD
PT --> PO PT --> PO
DT[delivery_terms] --> 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 --> ASSET
ACAT --> GRN_AUTO[GRN auto-asset creation] ACAT --> GRN_AUTO[GRN auto-asset creation]
ASUB --> ASSET ASUB --> ASSET
@ -646,12 +671,12 @@ stateDiagram-v2
<tbody> <tbody>
<tr><td><code>asset_code</code></td><td><code>document_series</code> via <code>ASSET_{category.code}</code></td></tr> <tr><td><code>asset_code</code></td><td><code>document_series</code> via <code>ASSET_{category.code}</code></td></tr>
<tr><td><code>asset_name</code></td><td>Item name (+ <code>#N</code> if qty &gt; 1)</td></tr> <tr><td><code>asset_name</code></td><td>Item name (+ <code>#N</code> if qty &gt; 1)</td></tr>
<tr><td><code>asset_category_id</code> / <code>asset_subcategory_id</code></td><td>From GRN line payload</td></tr> <tr><td><code>item_category_id</code> / <code>item_subcategory_id</code></td><td>From item master (optional GRN line override)</td></tr>
<tr><td><code>plant_id</code></td><td>From PO</td></tr> <tr><td><code>plant_id</code></td><td>From PO</td></tr>
<tr><td><code>warehouse_id</code>, <code>vendor_id</code>, <code>po_id</code>, <code>grn_id</code>, <code>grn_item_id</code></td><td>From GRN</td></tr> <tr><td><code>warehouse_id</code>, <code>vendor_id</code>, <code>po_id</code>, <code>grn_id</code>, <code>grn_item_id</code></td><td>From GRN</td></tr>
<tr><td><code>purchase_date</code></td><td><code>grn.grn_date</code></td></tr> <tr><td><code>purchase_date</code></td><td><code>grn.grn_date</code></td></tr>
<tr><td><code>purchase_cost</code></td><td><code>grn_items.rate</code></td></tr> <tr><td><code>purchase_cost</code></td><td><code>grn_items.rate</code></td></tr>
<tr><td><code>useful_life_years</code>, <code>depreciation_method</code></td><td>From <code>asset_categories</code> defaults</td></tr> <tr><td><code>useful_life_years</code>, <code>depreciation_method</code></td><td>From <code>item_categories</code> defaults</td></tr>
<tr><td><code>condition</code></td><td><code>NEW</code></td></tr> <tr><td><code>condition</code></td><td><code>NEW</code></td></tr>
<tr><td><code>status</code></td><td><code>IN_USE</code></td></tr> <tr><td><code>status</code></td><td><code>IN_USE</code></td></tr>
</tbody> </tbody>
@ -667,7 +692,7 @@ flowchart TB
end end
subgraph Validate["normalizeAssetPayload validations"] subgraph Validate["normalizeAssetPayload validations"]
V1[asset_category + subcategory match] V1[item_category + subcategory match]
V2[plant / dept / warehouse / user refs] V2[plant / dept / warehouse / user refs]
V3[vendor / PO / GRN / grn_item refs] V3[vendor / PO / GRN / grn_item refs]
V4[disposal_date required if DISPOSED/SCRAPPED] V4[disposal_date required if DISPOSED/SCRAPPED]
@ -801,7 +826,7 @@ flowchart TD
<pre class="mermaid"> <pre class="mermaid">
flowchart TD flowchart TD
subgraph Inputs subgraph Inputs
CAT[asset_categories defaults] CAT[item_categories defaults]
FORM[User form fields] FORM[User form fields]
end end
@ -951,9 +976,11 @@ erDiagram
items ||--o{ purchase_order_items : ordered items ||--o{ purchase_order_items : ordered
items ||--o{ grn_items : received items ||--o{ grn_items : received
asset_categories ||--o{ asset_subcategories : has item_categories ||--o{ item_subcategories : has
asset_categories ||--o{ assets : classifies item_categories ||--o{ items : classifies
asset_subcategories ||--o{ assets : sub_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_amc_contracts : has
assets ||--o{ asset_insurance_policies : insured_by assets ||--o{ asset_insurance_policies : insured_by
@ -1039,15 +1066,17 @@ erDiagram
<button type="button" class="diagram-fullscreen-close" id="diagram-fullscreen-close" title="Exit fullscreen (Esc)">×</button> <button type="button" class="diagram-fullscreen-close" id="diagram-fullscreen-close" title="Exit fullscreen (Esc)">×</button>
</div> </div>
<div class="diagram-fullscreen-viewport" id="diagram-fullscreen-viewport"> <div class="diagram-fullscreen-viewport" id="diagram-fullscreen-viewport">
<div class="diagram-fullscreen-canvas" id="diagram-fullscreen-canvas"></div> <div class="diagram-fullscreen-stage" id="diagram-fullscreen-stage">
<div class="diagram-fullscreen-canvas" id="diagram-fullscreen-canvas"></div>
</div>
</div> </div>
<div class="diagram-fullscreen-hint">Fullscreen: scroll to zoom · drag to pan · + / / 0 keys · Esc to exit</div> <div class="diagram-fullscreen-hint">Drag to move · Scroll or Ctrl+scroll to zoom · + / / 0 keys · Esc to exit</div>
</div> </div>
<script> <script>
const ZOOM_MIN = 0.25; const ZOOM_MIN = 0.2;
const ZOOM_MAX = 4; const ZOOM_MAX = 5;
const ZOOM_STEP = 0.2; const ZOOM_STEP = 0.15;
const ZOOM_DEFAULT = 1; const ZOOM_DEFAULT = 1;
function clampZoom(value) { function clampZoom(value) {
@ -1061,7 +1090,8 @@ erDiagram
<button type="button" data-action="zoom-out" title="Zoom out (-)"></button> <button type="button" data-action="zoom-out" title="Zoom out (-)"></button>
<span class="diagram-zoom-label" data-zoom-label>100%</span> <span class="diagram-zoom-label" data-zoom-label>100%</span>
<button type="button" data-action="zoom-in" title="Zoom in (+)">+</button> <button type="button" data-action="zoom-in" title="Zoom in (+)">+</button>
<button type="button" data-action="zoom-reset" title="Reset zoom (0)">Reset</button> <button type="button" data-action="zoom-reset" title="Reset view (0)">Reset</button>
<button type="button" data-action="zoom-fit" title="Fit to view">Fit</button>
<span class="spacer"></span> <span class="spacer"></span>
<button type="button" data-action="fullscreen" title="Fullscreen (F)">⛶ Fullscreen</button> <button type="button" data-action="fullscreen" title="Fullscreen (F)">⛶ Fullscreen</button>
`; `;
@ -1072,100 +1102,204 @@ erDiagram
return toolbar; return toolbar;
} }
function applyZoom(state) { class DiagramViewer {
state.canvas.style.transform = `scale(${state.zoom})`; constructor({ viewport, stage, canvas, label, wheelNeedsModifier = true }) {
state.label.textContent = `${Math.round(state.zoom * 100)}%`; this.viewport = viewport;
this.stage = stage;
this.canvas = canvas;
this.label = label;
this.wheelNeedsModifier = wheelNeedsModifier;
this.zoom = ZOOM_DEFAULT;
this.panX = 0;
this.panY = 0;
this.isPanning = false;
this.pointerId = null;
this.lastPointer = { x: 0, y: 0 };
this.bindEvents();
}
render() {
this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
if (this.label) {
this.label.textContent = `${Math.round(this.zoom * 100)}%`;
}
}
getViewportPoint(clientX, clientY) {
const rect = this.stage.getBoundingClientRect();
return { x: clientX - rect.left, y: clientY - rect.top };
}
zoomAt(clientX, clientY, delta) {
const point = this.getViewportPoint(clientX, clientY);
const prevZoom = this.zoom;
const nextZoom = clampZoom(prevZoom + delta);
if (nextZoom === prevZoom) return;
this.panX = point.x - ((point.x - this.panX) * nextZoom) / prevZoom;
this.panY = point.y - ((point.y - this.panY) * nextZoom) / prevZoom;
this.zoom = nextZoom;
this.render();
}
zoomBy(delta, centerToStage = true) {
const rect = this.stage.getBoundingClientRect();
const x = centerToStage ? rect.left + rect.width / 2 : rect.left;
const y = centerToStage ? rect.top + rect.height / 2 : rect.top;
this.zoomAt(x, y, delta);
}
reset() {
this.zoom = ZOOM_DEFAULT;
this.panX = 0;
this.panY = 0;
this.render();
this.centerContent();
}
fitToView() {
const svg = this.canvas.querySelector('svg');
if (!svg) {
this.reset();
return;
}
const stageRect = this.stage.getBoundingClientRect();
const bbox = svg.getBBox();
const pad = 24;
const scaleX = (stageRect.width - pad * 2) / bbox.width;
const scaleY = (stageRect.height - pad * 2) / bbox.height;
const fitZoom = clampZoom(Math.min(scaleX, scaleY, 1));
this.zoom = fitZoom;
this.panX = (stageRect.width - bbox.width * fitZoom) / 2 - bbox.x * fitZoom;
this.panY = (stageRect.height - bbox.height * fitZoom) / 2 - bbox.y * fitZoom;
this.render();
}
centerContent() {
const svg = this.canvas.querySelector('svg');
if (!svg) return;
const stageRect = this.stage.getBoundingClientRect();
const bbox = svg.getBBox();
this.panX = (stageRect.width - bbox.width * this.zoom) / 2 - bbox.x * this.zoom;
this.panY = (stageRect.height - bbox.height * this.zoom) / 2 - bbox.y * this.zoom;
this.render();
}
handleAction(action) {
switch (action) {
case 'zoom-in':
this.zoomBy(ZOOM_STEP);
break;
case 'zoom-out':
this.zoomBy(-ZOOM_STEP);
break;
case 'zoom-reset':
this.reset();
break;
case 'zoom-fit':
this.fitToView();
break;
default:
break;
}
}
bindEvents() {
this.viewport.classList.add('can-pan');
this.viewport.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('button')) return;
this.isPanning = true;
this.pointerId = e.pointerId;
this.lastPointer = { x: e.clientX, y: e.clientY };
this.viewport.classList.add('is-panning');
this.viewport.setPointerCapture(e.pointerId);
e.preventDefault();
});
this.viewport.addEventListener('pointermove', (e) => {
if (!this.isPanning || e.pointerId !== this.pointerId) return;
this.panX += e.clientX - this.lastPointer.x;
this.panY += e.clientY - this.lastPointer.y;
this.lastPointer = { x: e.clientX, y: e.clientY };
this.render();
e.preventDefault();
});
const endPan = (e) => {
if (!this.isPanning || (e.pointerId !== undefined && e.pointerId !== this.pointerId)) return;
this.isPanning = false;
this.pointerId = null;
this.viewport.classList.remove('is-panning');
if (this.viewport.hasPointerCapture(e.pointerId)) {
this.viewport.releasePointerCapture(e.pointerId);
}
};
this.viewport.addEventListener('pointerup', endPan);
this.viewport.addEventListener('pointercancel', endPan);
this.viewport.addEventListener('lostpointercapture', () => {
this.isPanning = false;
this.pointerId = null;
this.viewport.classList.remove('is-panning');
});
this.viewport.addEventListener(
'wheel',
(e) => {
if (this.wheelNeedsModifier && !e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
this.zoomAt(e.clientX, e.clientY, delta);
},
{ passive: false }
);
}
} }
function setupPan(viewport, getZoom) { const diagramViewers = [];
let panning = false; let fullscreenViewer = null;
let startX = 0; let fullscreenToolbarReady = false;
let startY = 0;
let scrollLeft = 0;
let scrollTop = 0;
viewport.addEventListener('mousedown', (e) => { function openFullscreen(sourceViewer) {
if (e.button !== 0 || getZoom() <= 1) return;
if (e.target.closest('button')) return;
panning = true;
viewport.classList.add('is-panning');
startX = e.clientX;
startY = e.clientY;
scrollLeft = viewport.scrollLeft;
scrollTop = viewport.scrollTop;
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!panning) return;
viewport.scrollLeft = scrollLeft - (e.clientX - startX);
viewport.scrollTop = scrollTop - (e.clientY - startY);
});
window.addEventListener('mouseup', () => {
if (!panning) return;
panning = false;
viewport.classList.remove('is-panning');
});
}
function setupWheelZoom(viewport, state, applyFn, requireModifier) {
viewport.addEventListener('wheel', (e) => {
if (requireModifier && !e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
state.zoom = clampZoom(state.zoom + delta);
applyFn(state);
}, { passive: false });
}
const diagramStates = [];
let activeFullscreenState = null;
function openFullscreen(diagramState) {
const overlay = document.getElementById('diagram-fullscreen'); const overlay = document.getElementById('diagram-fullscreen');
const canvas = document.getElementById('diagram-fullscreen-canvas');
const viewport = document.getElementById('diagram-fullscreen-viewport'); const viewport = document.getElementById('diagram-fullscreen-viewport');
const stage = document.getElementById('diagram-fullscreen-stage');
const canvas = document.getElementById('diagram-fullscreen-canvas');
const title = document.getElementById('diagram-fullscreen-title'); const title = document.getElementById('diagram-fullscreen-title');
const toolbarHost = document.getElementById('diagram-fullscreen-toolbar'); const toolbarHost = document.getElementById('diagram-fullscreen-toolbar');
title.textContent = diagramState.title; title.textContent = sourceViewer.title || 'Diagram';
canvas.innerHTML = ''; canvas.innerHTML = sourceViewer.canvas.innerHTML;
const clone = diagramState.canvas.cloneNode(true);
clone.style.transform = '';
canvas.appendChild(clone);
const fsState = { if (!fullscreenToolbarReady) {
zoom: diagramState.zoom, const toolbar = createToolbar((action) => {
canvas: clone, if (action === 'fullscreen') return;
label: toolbarHost.querySelector('[data-zoom-label]'), if (fullscreenViewer) fullscreenViewer.handleAction(action);
viewport, });
source: diagramState, toolbarHost.appendChild(toolbar);
}; fullscreenToolbarReady = true;
if (!toolbarHost.dataset.ready) {
const fsToolbar = createToolbar((action) => handleDiagramAction(fsState, action, true));
toolbarHost.innerHTML = '';
toolbarHost.appendChild(fsToolbar);
toolbarHost.dataset.ready = '1';
setupPan(viewport, () => fsState.zoom);
setupWheelZoom(viewport, fsState, (s) => {
applyZoom(s);
if (s.source) {
s.source.zoom = s.zoom;
applyZoom(s.source);
}
}, false);
} }
fsState.label = toolbarHost.querySelector('[data-zoom-label]'); if (!fullscreenViewer) {
applyZoom(fsState); fullscreenViewer = new DiagramViewer({
activeFullscreenState = fsState; viewport,
stage,
canvas,
label: toolbarHost.querySelector('[data-zoom-label]'),
wheelNeedsModifier: false,
});
}
fullscreenViewer.fitToView();
overlay.classList.add('is-open'); overlay.classList.add('is-open');
overlay.setAttribute('aria-hidden', 'false'); overlay.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
} }
function closeFullscreen() { function closeFullscreen() {
@ -1173,60 +1307,21 @@ erDiagram
overlay.classList.remove('is-open'); overlay.classList.remove('is-open');
overlay.setAttribute('aria-hidden', 'true'); overlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = ''; document.body.style.overflow = '';
activeFullscreenState = null; fullscreenViewer = null;
}
function handleDiagramAction(state, action, fromFullscreen) {
switch (action) {
case 'zoom-in':
state.zoom = clampZoom(state.zoom + ZOOM_STEP);
applyZoom(state);
if (fromFullscreen && state.source) {
state.source.zoom = state.zoom;
applyZoom(state.source);
}
break;
case 'zoom-out':
state.zoom = clampZoom(state.zoom - ZOOM_STEP);
applyZoom(state);
if (fromFullscreen && state.source) {
state.source.zoom = state.zoom;
applyZoom(state.source);
}
break;
case 'zoom-reset':
state.zoom = ZOOM_DEFAULT;
applyZoom(state);
if (fromFullscreen && state.source) {
state.source.zoom = ZOOM_DEFAULT;
applyZoom(state.source);
state.viewport.scrollTop = 0;
state.viewport.scrollLeft = 0;
} else if (state.viewport) {
state.viewport.scrollTop = 0;
state.viewport.scrollLeft = 0;
}
break;
case 'fullscreen':
if (!fromFullscreen) openFullscreen(state);
break;
default:
break;
}
} }
function wrapDiagrams() { function wrapDiagrams() {
document.querySelectorAll('.diagram').forEach((diagram, index) => { document.querySelectorAll('.diagram').forEach((diagram, index) => {
const section = diagram.closest('main')?.querySelector(`h2, h3`);
let title = `Diagram ${index + 1}`; let title = `Diagram ${index + 1}`;
const prevHeading = diagram.previousElementSibling; const prevHeading = diagram.previousElementSibling;
if (prevHeading && (prevHeading.tagName === 'H2' || prevHeading.tagName === 'H3')) { if (prevHeading && (prevHeading.tagName === 'H2' || prevHeading.tagName === 'H3')) {
title = prevHeading.textContent.trim(); title = prevHeading.textContent.trim();
} }
const toolbar = createToolbar((action) => handleDiagramAction(state, action, false));
const viewport = document.createElement('div'); const viewport = document.createElement('div');
viewport.className = 'diagram-viewport'; viewport.className = 'diagram-viewport';
const stage = document.createElement('div');
stage.className = 'diagram-stage';
const canvas = document.createElement('div'); const canvas = document.createElement('div');
canvas.className = 'diagram-canvas'; canvas.className = 'diagram-canvas';
@ -1234,22 +1329,32 @@ erDiagram
canvas.appendChild(diagram.firstChild); canvas.appendChild(diagram.firstChild);
} }
viewport.appendChild(canvas); stage.appendChild(canvas);
viewport.appendChild(stage);
const viewer = new DiagramViewer({
viewport,
stage,
canvas,
label: null,
wheelNeedsModifier: true,
});
viewer.title = title;
viewer.element = diagram;
const toolbar = createToolbar((action) => {
if (action === 'fullscreen') {
openFullscreen(viewer);
return;
}
viewer.handleAction(action);
});
viewer.label = toolbar.querySelector('[data-zoom-label]');
viewer.render();
diagram.appendChild(toolbar); diagram.appendChild(toolbar);
diagram.appendChild(viewport); diagram.appendChild(viewport);
diagramViewers.push(viewer);
const state = {
zoom: ZOOM_DEFAULT,
canvas,
label: toolbar.querySelector('[data-zoom-label]'),
viewport,
title,
element: diagram,
};
setupPan(viewport, () => state.zoom);
setupWheelZoom(viewport, state, applyZoom, true);
diagramStates.push(state);
}); });
} }
@ -1257,35 +1362,32 @@ erDiagram
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
const fsOpen = document.getElementById('diagram-fullscreen').classList.contains('is-open'); const fsOpen = document.getElementById('diagram-fullscreen').classList.contains('is-open');
const target = fsOpen && activeFullscreenState ? activeFullscreenState : null; const viewer = fsOpen
? fullscreenViewer
: diagramViewers.find((v) => {
const rect = v.element.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0;
});
if (!viewer) return;
if (e.key === 'Escape' && fsOpen) { if (e.key === 'Escape' && fsOpen) {
closeFullscreen(); closeFullscreen();
return; return;
} }
if (!target && !e.target.closest('.diagram')) return;
const state = target || diagramStates.find((s) => s.element.contains(document.activeElement))
|| diagramStates.find((s) => {
const rect = s.element.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0;
});
if (!state) return;
if (e.key === '+' || e.key === '=') { if (e.key === '+' || e.key === '=') {
e.preventDefault(); e.preventDefault();
handleDiagramAction(state, 'zoom-in', !!target); viewer.handleAction('zoom-in');
} else if (e.key === '-' || e.key === '_') { } else if (e.key === '-' || e.key === '_') {
e.preventDefault(); e.preventDefault();
handleDiagramAction(state, 'zoom-out', !!target); viewer.handleAction('zoom-out');
} else if (e.key === '0') { } else if (e.key === '0') {
e.preventDefault(); e.preventDefault();
handleDiagramAction(state, 'zoom-reset', !!target); viewer.handleAction('zoom-reset');
} else if ((e.key === 'f' || e.key === 'F') && !target) { } else if ((e.key === 'f' || e.key === 'F') && !fsOpen) {
e.preventDefault(); e.preventDefault();
openFullscreen(state); openFullscreen(viewer);
} }
}); });
@ -1300,7 +1402,7 @@ erDiagram
wrapDiagrams(); wrapDiagrams();
mermaid.run({ querySelector: '.mermaid' }).then(() => { mermaid.run({ querySelector: '.mermaid' }).then(() => {
diagramStates.forEach(applyZoom); diagramViewers.forEach((viewer) => viewer.fitToView());
}); });
</script> </script>
</body> </body>

View File

@ -69,15 +69,14 @@ flowchart LR
ROLES["/roles CRUD + permissions matrix"] ROLES["/roles CRUD + permissions matrix"]
end end
subgraph Masters["/masters/* (14 sub-masters)"] subgraph Masters["/masters/* (item categories shared with assets)"]
M1[uom] M1[uom]
M2[item-categories / subcategories / items] M2[item-categories / subcategories / items]
M3[brands / gst-rates] M3[brands / gst-rates]
M4[payment-terms / delivery-terms] M4[payment-terms / delivery-terms]
M5[asset-categories / subcategories] M5[departments / designations]
M6[departments / designations] M6[locations / plants / warehouses]
M7[locations / plants / warehouses] M7[document-series]
M8[document-series]
end end
subgraph Procurement subgraph Procurement
@ -180,11 +179,12 @@ flowchart TD
PT --> PO PT --> PO
DT[delivery_terms] --> PO DT[delivery_terms] --> PO
ACAT[asset_categories] --> ASUB[asset_subcategories] ICAT[item_categories] --> ISUB[item_subcategories]
ACAT --> ASSET ISUB --> ITEMS[items]
ACAT --> GRN_AUTO[GRN auto-asset creation] ICAT --> ASSET
ASUB --> ASSET ICAT --> GRN_AUTO[GRN auto-asset creation]
ASUB --> GRN_AUTO ISUB --> ASSET
ISUB --> GRN_AUTO
ITEMS --> PO_ITEMS ITEMS --> PO_ITEMS
ITEMS --> GRN_ITEMS ITEMS --> GRN_ITEMS
@ -260,12 +260,12 @@ stateDiagram-v2
|---|---| |---|---|
| `asset_code` | `document_series` via `ASSET_{category.code}` | | `asset_code` | `document_series` via `ASSET_{category.code}` |
| `asset_name` | Item name (+ `#N` if qty > 1) | | `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 | | `plant_id` | From PO |
| `warehouse_id`, `vendor_id`, `po_id`, `grn_id`, `grn_item_id` | From GRN | | `warehouse_id`, `vendor_id`, `po_id`, `grn_id`, `grn_item_id` | From GRN |
| `purchase_date` | `grn.grn_date` | | `purchase_date` | `grn.grn_date` |
| `purchase_cost` | `grn_items.rate` | | `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` | | `condition` | `NEW` |
| `status` | `IN_USE` | | `status` | `IN_USE` |
@ -281,7 +281,7 @@ flowchart TB
end end
subgraph Validate["normalizeAssetPayload validations"] subgraph Validate["normalizeAssetPayload validations"]
V1[asset_category + subcategory match] V1[item_category + subcategory match]
V2[plant / dept / warehouse / user refs] V2[plant / dept / warehouse / user refs]
V3[vendor / PO / GRN / grn_item refs] V3[vendor / PO / GRN / grn_item refs]
V4[disposal_date required if DISPOSED/SCRAPPED] V4[disposal_date required if DISPOSED/SCRAPPED]
@ -418,7 +418,7 @@ flowchart TD
```mermaid ```mermaid
flowchart TD flowchart TD
subgraph Inputs subgraph Inputs
CAT[asset_categories defaults] CAT[item_categories defaults]
FORM[User form fields] FORM[User form fields]
end end
@ -573,9 +573,11 @@ erDiagram
items ||--o{ purchase_order_items : ordered items ||--o{ purchase_order_items : ordered
items ||--o{ grn_items : received items ||--o{ grn_items : received
asset_categories ||--o{ asset_subcategories : has item_categories ||--o{ item_subcategories : has
asset_categories ||--o{ assets : classifies item_categories ||--o{ items : classifies
asset_subcategories ||--o{ assets : sub_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_amc_contracts : has
assets ||--o{ asset_insurance_policies : insured_by assets ||--o{ asset_insurance_policies : insured_by

View File

@ -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. /// 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 { model asset_transfers {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
asset_id BigInt asset_id BigInt
@ -208,8 +170,8 @@ model assets {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
asset_code String @unique @db.VarChar(30) asset_code String @unique @db.VarChar(30)
asset_name String @db.VarChar(200) asset_name String @db.VarChar(200)
asset_category_id BigInt item_category_id BigInt
asset_subcategory_id BigInt? item_subcategory_id BigInt?
brand_model String? @db.VarChar(200) brand_model String? @db.VarChar(200)
manufacturer String? @db.VarChar(200) manufacturer String? @db.VarChar(200)
serial_number String? @db.VarChar(100) serial_number String? @db.VarChar(100)
@ -248,8 +210,8 @@ model assets {
asset_insurance_policies asset_insurance_policies[] asset_insurance_policies asset_insurance_policies[]
asset_service_visits asset_service_visits[] asset_service_visits asset_service_visits[]
asset_transfers asset_transfers[] asset_transfers asset_transfers[]
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction) item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction)
asset_subcategories asset_subcategories? @relation(fields: [asset_subcategory_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_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) 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) 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) 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) warehouse locations? @relation("AssetWarehouseLocation", fields: [warehouse_id], references: [id], onUpdate: NoAction)
@@index([asset_category_id], map: "idx_assets_category_id") @@index([item_category_id], map: "idx_assets_item_category_id")
@@index([asset_subcategory_id], map: "idx_assets_subcategory_id") @@index([item_subcategory_id], map: "idx_assets_item_subcategory_id")
@@index([department_id], map: "idx_assets_dept_id") @@index([department_id], map: "idx_assets_dept_id")
@@index([plant_id], map: "idx_assets_plant_id") @@index([plant_id], map: "idx_assets_plant_id")
} }
@ -480,6 +442,9 @@ model item_categories {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30) code String @unique @db.VarChar(30)
name String @db.VarChar(100) 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) is_active Boolean @default(true)
created_by BigInt? created_by BigInt?
updated_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) users_item_categories_updated_byTousers users? @relation("item_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
item_subcategories item_subcategories[] item_subcategories item_subcategories[]
items items[] items items[]
assets assets[]
} }
model item_subcategories { model item_subcategories {
@ -507,6 +473,7 @@ model item_subcategories {
item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction) 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) users_item_subcategories_updated_byTousers users? @relation("item_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
items items[] items items[]
assets assets[]
} }
model items { 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_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_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_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_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_to_user_idTousers asset_transfers[] @relation("asset_transfers_to_user_idTousers")
asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers") asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers")

View File

@ -26,7 +26,7 @@ CREATE VIEW v_asset_expiry_alerts AS
END AS alert_level END AS alert_level
FROM asset_amc_contracts amc FROM asset_amc_contracts amc
JOIN assets a ON a.id = amc.asset_id 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' 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 departments d ON d.id = a.department_id
LEFT JOIN vendors v ON v.id = amc.vendor_id LEFT JOIN vendors v ON v.id = amc.vendor_id
@ -47,7 +47,7 @@ UNION ALL
END END
FROM asset_insurance_policies ins FROM asset_insurance_policies ins
JOIN assets a ON a.id = ins.asset_id 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' 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 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 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' WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 90 THEN 'INFO'
END END
FROM assets a 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' 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 departments d ON d.id = a.department_id
WHERE a.warranty_expiry_date IS NOT NULL AND a.deleted_at IS NULL 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.contract_no AS amc_contract_no,
amc.end_date AS amc_end_date amc.end_date AS amc_end_date
FROM assets a 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' 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 departments d ON d.id = a.department_id
JOIN LATERAL ( JOIN LATERAL (
@ -144,7 +144,7 @@ SELECT
(lsv.next_service_date - CURRENT_DATE) AS service_due_in_days, (lsv.next_service_date - CURRENT_DATE) AS service_due_in_days,
a.created_at a.created_at
FROM assets a 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' 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 departments d ON d.id = a.department_id
LEFT JOIN locations w ON w.id = a.warehouse_id AND w.type = 'warehouse' LEFT JOIN locations w ON w.id = a.warehouse_id AND w.type = 'warehouse'

View File

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

View File

@ -5,11 +5,11 @@ components:
schemas: schemas:
AssetsCreateBody: AssetsCreateBody:
type: object 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: properties:
asset_name: { type: string, example: 'CNC Lathe Machine' } asset_name: { type: string, example: 'CNC Lathe Machine' }
asset_category_id: { type: integer, example: 1 } item_category_id: { type: integer, example: 1 }
asset_subcategory_id: { type: integer, example: 1 } item_subcategory_id: { type: integer, example: 1 }
plant_id: { type: integer, example: 1 } plant_id: { type: integer, example: 1 }
brand_model: { type: string } brand_model: { type: string }
manufacturer: { type: string } manufacturer: { type: string }
@ -43,8 +43,8 @@ components:
minProperties: 1 minProperties: 1
properties: properties:
asset_name: { type: string } asset_name: { type: string }
asset_category_id: { type: integer } item_category_id: { type: integer }
asset_subcategory_id: { type: integer } item_subcategory_id: { type: integer }
plant_id: { type: integer } plant_id: { type: integer }
brand_model: { type: string } brand_model: { type: string }
manufacturer: { type: string } manufacturer: { type: string }
@ -410,8 +410,8 @@ paths:
- { name: search, in: query, schema: { type: string } } - { name: search, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } }
- { name: condition, in: query, schema: { type: string } } - { name: condition, in: query, schema: { type: string } }
- { name: asset_category_id, in: query, schema: { type: integer } } - { name: item_category_id, in: query, schema: { type: integer } }
- { name: asset_subcategory_id, in: query, schema: { type: integer } } - { name: item_subcategory_id, in: query, schema: { type: integer } }
- { name: plant_id, in: query, schema: { type: integer } } - { name: plant_id, in: query, schema: { type: integer } }
- { name: department_id, in: query, schema: { type: integer } } - { name: department_id, in: query, schema: { type: integer } }
- { name: is_active, in: query, schema: { type: boolean } } - { name: is_active, in: query, schema: { type: boolean } }

View File

@ -3,13 +3,11 @@ tags:
- name: UOM - name: UOM
- name: Item Categories - name: Item Categories
- name: Item Subcategories - name: Item Subcategories
- name: Asset Subcategories
- name: Items - name: Items
- name: Brands - name: Brands
- name: GST Rates - name: GST Rates
- name: Payment Terms - name: Payment Terms
- name: Delivery Terms - name: Delivery Terms
- name: Asset Categories
- name: Departments - name: Departments
- name: Designations - name: Designations
- name: Locations - name: Locations
@ -85,6 +83,9 @@ components:
properties: properties:
code: { type: string, example: 'RAW' } code: { type: string, example: 'RAW' }
name: { type: string, example: 'Raw Material' } 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 } is_active: { type: boolean, example: true }
ItemCategoriesUpdateBody: ItemCategoriesUpdateBody:
type: object type: object
@ -92,6 +93,9 @@ components:
properties: properties:
code: { type: string, example: 'RAW' } code: { type: string, example: 'RAW' }
name: { type: string, example: 'Raw Material' } 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 } is_active: { type: boolean, example: true }
ItemSubcategoriesCreateBody: ItemSubcategoriesCreateBody:
type: object type: object
@ -215,42 +219,6 @@ components:
name: { type: string, example: 'Free on Road' } name: { type: string, example: 'Free on Road' }
description: { type: string, example: 'Delivered to site' } description: { type: string, example: 'Delivered to site' }
is_active: { type: boolean, example: true } 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: DepartmentsCreateBody:
type: object type: object
required: [name] required: [name]
@ -1079,148 +1047,6 @@ paths:
description: Deleted description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found } "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: /masters/departments:
get: get:
tags: [Departments] tags: [Departments]

View File

@ -19,8 +19,8 @@ components:
expiry_date: { type: string, format: date, nullable: true } expiry_date: { type: string, format: date, nullable: true }
storage_location: { type: string, example: 'Rack A-12' } storage_location: { type: string, example: 'Rack A-12' }
remarks: { type: string, example: 'Partial receipt' } remarks: { type: string, example: 'Partial receipt' }
asset_category_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 }
asset_subcategory_id: { type: integer, nullable: true, example: 1, description: Required when PO item is an asset item } item_subcategory_id: { type: integer, nullable: true, example: 1, description: Optional override; defaults from PO item master when omitted }
GrnCreateBody: GrnCreateBody:
type: object type: object
required: [grn_date, po_id, warehouse_id, items] required: [grn_date, po_id, warehouse_id, items]

View File

@ -8,8 +8,8 @@ const SOFT_DELETE_TABLES = new Set([
'users', 'users',
'purchase_orders', 'purchase_orders',
'grn', 'grn',
'asset_categories', 'item_categories',
'asset_subcategories', 'item_subcategories',
]); ]);
const toDateOnly = (value) => { const toDateOnly = (value) => {

View File

@ -14,8 +14,17 @@ const {
const { assertReference, toDateOnly } = require('./assets.helpers'); const { assertReference, toDateOnly } = require('./assets.helpers');
const assetInclude = { const assetInclude = {
asset_categories: { select: { id: true, code: true, name: true, code_prefix: true } }, item_categories: {
asset_subcategories: { select: { id: true, code: true, name: true, asset_category_id: true } }, 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 } }, plant: { select: { id: true, code: true, name: true } },
departments: { select: { id: true, name: true } }, departments: { select: { id: true, name: true } },
warehouse: { select: { id: true, code: true, name: true } }, warehouse: { select: { id: true, code: true, name: true } },
@ -50,8 +59,8 @@ const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`;
const sanitizeAsset = (asset) => { const sanitizeAsset = (asset) => {
if (!asset) return null; if (!asset) return null;
const { const {
asset_categories, item_categories,
asset_subcategories, item_subcategories,
plant, plant,
departments, departments,
warehouse, warehouse,
@ -82,8 +91,8 @@ const sanitizeAsset = (asset) => {
purchase_cost: purchaseCost, purchase_cost: purchaseCost,
salvage_value: salvageValue, salvage_value: salvageValue,
depreciation_rate: depreciationRate, depreciation_rate: depreciationRate,
asset_category: asset_categories || null, item_category: item_categories || null,
asset_subcategory: asset_subcategories || null, item_subcategory: item_subcategories || null,
plant: plant || null, plant: plant || null,
department: departments || null, department: departments || null,
warehouse: warehouse || null, warehouse: warehouse || null,
@ -101,8 +110,8 @@ const sanitizeAsset = (asset) => {
useful_life_years: rest.useful_life_years, useful_life_years: rest.useful_life_years,
purchase_date: rest.purchase_date, purchase_date: rest.purchase_date,
}), }),
asset_categories: undefined, item_categories: undefined,
asset_subcategories: undefined, item_subcategories: undefined,
departments: undefined, departments: undefined,
users_assets_assigned_to_user_idTousers: undefined, users_assets_assigned_to_user_idTousers: undefined,
vendors_assets_vendor_idTovendors: 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( const subcategory = await assertReference(
'asset_subcategories', 'item_subcategories',
subcategoryId, subcategoryId,
'asset_subcategory_id' 'item_subcategory_id'
); );
if (subcategory.asset_category_id.toString() !== BigInt(categoryId).toString()) { if (subcategory.item_category_id.toString() !== BigInt(categoryId).toString()) {
throw new ApiError(422, 'asset_subcategory_id does not belong to the selected asset_category_id'); throw new ApiError(422, 'item_subcategory_id does not belong to the selected item_category_id');
} }
return subcategory; return subcategory;
}; };
const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => { const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
const category = await assertReference( const category = await assertReference(
'asset_categories', 'item_categories',
payload.asset_category_id, payload.item_category_id,
'asset_category_id' 'item_category_id'
); );
if (isCreate && !payload.asset_subcategory_id) { if (isCreate && !payload.item_subcategory_id) {
throw new ApiError(422, 'asset_subcategory_id is required'); throw new ApiError(422, 'item_subcategory_id is required');
} }
if (payload.asset_subcategory_id) { if (payload.item_subcategory_id) {
await assertAssetSubcategory(payload.asset_subcategory_id, payload.asset_category_id); await assertItemSubcategory(payload.item_subcategory_id, payload.item_category_id);
} }
await assertPlant(payload.plant_id, 'plant_id'); await assertPlant(payload.plant_id, 'plant_id');
if (payload.department_id) if (payload.department_id)
@ -212,9 +221,9 @@ const normalizeAssetPayload = async (payload, { isCreate = false } = {}) => {
return { return {
asset_name: payload.asset_name, asset_name: payload.asset_name,
asset_category_id: BigInt(payload.asset_category_id), item_category_id: BigInt(payload.item_category_id),
asset_subcategory_id: payload.asset_subcategory_id item_subcategory_id: payload.item_subcategory_id
? BigInt(payload.asset_subcategory_id) ? BigInt(payload.item_subcategory_id)
: null, : null,
brand_model: payload.brand_model || null, brand_model: payload.brand_model || null,
manufacturer: payload.manufacturer || null, manufacturer: payload.manufacturer || null,
@ -294,9 +303,9 @@ const listAssets = async (query) => {
deleted_at: null, deleted_at: null,
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
...(query.condition ? { condition: query.condition } : {}), ...(query.condition ? { condition: query.condition } : {}),
...(query.asset_category_id ? { asset_category_id: BigInt(query.asset_category_id) } : {}), ...(query.item_category_id ? { item_category_id: BigInt(query.item_category_id) } : {}),
...(query.asset_subcategory_id ...(query.item_subcategory_id
? { asset_subcategory_id: BigInt(query.asset_subcategory_id) } ? { item_subcategory_id: BigInt(query.item_subcategory_id) }
: {}), : {}),
...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}), ...(query.plant_id ? { plant_id: BigInt(query.plant_id) } : {}),
...(query.department_id ? { department_id: BigInt(query.department_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 existing = await getAssetOrThrow(id);
const merged = { const merged = {
asset_name: payload.asset_name ?? existing.asset_name, asset_name: payload.asset_name ?? existing.asset_name,
asset_category_id: payload.asset_category_id ?? existing.asset_category_id, item_category_id: payload.item_category_id ?? existing.item_category_id,
asset_subcategory_id: item_subcategory_id:
payload.asset_subcategory_id !== undefined payload.item_subcategory_id !== undefined
? payload.asset_subcategory_id ? payload.item_subcategory_id
: existing.asset_subcategory_id, : existing.item_subcategory_id,
brand_model: payload.brand_model !== undefined ? payload.brand_model : existing.brand_model, brand_model: payload.brand_model !== undefined ? payload.brand_model : existing.brand_model,
manufacturer: payload.manufacturer !== undefined ? payload.manufacturer : existing.manufacturer, manufacturer: payload.manufacturer !== undefined ? payload.manufacturer : existing.manufacturer,
serial_number: serial_number:

View File

@ -16,8 +16,8 @@ const {
const assetFields = { const assetFields = {
asset_name: Joi.string().max(200).required(), asset_name: Joi.string().max(200).required(),
asset_category_id: Joi.number().integer().positive().required(), item_category_id: Joi.number().integer().positive().required(),
asset_subcategory_id: Joi.number().integer().positive().required(), item_subcategory_id: Joi.number().integer().positive().required(),
brand_model: Joi.string().max(200).allow(null, '').optional(), brand_model: Joi.string().max(200).allow(null, '').optional(),
manufacturer: Joi.string().max(200).allow(null, '').optional(), manufacturer: Joi.string().max(200).allow(null, '').optional(),
serial_number: Joi.string().max(100).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({ const updateAssetSchema = Joi.object({
asset_name: assetFields.asset_name.optional(), asset_name: assetFields.asset_name.optional(),
asset_category_id: assetFields.asset_category_id.optional(), item_category_id: assetFields.item_category_id.optional(),
asset_subcategory_id: assetFields.asset_subcategory_id.optional(), item_subcategory_id: assetFields.item_subcategory_id.optional(),
brand_model: assetFields.brand_model, brand_model: assetFields.brand_model,
manufacturer: assetFields.manufacturer, manufacturer: assetFields.manufacturer,
serial_number: assetFields.serial_number, serial_number: assetFields.serial_number,
@ -101,8 +101,8 @@ const listAssetsQuerySchema = Joi.object({
condition: Joi.string() condition: Joi.string()
.valid(...ASSET_CONDITIONS) .valid(...ASSET_CONDITIONS)
.optional(), .optional(),
asset_category_id: Joi.number().integer().positive().optional(), item_category_id: Joi.number().integer().positive().optional(),
asset_subcategory_id: Joi.number().integer().positive().optional(), item_subcategory_id: Joi.number().integer().positive().optional(),
plant_id: Joi.number().integer().positive().optional(), plant_id: Joi.number().integer().positive().optional(),
department_id: Joi.number().integer().positive().optional(), department_id: Joi.number().integer().positive().optional(),
is_active: Joi.boolean().optional(), is_active: Joi.boolean().optional(),

View File

@ -4,7 +4,7 @@ const { recalculatePoStatus } = require('./grn.poStatus');
const createAssetsForLine = async ( const createAssetsForLine = async (
tx, tx,
{ grn, grnItem, item, assetCategory, assetSubcategory, plantId, assetCodes, userId } { grn, grnItem, item, itemCategory, itemSubcategory, plantId, assetCodes, userId }
) => { ) => {
const createdAssets = []; const createdAssets = [];
@ -13,8 +13,8 @@ const createAssetsForLine = async (
data: { data: {
asset_code: assetCodes[index], asset_code: assetCodes[index],
asset_name: assetCodes.length > 1 ? `${item.item_name} #${index + 1}` : item.item_name, asset_name: assetCodes.length > 1 ? `${item.item_name} #${index + 1}` : item.item_name,
asset_category_id: assetCategory.id, item_category_id: itemCategory.id,
asset_subcategory_id: assetSubcategory.id, item_subcategory_id: itemSubcategory.id,
plant_id: BigInt(plantId), plant_id: BigInt(plantId),
warehouse_id: grn.warehouse_id, warehouse_id: grn.warehouse_id,
vendor_id: grn.vendor_id, vendor_id: grn.vendor_id,
@ -23,8 +23,8 @@ const createAssetsForLine = async (
grn_item_id: grnItem.id, grn_item_id: grnItem.id,
purchase_date: grn.grn_date, purchase_date: grn.grn_date,
purchase_cost: grnItem.rate, purchase_cost: grnItem.rate,
useful_life_years: assetCategory.default_useful_life_years, useful_life_years: itemCategory.default_useful_life_years,
depreciation_method: assetCategory.default_depreciation_method, depreciation_method: itemCategory.default_depreciation_method,
condition: 'NEW', condition: 'NEW',
status: 'IN_USE', status: 'IN_USE',
created_by: userId ? BigInt(userId) : null, created_by: userId ? BigInt(userId) : null,
@ -67,8 +67,8 @@ const createGrnWithReceipt = async ({ grnNumber, header, items, assetPlans, user
grn, grn,
grnItem, grnItem,
item: plan.item, item: plan.item,
assetCategory: plan.assetCategory, itemCategory: plan.itemCategory,
assetSubcategory: plan.assetSubcategory, itemSubcategory: plan.itemSubcategory,
plantId: plan.plantId, plantId: plan.plantId,
assetCodes: plan.assetCodes, assetCodes: plan.assetCodes,
userId, userId,

View File

@ -108,6 +108,8 @@ const getReceivablePoOrThrow = async (poId) => {
item_code: true, item_code: true,
item_name: true, item_name: true,
is_asset_item: 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; const item = poItem.items;
if (item.is_asset_item && acceptedQty > 0) { 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( throw new ApiError(
422, 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( throw new ApiError(
422, 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({ const itemCategory = await prisma.item_categories.findFirst({
where: { id: BigInt(row.asset_category_id), deleted_at: null, is_active: true }, 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({ const itemSubcategory = await prisma.item_subcategories.findFirst({
where: { id: BigInt(row.asset_subcategory_id), deleted_at: null, is_active: true }, where: { id: BigInt(subcategoryId), deleted_at: null, is_active: true },
}); });
if (!assetSubcategory) { if (!itemSubcategory) {
throw new ApiError(422, `Line ${row.line_no}: invalid asset_subcategory_id`); 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( throw new ApiError(
422, 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 units = Math.floor(acceptedQty);
const assetCodes = []; const assetCodes = [];
for (let unit = 0; unit < units; unit += 1) { for (let unit = 0; unit < units; unit += 1) {
assetCodes.push(await nextDocumentNumber(assetSeriesCode(assetCategory.code))); assetCodes.push(await nextDocumentNumber(assetSeriesCode(itemCategory.code)));
} }
assetPlans.push({ assetPlans.push({
po_item_id: poItem.id, po_item_id: poItem.id,
item, item,
assetCategory, itemCategory,
assetSubcategory, itemSubcategory,
plantId: po.plant_id, plantId: po.plant_id,
assetCodes, assetCodes,
}); });

View File

@ -14,8 +14,8 @@ const grnItemSchema = Joi.object({
expiry_date: Joi.date().iso().allow(null).optional(), expiry_date: Joi.date().iso().allow(null).optional(),
storage_location: Joi.string().max(100).allow(null, '').optional(), storage_location: Joi.string().max(100).allow(null, '').optional(),
remarks: Joi.string().allow(null, '').optional(), remarks: Joi.string().allow(null, '').optional(),
asset_category_id: Joi.number().integer().positive().allow(null).optional(), item_category_id: Joi.number().integer().positive().allow(null).optional(),
asset_subcategory_id: Joi.number().integer().positive().allow(null).optional(), item_subcategory_id: Joi.number().integer().positive().allow(null).optional(),
}); });
const createGrnSchema = Joi.object({ const createGrnSchema = Joi.object({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,8 +8,6 @@ router.use('/brands', require('./brands/brands.routes'));
router.use('/gst-rates', require('./gst-rates/gst-rates.routes')); router.use('/gst-rates', require('./gst-rates/gst-rates.routes'));
router.use('/payment-terms', require('./payment-terms/payment-terms.routes')); router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-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('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes')); router.use('/designations', require('./designations/designations.routes'));
router.use('/document-series', require('./document-series/document-series.routes')); router.use('/document-series', require('./document-series/document-series.routes'));

View File

@ -1,5 +1,7 @@
const { buildMasterService } = require('../_shared/master.factory'); const { buildMasterService } = require('../_shared/master.factory');
const emptyToNull = (value) => (value === '' ? null : value);
const config = { const config = {
modelName: 'item_categories', modelName: 'item_categories',
tableName: 'item_categories', tableName: 'item_categories',
@ -18,6 +20,24 @@ const config = {
uppercase: false, uppercase: false,
searchable: true, 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', name: 'is_active',
type: 'boolean', type: 'boolean',
@ -41,10 +61,24 @@ const config = {
const service = buildMasterService(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 = { module.exports = {
createItemCategories: service.createOne, createItemCategories: (payload, userId, requestId) =>
service.createOne(normalizeAssetDefaults(payload), userId, requestId),
listItemCategories: service.list, listItemCategories: service.list,
getItemCategoriesById: service.getOne, getItemCategoriesById: service.getOne,
updateItemCategories: service.updateOne, updateItemCategories: (id, payload, userId, requestId) =>
service.updateOne(id, normalizeAssetDefaults(payload), userId, requestId),
deleteItemCategories: service.removeOne, deleteItemCategories: service.removeOne,
}; };

View File

@ -1,15 +1,28 @@
const Joi = require('joi'); const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation'); const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const { DEPRECIATION_METHODS } = require('../../assets/assets.constants');
const createSchema = Joi.object({ const createSchema = Joi.object({
code: masterCode({ max: 30 }), code: masterCode({ max: 30 }),
name: masterName({ max: 150 }), 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(), is_active: Joi.boolean().optional(),
}); });
const updateSchema = Joi.object({ const updateSchema = Joi.object({
code: masterCode({ max: 30 }), code: masterCode({ max: 30 }),
name: masterName({ max: 150 }), 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(), is_active: Joi.boolean().optional(),
}).min(1); }).min(1);