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/
│ │ │ ├── payment-terms/
│ │ │ ├── delivery-terms/
│ │ │ ├── asset-categories/
│ │ │ ├── departments/
│ │ │ ├── designations/
│ │ │ ├── plants/
@ -1180,7 +1179,7 @@ module.exports = router;
### 9.4 Masters module pattern
Every master (UOM, Item Categories, Brands, GST Rates, Warehouses, Payment Terms, Delivery Terms, Asset Categories, Departments, Designations, Plants, etc.) follows the **same four-file pattern as Vendors**, scaled down — simple `name`/`code` fields, no document-number generation, RBAC permission code `MASTERS`. Build one master fully (UOM is simplest) as the template, then replicate for the rest.
Every master (UOM, Item Categories, Brands, GST Rates, Warehouses, Payment Terms, Delivery Terms, Departments, Designations, Plants, etc.) follows the **same four-file pattern as Vendors**, scaled down — simple `name`/`code` fields, no document-number generation, RBAC permission code `MASTERS`. Build one master fully (UOM is simplest) as the template, then replicate for the rest. Assets use the same item category / subcategory masters (no separate asset category APIs).
`src/modules/masters/index.js` aggregates all sub-routers:
@ -1197,7 +1196,6 @@ router.use('/gst-rates', require('./gst-rates/gst-rates.routes'));
router.use('/warehouses', require('./warehouses/warehouses.routes'));
router.use('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
router.use('/asset-categories', require('./asset-categories/asset-categories.routes'));
router.use('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes'));
router.use('/plants', require('./plants/plants.routes'));
@ -1276,7 +1274,6 @@ BigInt.prototype.toJSON = function () {
/api/v1/masters/warehouses
/api/v1/masters/payment-terms
/api/v1/masters/delivery-terms
/api/v1/masters/asset-categories
/api/v1/masters/departments
/api/v1/masters/designations
/api/v1/masters/plants

View File

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

View File

@ -210,27 +210,36 @@
}
.diagram-viewport {
overflow: auto;
max-height: 70vh;
padding: 1rem;
cursor: grab;
position: relative;
overflow: hidden;
height: min(70vh, 560px);
min-height: 280px;
background: #fafbfc;
touch-action: none;
user-select: none;
}
.diagram-viewport.can-pan { cursor: grab; }
.diagram-viewport.is-panning { cursor: grabbing; }
.diagram-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.diagram-canvas {
display: inline-block;
min-width: 100%;
transform-origin: 0 0;
transition: transform 0.12s ease-out;
will-change: transform;
}
.diagram-canvas .mermaid,
.diagram-canvas svg {
display: block;
margin: 0 auto;
margin: 0;
max-width: none !important;
pointer-events: none;
}
.diagram-fullscreen {
@ -302,25 +311,36 @@
.diagram-fullscreen-viewport {
flex: 1;
overflow: auto;
padding: 2rem;
cursor: grab;
display: flex;
align-items: flex-start;
justify-content: center;
position: relative;
overflow: hidden;
background: #0f172a;
touch-action: none;
user-select: none;
}
.diagram-fullscreen-viewport.can-pan { cursor: grab; }
.diagram-fullscreen-viewport.is-panning { cursor: grabbing; }
.diagram-fullscreen-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.diagram-fullscreen-canvas {
display: inline-block;
transform-origin: center center;
transform-origin: 0 0;
will-change: transform;
background: var(--surface);
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.35);
}
.diagram-fullscreen-canvas svg {
pointer-events: none;
}
.diagram-fullscreen-hint {
position: absolute;
bottom: 1rem;
@ -357,7 +377,9 @@
main { margin-left: 0; max-width: 100%; }
.diagram { break-inside: avoid; }
.diagram-toolbar { display: none; }
.diagram-viewport { max-height: none; overflow: visible; }
.diagram-viewport { height: auto; min-height: 0; overflow: visible; }
.diagram-stage { position: static; overflow: visible; }
.diagram-canvas { transform: none !important; }
}
</style>
</head>
@ -462,7 +484,7 @@ flowchart LR
M2[item-categories / subcategories / items]
M3[brands / gst-rates]
M4[payment-terms / delivery-terms]
M5[asset-categories / subcategories]
M5[item-categories / subcategories]
M6[departments / designations]
M7[locations / plants / warehouses]
M8[document-series]
@ -565,7 +587,10 @@ flowchart TD
PT --> PO
DT[delivery_terms] --> PO
ACAT[asset_categories] --> ASUB[asset_subcategories]
ICAT[item_categories] --> ISUB[item_subcategories]
ISUB --> ITEMS[items]
ICAT --> ASSETS[assets]
ISUB --> ASSETS
ACAT --> ASSET
ACAT --> GRN_AUTO[GRN auto-asset creation]
ASUB --> ASSET
@ -646,12 +671,12 @@ stateDiagram-v2
<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_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>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_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>status</code></td><td><code>IN_USE</code></td></tr>
</tbody>
@ -667,7 +692,7 @@ flowchart TB
end
subgraph Validate["normalizeAssetPayload validations"]
V1[asset_category + subcategory match]
V1[item_category + subcategory match]
V2[plant / dept / warehouse / user refs]
V3[vendor / PO / GRN / grn_item refs]
V4[disposal_date required if DISPOSED/SCRAPPED]
@ -801,7 +826,7 @@ flowchart TD
<pre class="mermaid">
flowchart TD
subgraph Inputs
CAT[asset_categories defaults]
CAT[item_categories defaults]
FORM[User form fields]
end
@ -951,9 +976,11 @@ erDiagram
items ||--o{ purchase_order_items : ordered
items ||--o{ grn_items : received
asset_categories ||--o{ asset_subcategories : has
asset_categories ||--o{ assets : classifies
asset_subcategories ||--o{ assets : sub_classifies
item_categories ||--o{ item_subcategories : has
item_categories ||--o{ items : classifies
item_categories ||--o{ assets : classifies
item_subcategories ||--o{ items : sub_classifies
item_subcategories ||--o{ assets : sub_classifies
assets ||--o{ asset_amc_contracts : has
assets ||--o{ asset_insurance_policies : insured_by
@ -1039,15 +1066,17 @@ erDiagram
<button type="button" class="diagram-fullscreen-close" id="diagram-fullscreen-close" title="Exit fullscreen (Esc)">×</button>
</div>
<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 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>
<script>
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 4;
const ZOOM_STEP = 0.2;
const ZOOM_MIN = 0.2;
const ZOOM_MAX = 5;
const ZOOM_STEP = 0.15;
const ZOOM_DEFAULT = 1;
function clampZoom(value) {
@ -1061,7 +1090,8 @@ erDiagram
<button type="button" data-action="zoom-out" title="Zoom out (-)"></button>
<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-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>
<button type="button" data-action="fullscreen" title="Fullscreen (F)">⛶ Fullscreen</button>
`;
@ -1072,100 +1102,204 @@ erDiagram
return toolbar;
}
function applyZoom(state) {
state.canvas.style.transform = `scale(${state.zoom})`;
state.label.textContent = `${Math.round(state.zoom * 100)}%`;
class DiagramViewer {
constructor({ viewport, stage, canvas, label, wheelNeedsModifier = true }) {
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) {
let panning = false;
let startX = 0;
let startY = 0;
let scrollLeft = 0;
let scrollTop = 0;
const diagramViewers = [];
let fullscreenViewer = null;
let fullscreenToolbarReady = false;
viewport.addEventListener('mousedown', (e) => {
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) {
function openFullscreen(sourceViewer) {
const overlay = document.getElementById('diagram-fullscreen');
const canvas = document.getElementById('diagram-fullscreen-canvas');
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 toolbarHost = document.getElementById('diagram-fullscreen-toolbar');
title.textContent = diagramState.title;
canvas.innerHTML = '';
const clone = diagramState.canvas.cloneNode(true);
clone.style.transform = '';
canvas.appendChild(clone);
title.textContent = sourceViewer.title || 'Diagram';
canvas.innerHTML = sourceViewer.canvas.innerHTML;
const fsState = {
zoom: diagramState.zoom,
canvas: clone,
label: toolbarHost.querySelector('[data-zoom-label]'),
viewport,
source: diagramState,
};
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);
if (!fullscreenToolbarReady) {
const toolbar = createToolbar((action) => {
if (action === 'fullscreen') return;
if (fullscreenViewer) fullscreenViewer.handleAction(action);
});
toolbarHost.appendChild(toolbar);
fullscreenToolbarReady = true;
}
fsState.label = toolbarHost.querySelector('[data-zoom-label]');
applyZoom(fsState);
activeFullscreenState = fsState;
if (!fullscreenViewer) {
fullscreenViewer = new DiagramViewer({
viewport,
stage,
canvas,
label: toolbarHost.querySelector('[data-zoom-label]'),
wheelNeedsModifier: false,
});
}
fullscreenViewer.fitToView();
overlay.classList.add('is-open');
overlay.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
}
function closeFullscreen() {
@ -1173,60 +1307,21 @@ erDiagram
overlay.classList.remove('is-open');
overlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
activeFullscreenState = 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;
}
fullscreenViewer = null;
}
function wrapDiagrams() {
document.querySelectorAll('.diagram').forEach((diagram, index) => {
const section = diagram.closest('main')?.querySelector(`h2, h3`);
let title = `Diagram ${index + 1}`;
const prevHeading = diagram.previousElementSibling;
if (prevHeading && (prevHeading.tagName === 'H2' || prevHeading.tagName === 'H3')) {
title = prevHeading.textContent.trim();
}
const toolbar = createToolbar((action) => handleDiagramAction(state, action, false));
const viewport = document.createElement('div');
viewport.className = 'diagram-viewport';
const stage = document.createElement('div');
stage.className = 'diagram-stage';
const canvas = document.createElement('div');
canvas.className = 'diagram-canvas';
@ -1234,22 +1329,32 @@ erDiagram
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(viewport);
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);
diagramViewers.push(viewer);
});
}
@ -1257,35 +1362,32 @@ erDiagram
document.addEventListener('keydown', (e) => {
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) {
closeFullscreen();
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 === '=') {
e.preventDefault();
handleDiagramAction(state, 'zoom-in', !!target);
viewer.handleAction('zoom-in');
} else if (e.key === '-' || e.key === '_') {
e.preventDefault();
handleDiagramAction(state, 'zoom-out', !!target);
viewer.handleAction('zoom-out');
} else if (e.key === '0') {
e.preventDefault();
handleDiagramAction(state, 'zoom-reset', !!target);
} else if ((e.key === 'f' || e.key === 'F') && !target) {
viewer.handleAction('zoom-reset');
} else if ((e.key === 'f' || e.key === 'F') && !fsOpen) {
e.preventDefault();
openFullscreen(state);
openFullscreen(viewer);
}
});
@ -1300,7 +1402,7 @@ erDiagram
wrapDiagrams();
mermaid.run({ querySelector: '.mermaid' }).then(() => {
diagramStates.forEach(applyZoom);
diagramViewers.forEach((viewer) => viewer.fitToView());
});
</script>
</body>

View File

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

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.
model asset_categories {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(150)
code_prefix String @unique @db.VarChar(10)
default_useful_life_years Int?
default_depreciation_method String? @db.VarChar(20)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_asset_categories_created_byTousers users? @relation("asset_categories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
users_asset_categories_updated_byTousers users? @relation("asset_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
asset_subcategories asset_subcategories[]
assets assets[]
}
model asset_subcategories {
id BigInt @id @default(autoincrement())
asset_category_id BigInt
code String @unique @db.VarChar(30)
name String @db.VarChar(150)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
users_asset_subcategories_created_byTousers users? @relation("asset_subcategories_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
users_asset_subcategories_updated_byTousers users? @relation("asset_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
assets assets[]
@@index([asset_category_id], map: "idx_asset_subcategories_category_id")
}
model asset_transfers {
id BigInt @id @default(autoincrement())
asset_id BigInt
@ -208,8 +170,8 @@ model assets {
id BigInt @id @default(autoincrement())
asset_code String @unique @db.VarChar(30)
asset_name String @db.VarChar(200)
asset_category_id BigInt
asset_subcategory_id BigInt?
item_category_id BigInt
item_subcategory_id BigInt?
brand_model String? @db.VarChar(200)
manufacturer String? @db.VarChar(200)
serial_number String? @db.VarChar(100)
@ -248,8 +210,8 @@ model assets {
asset_insurance_policies asset_insurance_policies[]
asset_service_visits asset_service_visits[]
asset_transfers asset_transfers[]
asset_categories asset_categories @relation(fields: [asset_category_id], references: [id], onUpdate: NoAction)
asset_subcategories asset_subcategories? @relation(fields: [asset_subcategory_id], references: [id], onUpdate: NoAction)
item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction)
item_subcategories item_subcategories? @relation(fields: [item_subcategory_id], references: [id], onUpdate: NoAction)
users_assets_assigned_to_user_idTousers users? @relation("assets_assigned_to_user_idTousers", fields: [assigned_to_user_id], references: [id], onUpdate: NoAction)
users_assets_created_byTousers users? @relation("assets_created_byTousers", fields: [created_by], references: [id], onUpdate: NoAction)
departments departments? @relation(fields: [department_id], references: [id], onUpdate: NoAction)
@ -261,8 +223,8 @@ model assets {
vendors_assets_vendor_idTovendors vendors? @relation("assets_vendor_idTovendors", fields: [vendor_id], references: [id], onUpdate: NoAction)
warehouse locations? @relation("AssetWarehouseLocation", fields: [warehouse_id], references: [id], onUpdate: NoAction)
@@index([asset_category_id], map: "idx_assets_category_id")
@@index([asset_subcategory_id], map: "idx_assets_subcategory_id")
@@index([item_category_id], map: "idx_assets_item_category_id")
@@index([item_subcategory_id], map: "idx_assets_item_subcategory_id")
@@index([department_id], map: "idx_assets_dept_id")
@@index([plant_id], map: "idx_assets_plant_id")
}
@ -480,6 +442,9 @@ model item_categories {
id BigInt @id @default(autoincrement())
code String @unique @db.VarChar(30)
name String @db.VarChar(100)
code_prefix String? @unique @db.VarChar(10)
default_useful_life_years Int?
default_depreciation_method String? @db.VarChar(20)
is_active Boolean @default(true)
created_by BigInt?
updated_by BigInt?
@ -490,6 +455,7 @@ model item_categories {
users_item_categories_updated_byTousers users? @relation("item_categories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
item_subcategories item_subcategories[]
items items[]
assets assets[]
}
model item_subcategories {
@ -507,6 +473,7 @@ model item_subcategories {
item_categories item_categories @relation(fields: [item_category_id], references: [id], onUpdate: NoAction)
users_item_subcategories_updated_byTousers users? @relation("item_subcategories_updated_byTousers", fields: [updated_by], references: [id], onUpdate: NoAction)
items items[]
assets assets[]
}
model items {
@ -856,10 +823,6 @@ model users {
asset_insurance_policies_asset_insurance_policies_updated_byTousers asset_insurance_policies[] @relation("asset_insurance_policies_updated_byTousers")
asset_service_visits_asset_service_visits_created_byTousers asset_service_visits[] @relation("asset_service_visits_created_byTousers")
asset_service_visits_asset_service_visits_updated_byTousers asset_service_visits[] @relation("asset_service_visits_updated_byTousers")
asset_categories_asset_categories_created_byTousers asset_categories[] @relation("asset_categories_created_byTousers")
asset_categories_asset_categories_updated_byTousers asset_categories[] @relation("asset_categories_updated_byTousers")
asset_subcategories_asset_subcategories_created_byTousers asset_subcategories[] @relation("asset_subcategories_created_byTousers")
asset_subcategories_asset_subcategories_updated_byTousers asset_subcategories[] @relation("asset_subcategories_updated_byTousers")
asset_transfers_asset_transfers_from_user_idTousers asset_transfers[] @relation("asset_transfers_from_user_idTousers")
asset_transfers_asset_transfers_to_user_idTousers asset_transfers[] @relation("asset_transfers_to_user_idTousers")
asset_transfers_asset_transfers_transferred_byTousers asset_transfers[] @relation("asset_transfers_transferred_byTousers")

View File

@ -26,7 +26,7 @@ CREATE VIEW v_asset_expiry_alerts AS
END AS alert_level
FROM asset_amc_contracts amc
JOIN assets a ON a.id = amc.asset_id
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id
JOIN locations p ON p.id = a.plant_id AND p.type = 'plant'
LEFT JOIN departments d ON d.id = a.department_id
LEFT JOIN vendors v ON v.id = amc.vendor_id
@ -47,7 +47,7 @@ UNION ALL
END
FROM asset_insurance_policies ins
JOIN assets a ON a.id = ins.asset_id
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id
JOIN locations p ON p.id = a.plant_id AND p.type = 'plant'
LEFT JOIN departments d ON d.id = a.department_id
WHERE ins.is_active = TRUE AND ins.deleted_at IS NULL AND a.deleted_at IS NULL
@ -66,7 +66,7 @@ UNION ALL
WHEN (a.warranty_expiry_date - CURRENT_DATE) <= 90 THEN 'INFO'
END
FROM assets a
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id
JOIN locations p ON p.id = a.plant_id AND p.type = 'plant'
LEFT JOIN departments d ON d.id = a.department_id
WHERE a.warranty_expiry_date IS NOT NULL AND a.deleted_at IS NULL
@ -95,7 +95,7 @@ SELECT
amc.contract_no AS amc_contract_no,
amc.end_date AS amc_end_date
FROM assets a
JOIN asset_categories ac_cat ON ac_cat.id = a.asset_category_id
JOIN item_categories ac_cat ON ac_cat.id = a.item_category_id
JOIN locations p ON p.id = a.plant_id AND p.type = 'plant'
LEFT JOIN departments d ON d.id = a.department_id
JOIN LATERAL (
@ -144,7 +144,7 @@ SELECT
(lsv.next_service_date - CURRENT_DATE) AS service_due_in_days,
a.created_at
FROM assets a
JOIN asset_categories ac ON ac.id = a.asset_category_id
JOIN item_categories ac ON ac.id = a.item_category_id
JOIN locations p ON p.id = a.plant_id AND p.type = 'plant'
LEFT JOIN departments d ON d.id = a.department_id
LEFT JOIN locations w ON w.id = a.warehouse_id AND w.type = 'warehouse'

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

View File

@ -3,13 +3,11 @@ tags:
- name: UOM
- name: Item Categories
- name: Item Subcategories
- name: Asset Subcategories
- name: Items
- name: Brands
- name: GST Rates
- name: Payment Terms
- name: Delivery Terms
- name: Asset Categories
- name: Departments
- name: Designations
- name: Locations
@ -85,6 +83,9 @@ components:
properties:
code: { type: string, example: 'RAW' }
name: { type: string, example: 'Raw Material' }
code_prefix: { type: string, nullable: true, example: 'MCH', description: Optional; used for asset code series when category is used for assets }
default_useful_life_years: { type: integer, nullable: true, example: 15 }
default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, OTHER], example: SLM }
is_active: { type: boolean, example: true }
ItemCategoriesUpdateBody:
type: object
@ -92,6 +93,9 @@ components:
properties:
code: { type: string, example: 'RAW' }
name: { type: string, example: 'Raw Material' }
code_prefix: { type: string, nullable: true, example: 'MCH' }
default_useful_life_years: { type: integer, nullable: true, example: 15 }
default_depreciation_method: { type: string, nullable: true, enum: [SLM, WDV, OTHER], example: SLM }
is_active: { type: boolean, example: true }
ItemSubcategoriesCreateBody:
type: object
@ -215,42 +219,6 @@ components:
name: { type: string, example: 'Free on Road' }
description: { type: string, example: 'Delivered to site' }
is_active: { type: boolean, example: true }
AssetCategoriesCreateBody:
type: object
required: [code, name, code_prefix, default_useful_life_years, default_depreciation_method]
properties:
code: { type: string, example: 'MCH' }
name: { type: string, example: 'Plant & Machinery' }
code_prefix: { type: string, example: 'MCH' }
default_useful_life_years: { type: integer, example: 15 }
default_depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: WDV }
is_active: { type: boolean, example: true }
AssetCategoriesUpdateBody:
type: object
minProperties: 1
properties:
code: { type: string, example: 'MCH' }
name: { type: string, example: 'Plant & Machinery' }
code_prefix: { type: string, example: 'MCH' }
default_useful_life_years: { type: integer, example: 15 }
default_depreciation_method: { type: string, enum: [SLM, WDV, OTHER], example: WDV }
is_active: { type: boolean, example: true }
AssetSubcategoriesCreateBody:
type: object
required: [asset_category_id, code, name]
properties:
asset_category_id: { type: integer, example: 1 }
code: { type: string, example: 'CNC' }
name: { type: string, example: 'CNC Machines' }
is_active: { type: boolean, example: true }
AssetSubcategoriesUpdateBody:
type: object
minProperties: 1
properties:
asset_category_id: { type: integer, example: 1 }
code: { type: string, example: 'CNC' }
name: { type: string, example: 'CNC Machines' }
is_active: { type: boolean, example: true }
DepartmentsCreateBody:
type: object
required: [name]
@ -1079,148 +1047,6 @@ paths:
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/asset-categories:
get:
tags: [Asset Categories]
summary: List Asset Categories
parameters:
- in: query
name: page
schema: { type: integer, default: 1 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 100 }
- in: query
name: search
schema: { type: string }
- in: query
name: is_active
schema: { type: boolean }
responses:
"200":
description: List fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
post:
tags: [Asset Categories]
summary: Create Asset Categorie
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssetCategoriesCreateBody" }
responses:
"201":
description: Created
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"409": { description: Duplicate key }
/masters/asset-categories/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string }
get:
tags: [Asset Categories]
summary: Get Asset Categorie by id
responses:
"200":
description: Fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
put:
tags: [Asset Categories]
summary: Update Asset Categorie
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssetCategoriesUpdateBody" }
responses:
"200":
description: Updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
delete:
tags: [Asset Categories]
summary: Delete Asset Categorie
responses:
"200":
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/asset-subcategories:
get:
tags: [Asset Subcategories]
summary: List Asset Subcategories
parameters:
- in: query
name: page
schema: { type: integer, default: 1 }
- in: query
name: limit
schema: { type: integer, default: 20, maximum: 100 }
- in: query
name: search
schema: { type: string }
- in: query
name: is_active
schema: { type: boolean }
- in: query
name: asset_category_id
schema: { type: integer }
description: Filter by parent asset category
responses:
"200":
description: List fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
post:
tags: [Asset Subcategories]
summary: Create Asset Subcategory
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssetSubcategoriesCreateBody" }
responses:
"201":
description: Created
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"409": { description: Duplicate key }
/masters/asset-subcategories/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string }
get:
tags: [Asset Subcategories]
summary: Get Asset Subcategory by id
responses:
"200":
description: Fetched
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
put:
tags: [Asset Subcategories]
summary: Update Asset Subcategory
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssetSubcategoriesUpdateBody" }
responses:
"200":
description: Updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
delete:
tags: [Asset Subcategories]
summary: Delete Asset Subcategory
responses:
"200":
description: Deleted
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"404": { description: Not found }
/masters/departments:
get:
tags: [Departments]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -108,6 +108,8 @@ const getReceivablePoOrThrow = async (poId) => {
item_code: true,
item_name: true,
is_asset_item: true,
item_category_id: true,
item_subcategory_id: true,
},
},
},
@ -175,48 +177,51 @@ const validateAndBuildItems = async (po, payloadItems) => {
const item = poItem.items;
if (item.is_asset_item && acceptedQty > 0) {
if (!row.asset_category_id) {
const categoryId = row.item_category_id ?? item.item_category_id;
const subcategoryId = row.item_subcategory_id ?? item.item_subcategory_id;
if (!categoryId) {
throw new ApiError(
422,
`Line ${row.line_no}: asset_category_id is required for asset items`
`Line ${row.line_no}: item_category_id is required for asset items (set on item master or GRN line)`
);
}
if (!row.asset_subcategory_id) {
if (!subcategoryId) {
throw new ApiError(
422,
`Line ${row.line_no}: asset_subcategory_id is required for asset items`
`Line ${row.line_no}: item_subcategory_id is required for asset items (set on item master or GRN line)`
);
}
const assetCategory = await prisma.asset_categories.findFirst({
where: { id: BigInt(row.asset_category_id), deleted_at: null, is_active: true },
const itemCategory = await prisma.item_categories.findFirst({
where: { id: BigInt(categoryId), deleted_at: null, is_active: true },
});
if (!assetCategory) throw new ApiError(422, `Line ${row.line_no}: invalid asset_category_id`);
if (!itemCategory) throw new ApiError(422, `Line ${row.line_no}: invalid item_category_id`);
const assetSubcategory = await prisma.asset_subcategories.findFirst({
where: { id: BigInt(row.asset_subcategory_id), deleted_at: null, is_active: true },
const itemSubcategory = await prisma.item_subcategories.findFirst({
where: { id: BigInt(subcategoryId), deleted_at: null, is_active: true },
});
if (!assetSubcategory) {
throw new ApiError(422, `Line ${row.line_no}: invalid asset_subcategory_id`);
if (!itemSubcategory) {
throw new ApiError(422, `Line ${row.line_no}: invalid item_subcategory_id`);
}
if (assetSubcategory.asset_category_id.toString() !== assetCategory.id.toString()) {
if (itemSubcategory.item_category_id.toString() !== itemCategory.id.toString()) {
throw new ApiError(
422,
`Line ${row.line_no}: asset_subcategory_id does not belong to asset_category_id`
`Line ${row.line_no}: item_subcategory_id does not belong to item_category_id`
);
}
const units = Math.floor(acceptedQty);
const assetCodes = [];
for (let unit = 0; unit < units; unit += 1) {
assetCodes.push(await nextDocumentNumber(assetSeriesCode(assetCategory.code)));
assetCodes.push(await nextDocumentNumber(assetSeriesCode(itemCategory.code)));
}
assetPlans.push({
po_item_id: poItem.id,
item,
assetCategory,
assetSubcategory,
itemCategory,
itemSubcategory,
plantId: po.plant_id,
assetCodes,
});

View File

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

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('/payment-terms', require('./payment-terms/payment-terms.routes'));
router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes'));
router.use('/asset-categories', require('./asset-categories/asset-categories.routes'));
router.use('/asset-subcategories', require('./asset-subcategories/asset-subcategories.routes'));
router.use('/departments', require('./departments/departments.routes'));
router.use('/designations', require('./designations/designations.routes'));
router.use('/document-series', require('./document-series/document-series.routes'));

View File

@ -1,5 +1,7 @@
const { buildMasterService } = require('../_shared/master.factory');
const emptyToNull = (value) => (value === '' ? null : value);
const config = {
modelName: 'item_categories',
tableName: 'item_categories',
@ -18,6 +20,24 @@ const config = {
uppercase: false,
searchable: true,
},
{
name: 'code_prefix',
type: 'string',
uppercase: true,
searchable: false,
},
{
name: 'default_useful_life_years',
type: 'int',
uppercase: false,
searchable: false,
},
{
name: 'default_depreciation_method',
type: 'string',
uppercase: false,
searchable: false,
},
{
name: 'is_active',
type: 'boolean',
@ -41,10 +61,24 @@ const config = {
const service = buildMasterService(config);
const normalizeAssetDefaults = (payload) => {
if (!payload || typeof payload !== 'object') return payload;
const out = { ...payload };
if (Object.prototype.hasOwnProperty.call(out, 'code_prefix')) {
out.code_prefix = emptyToNull(out.code_prefix);
}
if (Object.prototype.hasOwnProperty.call(out, 'default_depreciation_method')) {
out.default_depreciation_method = emptyToNull(out.default_depreciation_method);
}
return out;
};
module.exports = {
createItemCategories: service.createOne,
createItemCategories: (payload, userId, requestId) =>
service.createOne(normalizeAssetDefaults(payload), userId, requestId),
listItemCategories: service.list,
getItemCategoriesById: service.getOne,
updateItemCategories: service.updateOne,
updateItemCategories: (id, payload, userId, requestId) =>
service.updateOne(id, normalizeAssetDefaults(payload), userId, requestId),
deleteItemCategories: service.removeOne,
};

View File

@ -1,15 +1,28 @@
const Joi = require('joi');
const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation');
const { DEPRECIATION_METHODS } = require('../../assets/assets.constants');
const createSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
code_prefix: masterCode({ max: 10 }).allow(null, ''),
default_useful_life_years: Joi.number().integer().min(0).allow(null).optional(),
default_depreciation_method: Joi.string()
.valid(...DEPRECIATION_METHODS)
.allow(null, '')
.optional(),
is_active: Joi.boolean().optional(),
});
const updateSchema = Joi.object({
code: masterCode({ max: 30 }),
name: masterName({ max: 150 }),
code_prefix: masterCode({ max: 10 }).allow(null, ''),
default_useful_life_years: Joi.number().integer().min(0).allow(null).optional(),
default_depreciation_method: Joi.string()
.valid(...DEPRECIATION_METHODS)
.allow(null, '')
.optional(),
is_active: Joi.boolean().optional(),
}).min(1);