From a434216fdbc7e0165cffa10618eae2484d14e2f0 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Fri, 10 Jul 2026 14:47:18 +0530 Subject: [PATCH] Items code auto generated and hs_code masted added --- BACKEND_SETUP.md | 3 + BACKEND_TASKS.md | 5 +- scripts/patch-items-drop-hsn-code.sql | 12 + scripts/patch-items-item-code-hsn.sql | 33 ++ scripts/seed-hsn-codes.sql | 40 +++ src/docs/completed-routes.yaml | 119 ++++++- .../masters/hsn-codes/hsn-codes.controller.js | 30 ++ .../masters/hsn-codes/hsn-codes.routes.js | 17 + .../masters/hsn-codes/hsn-codes.service.js | 38 ++ .../masters/hsn-codes/hsn-codes.validation.js | 31 ++ src/modules/masters/index.js | 1 + src/modules/masters/items/items.service.js | 336 ++++++++++++------ src/modules/masters/items/items.validation.js | 54 +-- .../purchase-orders.service.js | 2 +- 14 files changed, 567 insertions(+), 154 deletions(-) create mode 100644 scripts/patch-items-drop-hsn-code.sql create mode 100644 scripts/patch-items-item-code-hsn.sql create mode 100644 scripts/seed-hsn-codes.sql create mode 100644 src/modules/masters/hsn-codes/hsn-codes.controller.js create mode 100644 src/modules/masters/hsn-codes/hsn-codes.routes.js create mode 100644 src/modules/masters/hsn-codes/hsn-codes.service.js create mode 100644 src/modules/masters/hsn-codes/hsn-codes.validation.js diff --git a/BACKEND_SETUP.md b/BACKEND_SETUP.md index 82fe63e..a2da653 100644 --- a/BACKEND_SETUP.md +++ b/BACKEND_SETUP.md @@ -75,6 +75,7 @@ erp-backend/ │ │ │ ├── items/ │ │ │ ├── brands/ │ │ │ ├── gst-rates/ +│ │ │ ├── hsn-codes/ │ │ │ ├── warehouses/ │ │ │ ├── payment-terms/ │ │ │ ├── delivery-terms/ @@ -1193,6 +1194,7 @@ router.use('/item-subcategories', require('./item-subcategories/item-subcategori router.use('/items', require('./items/items.routes')); router.use('/brands', require('./brands/brands.routes')); router.use('/gst-rates', require('./gst-rates/gst-rates.routes')); +router.use('/hsn-codes', require('./hsn-codes/hsn-codes.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')); @@ -1271,6 +1273,7 @@ BigInt.prototype.toJSON = function () { /api/v1/masters/items /api/v1/masters/brands /api/v1/masters/gst-rates +/api/v1/masters/hsn-codes /api/v1/masters/warehouses /api/v1/masters/payment-terms /api/v1/masters/delivery-terms diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md index cd3082c..853352a 100644 --- a/BACKEND_TASKS.md +++ b/BACKEND_TASKS.md @@ -164,9 +164,10 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL | [x] | UOM | `/masters/uom` | | [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] | Items | `/masters/items` | `item_code` auto via `ITEM` document series; HSN via `hsn_code_id` | | [x] | Brands | `/masters/brands` | | [x] | GST Rates | `/masters/gst-rates` | +| [x] | HSN Codes | `/masters/hsn-codes` | Search by `code` or `description` | | [x] | Payment Terms | `/masters/payment-terms` | | [x] | Delivery Terms | `/masters/delivery-terms` | | [~] | Asset Categories | removed — use Item Categories | @@ -178,7 +179,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:** 12 modules × 5 endpoints (+ plants/warehouses aliases) — asset category masters retired; assets use item categories +**Masters total:** 13 modules × 5 endpoints (+ plants/warehouses aliases) --- diff --git a/scripts/patch-items-drop-hsn-code.sql b/scripts/patch-items-drop-hsn-code.sql new file mode 100644 index 0000000..7c2735a --- /dev/null +++ b/scripts/patch-items-drop-hsn-code.sql @@ -0,0 +1,12 @@ +-- Remove items.hsn_code column; HSN is referenced via hsn_code_id -> hsn_codes master + +BEGIN; + +ALTER TABLE items + DROP COLUMN IF EXISTS hsn_code; + +COMMIT; + +-- Verify: +-- SELECT column_name FROM information_schema.columns +-- WHERE table_name = 'items' AND column_name LIKE '%hsn%'; diff --git a/scripts/patch-items-item-code-hsn.sql b/scripts/patch-items-item-code-hsn.sql new file mode 100644 index 0000000..0e51a50 --- /dev/null +++ b/scripts/patch-items-item-code-hsn.sql @@ -0,0 +1,33 @@ +-- Items: ITEM document series for auto item_code generation +-- Run before deploying BE that auto-generates item_code via document_series. + +BEGIN; + +INSERT INTO document_series (code, prefix, current_number, padding, description, is_active) +SELECT + 'ITEM', + 'ITM-', + COALESCE(( + SELECT COUNT(*)::int + FROM items + WHERE deleted_at IS NULL + ), 0), + 5, + 'Item master code', + TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM document_series WHERE code = 'ITEM' +); + +UPDATE document_series ds +SET current_number = GREATEST( + ds.current_number, + COALESCE((SELECT COUNT(*)::int FROM items WHERE deleted_at IS NULL), 0) +), +updated_at = NOW() +WHERE ds.code = 'ITEM'; + +COMMIT; + +-- Verify: +-- SELECT code, prefix, current_number, padding FROM document_series WHERE code = 'ITEM'; diff --git a/scripts/seed-hsn-codes.sql b/scripts/seed-hsn-codes.sql new file mode 100644 index 0000000..9f8e0fc --- /dev/null +++ b/scripts/seed-hsn-codes.sql @@ -0,0 +1,40 @@ +-- Seed common HSN/SAC codes for items and purchase orders +-- Idempotent: upserts on code + +INSERT INTO hsn_codes (code, description, is_active) +VALUES + ('27101990', 'Mineral oils and preparations', TRUE), + ('28289040', 'Chlorides, bromides and iodides', TRUE), + ('29051100', 'Methanol (methyl alcohol)', TRUE), + ('34029099', 'Organic surface-active agents (other)', TRUE), + ('39011010', 'Polyethylene, specific gravity < 0.94', TRUE), + ('39201019', 'Plastic plates, sheets, film (other)', TRUE), + ('39232990', 'Plastic sacks and bags (other)', TRUE), + ('40103999', 'Transmission belts and V-belts (other)', TRUE), + ('48191000', 'Cartons, boxes and cases of corrugated paper', TRUE), + ('72142090', 'Bars and rods of iron or non-alloy steel (other)', TRUE), + ('73181500', 'Screws and bolts with nuts or washers', TRUE), + ('73269099', 'Other articles of iron or steel', TRUE), + ('84148090', 'Air pumps, compressors and fans (parts/other)', TRUE), + ('84198990', 'Machinery for treatment of materials by temperature (other)', TRUE), + ('84219900', 'Parts for filtering or purifying machinery', TRUE), + ('84283300', 'Conveyors (other)', TRUE), + ('84748000', 'Machinery for crushing, grinding or mixing', TRUE), + ('84799090', 'Parts of machines of heading 8479', TRUE), + ('84818090', 'Taps, cocks, valves and similar appliances (other)', TRUE), + ('85015210', 'AC motors, multi-phase, output <= 750 W', TRUE), + ('85044090', 'Static converters (other)', TRUE), + ('85369090', 'Electrical apparatus for switching (other)', TRUE), + ('85444999', 'Electric conductors, insulated (other)', TRUE), + ('87089900', 'Parts and accessories of motor vehicles', TRUE), + ('998719', 'Maintenance and repair services of machinery', TRUE), + ('998313', 'IT design and development services', TRUE) +ON CONFLICT (code) DO UPDATE +SET + description = EXCLUDED.description, + is_active = EXCLUDED.is_active, + updated_at = NOW(); + +-- Verify: +-- SELECT COUNT(*) FROM hsn_codes WHERE is_active = TRUE; +-- SELECT id, code, description FROM hsn_codes ORDER BY code; diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 692b078..7dbbfa9 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -6,6 +6,7 @@ tags: - name: Items - name: Brands - name: GST Rates + - name: HSN Codes - name: Payment Terms - name: Delivery Terms - name: Departments @@ -115,40 +116,45 @@ components: is_active: { type: boolean, example: true } ItemsCreateBody: type: object - required: [item_code, item_name, item_category_id, uom_id, is_asset_item, min_order_qty, reorder_level] + required: [item_name, item_category_id, uom_id] properties: - item_code: { type: string, example: 'RM-LABSA' } item_name: { type: string, example: 'LABSA' } item_category_id: { type: integer, example: 1 } - item_subcategory_id: { type: integer, example: 1 } + item_subcategory_id: { type: integer, nullable: true, example: 1 } uom_id: { type: integer, example: 1 } - hsn_code_id: { type: integer, example: 1 } - gst_rate_id: { type: integer, example: 4 } - brand_id: { type: integer, example: 1 } + hsn_code_id: { type: integer, nullable: true, example: 1 } + gst_rate_id: { type: integer, nullable: true, example: 4 } + brand_id: { type: integer, nullable: true, example: 1 } is_asset_item: { type: boolean, example: false } description: { type: string, example: 'Raw material' } specification: { type: string, example: '96% purity' } - min_order_qty: { type: number, example: 100.0 } - reorder_level: { type: number, example: 500.0 } + min_order_qty: { type: number, nullable: true, example: 100.0 } + reorder_level: { type: number, nullable: true, example: 500.0 } is_active: { type: boolean, example: true } ItemsUpdateBody: type: object minProperties: 1 properties: - item_code: { type: string, example: 'RM-LABSA' } item_name: { type: string, example: 'LABSA' } item_category_id: { type: integer, example: 1 } - item_subcategory_id: { type: integer, example: 1 } + item_subcategory_id: { type: integer, nullable: true, example: 1 } uom_id: { type: integer, example: 1 } - hsn_code_id: { type: integer, example: 1 } - gst_rate_id: { type: integer, example: 4 } - brand_id: { type: integer, example: 1 } + hsn_code_id: { type: integer, nullable: true, example: 1 } + gst_rate_id: { type: integer, nullable: true, example: 4 } + brand_id: { type: integer, nullable: true, example: 1 } is_asset_item: { type: boolean, example: false } description: { type: string, example: 'Raw material' } specification: { type: string, example: '96% purity' } - min_order_qty: { type: number, example: 100.0 } - reorder_level: { type: number, example: 500.0 } + min_order_qty: { type: number, nullable: true, example: 100.0 } + reorder_level: { type: number, nullable: true, example: 500.0 } is_active: { type: boolean, example: true } + ItemsResponse: + type: object + properties: + id: { type: string, example: '1' } + item_code: { type: string, example: 'ITM-00006', description: Auto-generated on create via ITEM document series; read-only } + item_name: { type: string, example: 'LABSA' } + hsn_code_id: { type: integer, nullable: true, example: 1 } BrandsCreateBody: type: object required: [code, name, brand_type] @@ -185,6 +191,20 @@ components: rate_pct: { type: number, example: 18 } description: { type: string, example: 'GST 18%' } is_active: { type: boolean, example: true } + HsnCodesCreateBody: + type: object + required: [code] + properties: + code: { type: string, example: '34029099', description: '4–8 digit HSN/SAC code' } + description: { type: string, example: 'Organic surface-active agents' } + is_active: { type: boolean, example: true } + HsnCodesUpdateBody: + type: object + minProperties: 1 + properties: + code: { type: string, example: '34029099' } + description: { type: string, example: 'Organic surface-active agents' } + is_active: { type: boolean, example: true } PaymentTermsCreateBody: type: object required: [code, name, credit_days] @@ -909,6 +929,75 @@ paths: description: Deleted content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } "404": { description: Not found } + /masters/hsn-codes: + get: + tags: [HSN Codes] + summary: List HSN Codes + 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, description: Search by code or description } + - in: query + name: is_active + schema: { type: boolean } + responses: + "200": + description: List fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + post: + tags: [HSN Codes] + summary: Create HSN Code + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/HsnCodesCreateBody" } + responses: + "201": + description: Created + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "409": { description: Duplicate code } + /masters/hsn-codes/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: string } + get: + tags: [HSN Codes] + summary: Get HSN Code by id + responses: + "200": + description: Fetched + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + put: + tags: [HSN Codes] + summary: Update HSN Code + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/HsnCodesUpdateBody" } + responses: + "200": + description: Updated + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } + delete: + tags: [HSN Codes] + summary: Delete HSN Code + responses: + "200": + description: Deleted + content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + "404": { description: Not found } /masters/payment-terms: get: tags: [Payment Terms] diff --git a/src/modules/masters/hsn-codes/hsn-codes.controller.js b/src/modules/masters/hsn-codes/hsn-codes.controller.js new file mode 100644 index 0000000..fabf61a --- /dev/null +++ b/src/modules/masters/hsn-codes/hsn-codes.controller.js @@ -0,0 +1,30 @@ +const asyncHandler = require('../../../utils/asyncHandler'); +const ApiResponse = require('../../../utils/ApiResponse'); +const service = require('./hsn-codes.service'); + +const create = asyncHandler(async (req, res) => { + const data = await service.createHsnCodes(req.body, req.user?.id, req.id); + res.status(201).json(new ApiResponse(201, data, 'hsn_codes created successfully')); +}); + +const list = asyncHandler(async (req, res) => { + const result = await service.listHsnCodes(req.query); + res.json(new ApiResponse(200, result.data, 'hsn_codes list fetched', result.meta)); +}); + +const getOne = asyncHandler(async (req, res) => { + const data = await service.getHsnCodesById(req.params.id); + res.json(new ApiResponse(200, data, 'hsn_codes fetched')); +}); + +const update = asyncHandler(async (req, res) => { + const data = await service.updateHsnCodes(req.params.id, req.body, req.user?.id, req.id); + res.json(new ApiResponse(200, data, 'hsn_codes updated successfully')); +}); + +const remove = asyncHandler(async (req, res) => { + await service.deleteHsnCodes(req.params.id, req.user?.id, req.id); + res.json(new ApiResponse(200, null, 'hsn_codes deleted successfully')); +}); + +module.exports = { create, list, getOne, update, remove }; diff --git a/src/modules/masters/hsn-codes/hsn-codes.routes.js b/src/modules/masters/hsn-codes/hsn-codes.routes.js new file mode 100644 index 0000000..5ca1246 --- /dev/null +++ b/src/modules/masters/hsn-codes/hsn-codes.routes.js @@ -0,0 +1,17 @@ +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('./hsn-codes.controller'); +const { createSchema, updateSchema, listQuerySchema } = require('./hsn-codes.validation'); + +const router = express.Router(); + +router.use(authenticate); +router.get('/', authorize('MASTERS', 'view'), validate(listQuerySchema, 'query'), controller.list); +router.get('/:id', authorize('MASTERS', 'view'), controller.getOne); +router.post('/', authorize('MASTERS', 'create'), validate(createSchema), controller.create); +router.put('/:id', authorize('MASTERS', 'edit'), validate(updateSchema), controller.update); +router.delete('/:id', authorize('MASTERS', 'delete'), controller.remove); + +module.exports = router; diff --git a/src/modules/masters/hsn-codes/hsn-codes.service.js b/src/modules/masters/hsn-codes/hsn-codes.service.js new file mode 100644 index 0000000..0aea82b --- /dev/null +++ b/src/modules/masters/hsn-codes/hsn-codes.service.js @@ -0,0 +1,38 @@ +const { buildMasterService } = require('../_shared/master.factory'); + +const config = { + modelName: 'hsn_codes', + tableName: 'hsn_codes', + uniqueField: 'code', + softDelete: false, + fields: [ + { + name: 'code', + type: 'string', + uppercase: false, + searchable: true, + }, + { + name: 'description', + type: 'string', + uppercase: false, + searchable: true, + }, + { + name: 'is_active', + type: 'boolean', + uppercase: false, + searchable: false, + }, + ], +}; + +const service = buildMasterService(config); + +module.exports = { + createHsnCodes: service.createOne, + listHsnCodes: service.list, + getHsnCodesById: service.getOne, + updateHsnCodes: service.updateOne, + deleteHsnCodes: service.removeOne, +}; diff --git a/src/modules/masters/hsn-codes/hsn-codes.validation.js b/src/modules/masters/hsn-codes/hsn-codes.validation.js new file mode 100644 index 0000000..b53d7fc --- /dev/null +++ b/src/modules/masters/hsn-codes/hsn-codes.validation.js @@ -0,0 +1,31 @@ +const Joi = require('joi'); + +const HSN_CODE_REGEX = /^[0-9]{4,8}$/; +const HSN_CODE_MESSAGE = 'HSN/SAC code must be 4 to 8 digits'; + +const hsnCodeField = Joi.string() + .trim() + .max(20) + .pattern(HSN_CODE_REGEX) + .messages({ 'string.pattern.base': HSN_CODE_MESSAGE }); + +const createSchema = Joi.object({ + code: hsnCodeField.required(), + description: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().optional(), +}); + +const updateSchema = Joi.object({ + code: hsnCodeField.optional(), + description: Joi.string().allow(null, '').optional(), + is_active: Joi.boolean().optional(), +}).min(1); + +const listQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + search: Joi.string().allow('').optional(), + is_active: Joi.boolean().optional(), +}); + +module.exports = { createSchema, updateSchema, listQuerySchema }; diff --git a/src/modules/masters/index.js b/src/modules/masters/index.js index 3f202e3..9c34935 100644 --- a/src/modules/masters/index.js +++ b/src/modules/masters/index.js @@ -6,6 +6,7 @@ router.use('/uom', require('./uom/uom.routes')); router.use('/item-categories', require('./item-categories/item-categories.routes')); router.use('/brands', require('./brands/brands.routes')); router.use('/gst-rates', require('./gst-rates/gst-rates.routes')); +router.use('/hsn-codes', require('./hsn-codes/hsn-codes.routes')); router.use('/payment-terms', require('./payment-terms/payment-terms.routes')); router.use('/delivery-terms', require('./delivery-terms/delivery-terms.routes')); router.use('/departments', require('./departments/departments.routes')); diff --git a/src/modules/masters/items/items.service.js b/src/modules/masters/items/items.service.js index e669cb5..de05316 100644 --- a/src/modules/masters/items/items.service.js +++ b/src/modules/masters/items/items.service.js @@ -1,116 +1,232 @@ -const { buildMasterService } = require('../_shared/master.factory'); +const prisma = require('../../../config/prisma'); +const ApiError = require('../../../utils/ApiError'); +const auditLog = require('../../../utils/auditLog'); +const { getPagination } = require('../../../utils/pagination'); +const { nextDocumentNumber } = require('../../../utils/generateCode'); -const config = { - modelName: 'items', - tableName: 'items', - uniqueField: 'item_code', - softDelete: true, - fields: [ - { - name: 'item_code', - type: 'string', - uppercase: true, - searchable: true, - }, - { - name: 'item_name', - type: 'string', - uppercase: false, - searchable: true, - }, - { - name: 'item_category_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'item_subcategory_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'uom_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'hsn_code_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'gst_rate_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'brand_id', - type: 'int', - uppercase: false, - searchable: false, - }, - { - name: 'is_asset_item', - type: 'boolean', - uppercase: false, - searchable: false, - }, - { - name: 'description', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'specification', - type: 'string', - uppercase: false, - searchable: false, - }, - { - name: 'min_order_qty', - type: 'decimal', - uppercase: false, - searchable: false, - }, - { - name: 'reorder_level', - type: 'decimal', - 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 ITEM_SERIES_CODE = 'ITEM'; + +const SEARCH_FIELDS = ['item_code', 'item_name']; + +const normalizeString = (value) => { + if (value === undefined || value === null) return value; + const str = String(value).trim(); + return str === '' ? null : str; }; -const service = buildMasterService(config); +const normalizePayload = (payload, { isCreate = false } = {}) => { + const data = { ...payload }; + + if (Object.prototype.hasOwnProperty.call(data, 'item_name') && data.item_name !== undefined) { + data.item_name = String(data.item_name).trim(); + } + if (Object.prototype.hasOwnProperty.call(data, 'description')) { + data.description = normalizeString(data.description); + } + if (Object.prototype.hasOwnProperty.call(data, 'specification')) { + data.specification = normalizeString(data.specification); + } + + const bigintFields = [ + 'item_category_id', + 'item_subcategory_id', + 'uom_id', + 'hsn_code_id', + 'gst_rate_id', + 'brand_id', + ]; + for (const field of bigintFields) { + if (data[field] !== undefined && data[field] !== null && data[field] !== '') { + data[field] = BigInt(data[field]); + } else if (data[field] === '' || data[field] === null) { + data[field] = null; + } + } + + if (data.min_order_qty !== undefined && data.min_order_qty !== null) { + data.min_order_qty = Number(data.min_order_qty); + } + if (data.reorder_level !== undefined && data.reorder_level !== null) { + data.reorder_level = Number(data.reorder_level); + } + + if (!isCreate) { + delete data.item_code; + } + + return data; +}; + +const assertReference = async (table, id, label, { requireActive = true } = {}) => { + if (!id) return null; + const row = await prisma[table].findFirst({ + where: { id: BigInt(id), deleted_at: null }, + }); + if (!row) throw new ApiError(422, `Invalid ${label}`); + if (requireActive && row.is_active === false) throw new ApiError(422, `${label} is inactive`); + return row; +}; + +const assertHsnCode = async (hsnCodeId) => { + if (!hsnCodeId) return null; + const row = await prisma.hsn_codes.findFirst({ + where: { id: BigInt(hsnCodeId) }, + }); + if (!row) throw new ApiError(422, 'Invalid hsn_code_id'); + if (row.is_active === false) throw new ApiError(422, 'hsn_code_id is inactive'); + return row; +}; + +const assertItemSubcategory = async (subcategoryId, categoryId) => { + const subcategory = await assertReference('item_subcategories', subcategoryId, 'item_subcategory_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 validateItemPayload = async (payload, { isCreate = false } = {}) => { + if (isCreate) { + if (!payload.item_name) throw new ApiError(422, 'item_name is required'); + if (!payload.item_category_id) throw new ApiError(422, 'item_category_id is required'); + if (!payload.uom_id) throw new ApiError(422, 'uom_id is required'); + } + + if (payload.item_category_id) { + await assertReference('item_categories', payload.item_category_id, 'item_category_id'); + } + if (payload.item_subcategory_id) { + const categoryId = payload.item_category_id; + if (!categoryId) throw new ApiError(422, 'item_category_id is required when item_subcategory_id is set'); + await assertItemSubcategory(payload.item_subcategory_id, categoryId); + } + if (payload.uom_id) await assertReference('uom', payload.uom_id, 'uom_id'); + if (payload.hsn_code_id !== undefined) await assertHsnCode(payload.hsn_code_id); + if (payload.gst_rate_id) { + await assertReference('gst_rates', payload.gst_rate_id, 'gst_rate_id', { requireActive: false }); + } + if (payload.brand_id) { + await assertReference('brands', payload.brand_id, 'brand_id', { requireActive: false }); + } +}; + +const createItems = async (payload, userId, requestId) => { + const data = normalizePayload(payload, { isCreate: true }); + await validateItemPayload(data, { isCreate: true }); + + data.item_code = await nextDocumentNumber(ITEM_SERIES_CODE); + data.created_by = userId ? BigInt(userId) : null; + data.updated_by = userId ? BigInt(userId) : null; + if (data.is_asset_item === undefined) data.is_asset_item = false; + if (data.is_active === undefined) data.is_active = true; + + const created = await prisma.items.create({ data }); + + await auditLog({ + tableName: 'items', + recordId: created.id, + action: 'CREATE', + oldValue: null, + newValue: created, + userId, + requestId, + }); + + return created; +}; + +const listItems = async (query) => { + const { page, limit, skip } = getPagination(query); + + const where = { + deleted_at: null, + ...(query.is_active !== undefined ? { is_active: query.is_active } : {}), + ...(query.item_category_id ? { item_category_id: BigInt(query.item_category_id) } : {}), + ...(query.search + ? { + OR: SEARCH_FIELDS.map((name) => ({ + [name]: { contains: query.search, mode: 'insensitive' }, + })), + } + : {}), + }; + + const [data, total] = await Promise.all([ + prisma.items.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }), + prisma.items.count({ where }), + ]); + + return { data, meta: { page, limit, total } }; +}; + +const getItemsById = async (id) => { + const one = await prisma.items.findFirst({ + where: { id: BigInt(id), deleted_at: null }, + }); + if (!one) throw new ApiError(404, 'items not found'); + return one; +}; + +const updateItems = async (id, payload, userId, requestId) => { + const existing = await getItemsById(id); + const data = normalizePayload(payload); + + if (data.item_category_id === undefined) data.item_category_id = existing.item_category_id; + if (data.item_subcategory_id === undefined) data.item_subcategory_id = existing.item_subcategory_id; + + await validateItemPayload(data); + + data.updated_by = userId ? BigInt(userId) : null; + + const updated = await prisma.items.update({ + where: { id: BigInt(id) }, + data, + }); + + await auditLog({ + tableName: 'items', + recordId: id, + action: 'UPDATE', + oldValue: existing, + newValue: updated, + userId, + requestId, + }); + + return updated; +}; + +const deleteItems = async (id, userId, requestId) => { + const existing = await getItemsById(id); + + await prisma.items.update({ + where: { id: BigInt(id) }, + data: { + deleted_at: new Date(), + updated_by: userId ? BigInt(userId) : null, + }, + }); + + await auditLog({ + tableName: 'items', + recordId: id, + action: 'DELETE', + oldValue: existing, + newValue: { deleted_at: new Date() }, + userId, + requestId, + }); +}; module.exports = { - createItems: service.createOne, - listItems: service.list, - getItemsById: service.getOne, - updateItems: service.updateOne, - deleteItems: service.removeOne, + createItems, + listItems, + getItemsById, + updateItems, + deleteItems, }; diff --git a/src/modules/masters/items/items.validation.js b/src/modules/masters/items/items.validation.js index 7a7a5cf..daf9534 100644 --- a/src/modules/masters/items/items.validation.js +++ b/src/modules/masters/items/items.validation.js @@ -1,38 +1,40 @@ const Joi = require('joi'); -const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation'); +const { masterName, listQuerySchema } = require('../_shared/masters.validation'); const createSchema = Joi.object({ - item_code: masterCode({ max: 30 }), - item_name: masterName({ max: 200 }), - item_category_id: Joi.number().integer().optional(), - item_subcategory_id: Joi.number().integer().optional(), - uom_id: Joi.number().integer().optional(), - hsn_code_id: Joi.number().integer().optional(), - gst_rate_id: Joi.number().integer().optional(), - brand_id: Joi.number().integer().optional(), - is_asset_item: Joi.boolean().optional(), - description: Joi.string().allow('').optional(), - specification: Joi.string().allow('').optional(), - min_order_qty: Joi.number().precision(4).optional(), - reorder_level: Joi.number().precision(4).optional(), + item_name: masterName({ max: 200, required: true }), + item_category_id: Joi.number().integer().positive().required(), + item_subcategory_id: Joi.number().integer().positive().allow(null).optional(), + uom_id: Joi.number().integer().positive().required(), + hsn_code_id: Joi.number().integer().positive().allow(null).optional(), + gst_rate_id: Joi.number().integer().positive().allow(null).optional(), + brand_id: Joi.number().integer().positive().allow(null).optional(), + is_asset_item: Joi.boolean().default(false), + description: Joi.string().allow(null, '').optional(), + specification: Joi.string().allow(null, '').optional(), + min_order_qty: Joi.number().precision(4).allow(null).optional(), + reorder_level: Joi.number().precision(4).allow(null).optional(), is_active: Joi.boolean().optional(), }); const updateSchema = Joi.object({ - item_code: masterCode({ max: 30 }), item_name: masterName({ max: 200 }), - item_category_id: Joi.number().integer().optional(), - item_subcategory_id: Joi.number().integer().optional(), - uom_id: Joi.number().integer().optional(), - hsn_code_id: Joi.number().integer().optional(), - gst_rate_id: Joi.number().integer().optional(), - brand_id: Joi.number().integer().optional(), + item_category_id: Joi.number().integer().positive().optional(), + item_subcategory_id: Joi.number().integer().positive().allow(null).optional(), + uom_id: Joi.number().integer().positive().optional(), + hsn_code_id: Joi.number().integer().positive().allow(null).optional(), + gst_rate_id: Joi.number().integer().positive().allow(null).optional(), + brand_id: Joi.number().integer().positive().allow(null).optional(), is_asset_item: Joi.boolean().optional(), - description: Joi.string().allow('').optional(), - specification: Joi.string().allow('').optional(), - min_order_qty: Joi.number().precision(4).optional(), - reorder_level: Joi.number().precision(4).optional(), + description: Joi.string().allow(null, '').optional(), + specification: Joi.string().allow(null, '').optional(), + min_order_qty: Joi.number().precision(4).allow(null).optional(), + reorder_level: Joi.number().precision(4).allow(null).optional(), is_active: Joi.boolean().optional(), }).min(1); -module.exports = { createSchema, updateSchema, listQuerySchema }; +const listQuerySchemaExtended = listQuerySchema.keys({ + item_category_id: Joi.number().integer().positive().optional(), +}); + +module.exports = { createSchema, updateSchema, listQuerySchema: listQuerySchemaExtended }; diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js index e6c0c8e..d9d401b 100644 --- a/src/modules/purchase-orders/purchase-orders.service.js +++ b/src/modules/purchase-orders/purchase-orders.service.js @@ -33,7 +33,7 @@ const poDetailInclude = { purchase_order_items: { orderBy: { line_no: 'asc' }, include: { - items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true } }, + items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true, hsn_code_id: true } }, uom: { select: { id: true, code: true, name: true } }, gst_rates: { select: { id: true, rate_pct: true, description: true } }, hsn_codes: { select: { id: true, code: true, description: true } },