From 82676316c6372e93cb2e9667df0e842ee27e4330 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Mon, 13 Jul 2026 17:47:02 +0530 Subject: [PATCH] Category join in items --- package.json | 1 + prisma/seed.js | 31 ++++++++++ scripts/run-sql-patch.js | 62 +++++++++++++++++++ src/docs/completed-routes.yaml | 44 +++++++++++-- src/modules/masters/_shared/master.factory.js | 26 +++++--- .../item-subcategories.service.js | 17 +++++ .../item-subcategories.validation.js | 10 ++- 7 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 scripts/run-sql-patch.js diff --git a/package.json b/package.json index 080a1b7..5bf00b5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "node prisma/seed.js", + "db:patch": "node scripts/run-sql-patch.js", "pdf:preview": "node scripts/preview-pdf-templates.js", "prepare": "husky install" }, diff --git a/prisma/seed.js b/prisma/seed.js index c17473f..c714639 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -4,6 +4,30 @@ const bcrypt = require('bcrypt'); const prisma = new PrismaClient(); +const ensureModulePermissions = async (roleId, { code, name, sortOrder, actions }) => { + const mod = await prisma.modules.upsert({ + where: { code }, + update: { name, sort_order: sortOrder, is_active: true }, + create: { code, name, sort_order: sortOrder, is_active: true }, + }); + + for (const action of actions) { + const permission = await prisma.permissions.upsert({ + where: { module_id_action: { module_id: mod.id, action } }, + update: { is_active: true }, + create: { module_id: mod.id, action, is_active: true }, + }); + + await prisma.role_permissions.upsert({ + where: { + role_id_permission_id: { role_id: roleId, permission_id: permission.id }, + }, + update: {}, + create: { role_id: roleId, permission_id: permission.id }, + }); + } +}; + async function main() { const superAdminRole = await prisma.roles.findFirst({ where: { name: 'Super Admin', deleted_at: null }, @@ -13,6 +37,13 @@ async function main() { throw new Error('Super Admin role not found. Run erp_phase1_ddl.sql first.'); } + await ensureModulePermissions(superAdminRole.id, { + code: 'REPORTS', + name: 'Reports', + sortOrder: 90, + actions: ['view', 'export'], + }); + const passwordHash = await bcrypt.hash('Admin@123', 12); const user = await prisma.users.upsert({ diff --git a/scripts/run-sql-patch.js b/scripts/run-sql-patch.js new file mode 100644 index 0000000..f54741b --- /dev/null +++ b/scripts/run-sql-patch.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { PrismaClient } = require('@prisma/client'); + +const fileArg = process.argv[2]; + +if (!fileArg) { + console.error('Usage: node scripts/run-sql-patch.js '); + process.exit(1); +} + +const filePath = path.resolve(process.cwd(), fileArg); + +if (!fs.existsSync(filePath)) { + console.error(`SQL file not found: ${filePath}`); + process.exit(1); +} + +const prisma = new PrismaClient(); + +const splitStatements = (sql) => + sql + .split(/;\s*(?:\r?\n|$)/) + .map((part) => + part + .split(/\r?\n/) + .filter((line) => !line.trim().startsWith('--')) + .join('\n') + .trim() + ) + .filter(Boolean); + +async function main() { + const sql = fs.readFileSync(filePath, 'utf8'); + const statements = splitStatements(sql); + + if (statements.length === 0) { + console.error('No SQL statements found in file.'); + process.exit(1); + } + + console.log(`Applying ${statements.length} statement(s) from ${path.basename(filePath)}...`); + + for (const [index, statement] of statements.entries()) { + await prisma.$executeRawUnsafe(`${statement};`); + console.log(` [${index + 1}/${statements.length}] OK`); + } + + console.log('Patch applied successfully.'); +} + +main() + .catch((err) => { + console.error('Patch failed:', err.message); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/docs/completed-routes.yaml b/src/docs/completed-routes.yaml index 07be920..5a07bab 100644 --- a/src/docs/completed-routes.yaml +++ b/src/docs/completed-routes.yaml @@ -114,6 +114,21 @@ components: code: { type: string, example: 'CHEM' } name: { type: string, example: 'Chemicals' } is_active: { type: boolean, example: true } + ItemSubcategoriesResponse: + type: object + properties: + id: { type: string, example: '1' } + item_category_id: { type: string, example: '1' } + code: { type: string, example: 'CHEM' } + name: { type: string, example: 'Chemicals' } + is_active: { type: boolean, example: true } + item_category: + type: object + nullable: true + properties: + id: { type: string, example: '1' } + code: { type: string, example: 'RAW' } + name: { type: string, example: 'Raw Material' } ItemsCreateBody: type: object required: [item_name, item_category_id, uom_id] @@ -702,6 +717,7 @@ paths: get: tags: [Item Subcategories] summary: List Item Subcategories + description: Each row includes joined `item_category` (`id`, `code`, `name`). parameters: - in: query name: page @@ -715,10 +731,23 @@ paths: - in: query name: is_active schema: { type: boolean } + - in: query + name: item_category_id + schema: { type: integer } + description: Filter by parent item category responses: "200": - description: List fetched - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + description: List fetched (includes item_category join) + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/ItemSubcategoriesResponse" } post: tags: [Item Subcategories] summary: Create Item Subcategorie @@ -743,8 +772,15 @@ paths: summary: Get Item Subcategorie by id responses: "200": - description: Fetched - content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } + description: Fetched (includes item_category join) + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: { $ref: "#/components/schemas/ItemSubcategoriesResponse" } "404": { description: Not found } put: tags: [Item Subcategories] diff --git a/src/modules/masters/_shared/master.factory.js b/src/modules/masters/_shared/master.factory.js index cc3748a..ae5e3fc 100644 --- a/src/modules/masters/_shared/master.factory.js +++ b/src/modules/masters/_shared/master.factory.js @@ -28,8 +28,11 @@ const buildMasterService = ({ fields, uniqueField = null, softDelete = true, + include = undefined, + mapRow = (row) => row, }) => { const model = prisma[modelName]; + const withInclude = include ? { include } : {}; const createOne = async (payload, userId, requestId) => { const data = normalizePayload(payload, fields); @@ -49,17 +52,18 @@ const buildMasterService = ({ if (fields.some((f) => f.name === 'updated_by')) data.updated_by = userId ? BigInt(userId) : null; - const created = await model.create({ data }); + const created = await model.create({ data, ...withInclude }); + const mapped = mapRow(created); await auditLog({ tableName, recordId: created.id, action: 'CREATE', oldValue: null, - newValue: created, + newValue: mapped, userId, requestId, }); - return created; + return mapped; }; const list = async (query) => { @@ -85,20 +89,21 @@ const buildMasterService = ({ where[f.name] = f.type === 'int' ? BigInt(query[f.name]) : query[f.name]; } - const [data, total] = await Promise.all([ - model.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit }), + const [rows, total] = await Promise.all([ + model.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit, ...withInclude }), model.count({ where }), ]); - return { data, meta: { page, limit, total } }; + return { data: rows.map(mapRow), meta: { page, limit, total } }; }; const getOne = async (id) => { const one = await model.findFirst({ where: { id: BigInt(id), ...(softDelete ? { deleted_at: null } : {}) }, + ...withInclude, }); if (!one) throw new ApiError(404, `${tableName} not found`); - return one; + return mapRow(one); }; const updateOne = async (id, payload, userId, requestId) => { @@ -107,19 +112,20 @@ const buildMasterService = ({ if (fields.some((f) => f.name === 'updated_by')) data.updated_by = userId ? BigInt(userId) : null; - const updated = await model.update({ where: { id: BigInt(id) }, data }); + const updated = await model.update({ where: { id: BigInt(id) }, data, ...withInclude }); + const mapped = mapRow(updated); await auditLog({ tableName, recordId: id, action: 'UPDATE', oldValue: existing, - newValue: updated, + newValue: mapped, userId, requestId, }); - return updated; + return mapped; }; const removeOne = async (id, userId, requestId) => { diff --git a/src/modules/masters/item-subcategories/item-subcategories.service.js b/src/modules/masters/item-subcategories/item-subcategories.service.js index 0a659c9..57ab653 100644 --- a/src/modules/masters/item-subcategories/item-subcategories.service.js +++ b/src/modules/masters/item-subcategories/item-subcategories.service.js @@ -1,16 +1,33 @@ const { buildMasterService } = require('../_shared/master.factory'); +const subcategoryInclude = { + item_categories: { select: { id: true, code: true, name: true } }, +}; + +const sanitizeSubcategory = (row) => { + if (!row) return null; + const { item_categories, ...rest } = row; + return { + ...rest, + item_category: item_categories || null, + }; +}; + const config = { modelName: 'item_subcategories', tableName: 'item_subcategories', uniqueField: 'code', softDelete: true, + include: subcategoryInclude, + mapRow: sanitizeSubcategory, fields: [ { name: 'item_category_id', type: 'int', + asBigInt: true, uppercase: false, searchable: false, + filterable: true, }, { name: 'code', diff --git a/src/modules/masters/item-subcategories/item-subcategories.validation.js b/src/modules/masters/item-subcategories/item-subcategories.validation.js index a71be13..01658aa 100644 --- a/src/modules/masters/item-subcategories/item-subcategories.validation.js +++ b/src/modules/masters/item-subcategories/item-subcategories.validation.js @@ -1,18 +1,22 @@ const Joi = require('joi'); -const { masterCode, masterName, listQuerySchema } = require('../_shared/masters.validation'); +const { masterCode, masterName, listQuerySchema: baseListQuerySchema } = require('../_shared/masters.validation'); const createSchema = Joi.object({ - item_category_id: Joi.number().integer().optional(), + item_category_id: Joi.number().integer().positive().required(), code: masterCode({ max: 30 }), name: masterName({ max: 150 }), is_active: Joi.boolean().optional(), }); const updateSchema = Joi.object({ - item_category_id: Joi.number().integer().optional(), + item_category_id: Joi.number().integer().positive().optional(), code: masterCode({ max: 30 }), name: masterName({ max: 150 }), is_active: Joi.boolean().optional(), }).min(1); +const listQuerySchema = baseListQuerySchema.keys({ + item_category_id: Joi.number().integer().positive().optional(), +}); + module.exports = { createSchema, updateSchema, listQuerySchema };