Items code auto generated and hs_code masted added
This commit is contained in:
parent
4bab5f8859
commit
a434216fdb
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
12
scripts/patch-items-drop-hsn-code.sql
Normal file
12
scripts/patch-items-drop-hsn-code.sql
Normal file
@ -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%';
|
||||
33
scripts/patch-items-item-code-hsn.sql
Normal file
33
scripts/patch-items-item-code-hsn.sql
Normal file
@ -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';
|
||||
40
scripts/seed-hsn-codes.sql
Normal file
40
scripts/seed-hsn-codes.sql
Normal file
@ -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;
|
||||
@ -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]
|
||||
|
||||
30
src/modules/masters/hsn-codes/hsn-codes.controller.js
Normal file
30
src/modules/masters/hsn-codes/hsn-codes.controller.js
Normal file
@ -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 };
|
||||
17
src/modules/masters/hsn-codes/hsn-codes.routes.js
Normal file
17
src/modules/masters/hsn-codes/hsn-codes.routes.js
Normal file
@ -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;
|
||||
38
src/modules/masters/hsn-codes/hsn-codes.service.js
Normal file
38
src/modules/masters/hsn-codes/hsn-codes.service.js
Normal file
@ -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,
|
||||
};
|
||||
31
src/modules/masters/hsn-codes/hsn-codes.validation.js
Normal file
31
src/modules/masters/hsn-codes/hsn-codes.validation.js
Normal file
@ -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 };
|
||||
@ -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'));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 } },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user