Category join in items

This commit is contained in:
Gowtham M 2026-07-13 17:47:02 +05:30
parent 44c920d0c5
commit 82676316c6
7 changed files with 174 additions and 17 deletions

View File

@ -15,6 +15,7 @@
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy", "prisma:deploy": "prisma migrate deploy",
"prisma:seed": "node prisma/seed.js", "prisma:seed": "node prisma/seed.js",
"db:patch": "node scripts/run-sql-patch.js",
"pdf:preview": "node scripts/preview-pdf-templates.js", "pdf:preview": "node scripts/preview-pdf-templates.js",
"prepare": "husky install" "prepare": "husky install"
}, },

View File

@ -4,6 +4,30 @@ const bcrypt = require('bcrypt');
const prisma = new PrismaClient(); 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() { async function main() {
const superAdminRole = await prisma.roles.findFirst({ const superAdminRole = await prisma.roles.findFirst({
where: { name: 'Super Admin', deleted_at: null }, 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.'); 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 passwordHash = await bcrypt.hash('Admin@123', 12);
const user = await prisma.users.upsert({ const user = await prisma.users.upsert({

62
scripts/run-sql-patch.js Normal file
View File

@ -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 <path-to.sql>');
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();
});

View File

@ -114,6 +114,21 @@ components:
code: { type: string, example: 'CHEM' } code: { type: string, example: 'CHEM' }
name: { type: string, example: 'Chemicals' } name: { type: string, example: 'Chemicals' }
is_active: { type: boolean, example: true } 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: ItemsCreateBody:
type: object type: object
required: [item_name, item_category_id, uom_id] required: [item_name, item_category_id, uom_id]
@ -702,6 +717,7 @@ paths:
get: get:
tags: [Item Subcategories] tags: [Item Subcategories]
summary: List Item Subcategories summary: List Item Subcategories
description: Each row includes joined `item_category` (`id`, `code`, `name`).
parameters: parameters:
- in: query - in: query
name: page name: page
@ -715,10 +731,23 @@ paths:
- in: query - in: query
name: is_active name: is_active
schema: { type: boolean } schema: { type: boolean }
- in: query
name: item_category_id
schema: { type: integer }
description: Filter by parent item category
responses: responses:
"200": "200":
description: List fetched description: List fetched (includes item_category join)
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/ApiResponse"
- type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/ItemSubcategoriesResponse" }
post: post:
tags: [Item Subcategories] tags: [Item Subcategories]
summary: Create Item Subcategorie summary: Create Item Subcategorie
@ -743,8 +772,15 @@ paths:
summary: Get Item Subcategorie by id summary: Get Item Subcategorie by id
responses: responses:
"200": "200":
description: Fetched description: Fetched (includes item_category join)
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } } content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/ApiResponse"
- type: object
properties:
data: { $ref: "#/components/schemas/ItemSubcategoriesResponse" }
"404": { description: Not found } "404": { description: Not found }
put: put:
tags: [Item Subcategories] tags: [Item Subcategories]

View File

@ -28,8 +28,11 @@ const buildMasterService = ({
fields, fields,
uniqueField = null, uniqueField = null,
softDelete = true, softDelete = true,
include = undefined,
mapRow = (row) => row,
}) => { }) => {
const model = prisma[modelName]; const model = prisma[modelName];
const withInclude = include ? { include } : {};
const createOne = async (payload, userId, requestId) => { const createOne = async (payload, userId, requestId) => {
const data = normalizePayload(payload, fields); const data = normalizePayload(payload, fields);
@ -49,17 +52,18 @@ const buildMasterService = ({
if (fields.some((f) => f.name === 'updated_by')) if (fields.some((f) => f.name === 'updated_by'))
data.updated_by = userId ? BigInt(userId) : null; 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({ await auditLog({
tableName, tableName,
recordId: created.id, recordId: created.id,
action: 'CREATE', action: 'CREATE',
oldValue: null, oldValue: null,
newValue: created, newValue: mapped,
userId, userId,
requestId, requestId,
}); });
return created; return mapped;
}; };
const list = async (query) => { const list = async (query) => {
@ -85,20 +89,21 @@ const buildMasterService = ({
where[f.name] = f.type === 'int' ? BigInt(query[f.name]) : query[f.name]; where[f.name] = f.type === 'int' ? BigInt(query[f.name]) : query[f.name];
} }
const [data, total] = await Promise.all([ const [rows, total] = await Promise.all([
model.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit }), model.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit, ...withInclude }),
model.count({ where }), model.count({ where }),
]); ]);
return { data, meta: { page, limit, total } }; return { data: rows.map(mapRow), meta: { page, limit, total } };
}; };
const getOne = async (id) => { const getOne = async (id) => {
const one = await model.findFirst({ const one = await model.findFirst({
where: { id: BigInt(id), ...(softDelete ? { deleted_at: null } : {}) }, where: { id: BigInt(id), ...(softDelete ? { deleted_at: null } : {}) },
...withInclude,
}); });
if (!one) throw new ApiError(404, `${tableName} not found`); if (!one) throw new ApiError(404, `${tableName} not found`);
return one; return mapRow(one);
}; };
const updateOne = async (id, payload, userId, requestId) => { const updateOne = async (id, payload, userId, requestId) => {
@ -107,19 +112,20 @@ const buildMasterService = ({
if (fields.some((f) => f.name === 'updated_by')) if (fields.some((f) => f.name === 'updated_by'))
data.updated_by = userId ? BigInt(userId) : null; 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({ await auditLog({
tableName, tableName,
recordId: id, recordId: id,
action: 'UPDATE', action: 'UPDATE',
oldValue: existing, oldValue: existing,
newValue: updated, newValue: mapped,
userId, userId,
requestId, requestId,
}); });
return updated; return mapped;
}; };
const removeOne = async (id, userId, requestId) => { const removeOne = async (id, userId, requestId) => {

View File

@ -1,16 +1,33 @@
const { buildMasterService } = require('../_shared/master.factory'); 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 = { const config = {
modelName: 'item_subcategories', modelName: 'item_subcategories',
tableName: 'item_subcategories', tableName: 'item_subcategories',
uniqueField: 'code', uniqueField: 'code',
softDelete: true, softDelete: true,
include: subcategoryInclude,
mapRow: sanitizeSubcategory,
fields: [ fields: [
{ {
name: 'item_category_id', name: 'item_category_id',
type: 'int', type: 'int',
asBigInt: true,
uppercase: false, uppercase: false,
searchable: false, searchable: false,
filterable: true,
}, },
{ {
name: 'code', name: 'code',

View File

@ -1,18 +1,22 @@
const Joi = require('joi'); 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({ const createSchema = Joi.object({
item_category_id: Joi.number().integer().optional(), item_category_id: Joi.number().integer().positive().required(),
code: masterCode({ max: 30 }), code: masterCode({ max: 30 }),
name: masterName({ max: 150 }), name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(), is_active: Joi.boolean().optional(),
}); });
const updateSchema = Joi.object({ const updateSchema = Joi.object({
item_category_id: Joi.number().integer().optional(), item_category_id: Joi.number().integer().positive().optional(),
code: masterCode({ max: 30 }), code: masterCode({ max: 30 }),
name: masterName({ max: 150 }), name: masterName({ max: 150 }),
is_active: Joi.boolean().optional(), is_active: Joi.boolean().optional(),
}).min(1); }).min(1);
const listQuerySchema = baseListQuerySchema.keys({
item_category_id: Joi.number().integer().positive().optional(),
});
module.exports = { createSchema, updateSchema, listQuerySchema }; module.exports = { createSchema, updateSchema, listQuerySchema };