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 { buildSimplePdf } = require('../../utils/simplePdf'); const repository = require('./grn.repository'); const { assertWarehouse } = require('../../utils/locations'); const grnListInclude = { purchase_orders: { select: { id: true, po_number: true, status: true } }, vendors: { select: { id: true, vendor_code: true, vendor_name: true } }, warehouse: { select: { id: true, code: true, name: true } }, users_grn_received_byTousers: { select: { id: true, full_name: true } }, users_grn_created_byTousers: { select: { id: true, full_name: true } }, }; const grnDetailInclude = { ...grnListInclude, users_grn_quality_checked_byTousers: { select: { id: true, full_name: true } }, users_grn_updated_byTousers: { select: { id: true, full_name: true } }, users_grn_cancelled_byTousers: { select: { id: true, full_name: true } }, grn_items: { orderBy: { line_no: 'asc' }, include: { items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true } }, uom: { select: { id: true, code: true, name: true } }, purchase_order_items: { select: { id: true, line_no: true, ordered_qty: true, received_qty: true }, }, }, }, }; const toDateOnly = (value) => { if (!value) return null; const date = new Date(value); return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); }; const toNum = (value) => Number(value ?? 0); const assetSeriesCode = (categoryCode) => `ASSET_${categoryCode}`; const sanitizeGrn = (row) => { if (!row) return null; const { purchase_orders, vendors, warehouse, users_grn_received_byTousers, users_grn_created_byTousers, users_grn_quality_checked_byTousers, users_grn_updated_byTousers, users_grn_cancelled_byTousers, grn_items, ...rest } = row; return { ...rest, purchase_order: purchase_orders || null, vendor: vendors || null, warehouse: warehouse || null, received_by_user: users_grn_received_byTousers || null, created_by_user: users_grn_created_byTousers || null, quality_checked_by_user: users_grn_quality_checked_byTousers || null, updated_by_user: users_grn_updated_byTousers || null, cancelled_by_user: users_grn_cancelled_byTousers || null, items: (grn_items || []).map((line) => ({ ...line, item: line.items || null, uom: line.uom || null, po_item: line.purchase_order_items || null, items: undefined, purchase_order_items: undefined, })), grn_items: undefined, purchase_orders: undefined, vendors: undefined, warehouses: undefined, users_grn_received_byTousers: undefined, users_grn_created_byTousers: undefined, users_grn_quality_checked_byTousers: undefined, users_grn_updated_byTousers: undefined, users_grn_cancelled_byTousers: undefined, }; }; const getGrnOrThrow = async (id, { includeItems = false } = {}) => { const row = await prisma.grn.findFirst({ where: { id: BigInt(id), deleted_at: null }, include: includeItems ? grnDetailInclude : grnListInclude, }); if (!row) throw new ApiError(404, 'GRN not found'); return row; }; const getReceivablePoOrThrow = async (poId) => { const po = await prisma.purchase_orders.findFirst({ where: { id: BigInt(poId), deleted_at: null }, include: { purchase_order_items: { include: { items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true, }, }, }, }, }, }); if (!po) throw new ApiError(404, 'Purchase order not found'); if (['DRAFT', 'PENDING_APPROVAL', 'REJECTED', 'CANCELLED', 'CLOSED'].includes(po.status)) { throw new ApiError(409, `PO status ${po.status} is not open for receipt`); } const hasPending = po.purchase_order_items.some( (line) => toNum(line.received_qty) < toNum(line.ordered_qty) ); if (!hasPending) { throw new ApiError(409, 'PO has no pending quantity to receive'); } return po; }; const validateAndBuildItems = async (po, payloadItems) => { const poItemMap = new Map(po.purchase_order_items.map((line) => [line.id.toString(), line])); const lineNos = payloadItems.map((row) => row.line_no); if (new Set(lineNos).size !== lineNos.length) { throw new ApiError(422, 'Duplicate line_no in GRN items'); } const builtItems = []; const assetPlans = []; for (const row of payloadItems) { const poItem = poItemMap.get(String(row.po_item_id)); if (!poItem) throw new ApiError(422, `po_item_id ${row.po_item_id} does not belong to this PO`); const currentQty = toNum(row.current_qty); const acceptedQty = toNum(row.accepted_qty); const rejectedQty = toNum(row.rejected_qty); const previouslyReceived = toNum(poItem.received_qty); const orderedQty = toNum(poItem.ordered_qty); const pendingQty = orderedQty - previouslyReceived; if (Math.abs(acceptedQty + rejectedQty - currentQty) > 0.0001) { throw new ApiError( 422, `Line ${row.line_no}: accepted_qty + rejected_qty must equal current_qty` ); } if (rejectedQty > currentQty) { throw new ApiError(422, `Line ${row.line_no}: rejected_qty cannot exceed current_qty`); } if (currentQty > pendingQty + 0.0001) { throw new ApiError( 422, `Line ${row.line_no}: current_qty exceeds pending PO quantity (${pendingQty})` ); } if (rejectedQty > 0 && !row.rejection_reason) { throw new ApiError( 422, `Line ${row.line_no}: rejection_reason is required when rejected_qty > 0` ); } const item = poItem.items; if (item.is_asset_item && acceptedQty > 0) { if (!row.asset_category_id) { throw new ApiError( 422, `Line ${row.line_no}: asset_category_id is required for asset items` ); } if (!row.asset_subcategory_id) { throw new ApiError( 422, `Line ${row.line_no}: asset_subcategory_id is required for asset items` ); } const assetCategory = await prisma.asset_categories.findFirst({ where: { id: BigInt(row.asset_category_id), deleted_at: null, is_active: true }, }); if (!assetCategory) throw new ApiError(422, `Line ${row.line_no}: invalid asset_category_id`); const assetSubcategory = await prisma.asset_subcategories.findFirst({ where: { id: BigInt(row.asset_subcategory_id), deleted_at: null, is_active: true }, }); if (!assetSubcategory) { throw new ApiError(422, `Line ${row.line_no}: invalid asset_subcategory_id`); } if (assetSubcategory.asset_category_id.toString() !== assetCategory.id.toString()) { throw new ApiError( 422, `Line ${row.line_no}: asset_subcategory_id does not belong to asset_category_id` ); } const units = Math.floor(acceptedQty); const assetCodes = []; for (let unit = 0; unit < units; unit += 1) { assetCodes.push(await nextDocumentNumber(assetSeriesCode(assetCategory.code))); } assetPlans.push({ po_item_id: poItem.id, item, assetCategory, assetSubcategory, plantId: po.plant_id, assetCodes, }); } builtItems.push({ po_item_id: poItem.id, item_id: poItem.item_id, line_no: row.line_no, ordered_qty: poItem.ordered_qty, previously_received_qty: previouslyReceived, current_qty: currentQty, accepted_qty: acceptedQty, rejected_qty: rejectedQty, rejection_reason: row.rejection_reason || null, uom_id: poItem.uom_id, rate: row.rate !== undefined ? row.rate : poItem.rate, batch_no: row.batch_no || null, mfg_date: row.mfg_date ? toDateOnly(row.mfg_date) : null, expiry_date: row.expiry_date ? toDateOnly(row.expiry_date) : null, storage_location: row.storage_location || null, remarks: row.remarks || null, }); } return { builtItems, assetPlans }; }; const buildHeaderData = (payload, po, userId) => ({ grn_date: toDateOnly(payload.grn_date), po_id: po.id, vendor_id: po.vendor_id, warehouse_id: BigInt(payload.warehouse_id), vendor_invoice_no: payload.vendor_invoice_no || null, vendor_invoice_date: payload.vendor_invoice_date ? toDateOnly(payload.vendor_invoice_date) : null, vendor_invoice_amount: payload.vendor_invoice_amount ?? null, vehicle_no: payload.vehicle_no || null, lr_no: payload.lr_no || null, lr_date: payload.lr_date ? toDateOnly(payload.lr_date) : null, received_by: payload.received_by ? BigInt(payload.received_by) : userId ? BigInt(userId) : null, quality_checked_by: payload.quality_checked_by ? BigInt(payload.quality_checked_by) : null, remarks: payload.remarks || null, }); const createGrn = async (payload, userId, requestId) => { const po = await getReceivablePoOrThrow(payload.po_id); await assertWarehouse(payload.warehouse_id, 'warehouse_id'); const { builtItems, assetPlans } = await validateAndBuildItems(po, payload.items); const header = buildHeaderData(payload, po, userId); const grnNumber = await nextDocumentNumber('GRN'); const created = await repository.createGrnWithReceipt({ grnNumber, header, items: builtItems, assetPlans, userId, }); const detail = await getGrnOrThrow(created.id, { includeItems: true }); await auditLog({ tableName: 'grn', recordId: detail.id, action: 'CREATE', oldValue: null, newValue: sanitizeGrn(detail), userId, requestId, }); return sanitizeGrn(detail); }; const listGrns = async (query) => { const { page, limit, skip } = getPagination(query); const where = { deleted_at: null, ...(query.status ? { status: query.status } : {}), ...(query.po_id ? { po_id: BigInt(query.po_id) } : {}), ...(query.vendor_id ? { vendor_id: BigInt(query.vendor_id) } : {}), ...(query.warehouse_id ? { warehouse_id: BigInt(query.warehouse_id) } : {}), ...(query.search ? { grn_number: { contains: query.search, mode: 'insensitive' } } : {}), ...(query.date_from || query.date_to ? { grn_date: { ...(query.date_from ? { gte: toDateOnly(query.date_from) } : {}), ...(query.date_to ? { lte: toDateOnly(query.date_to) } : {}), }, } : {}), }; const [rows, total] = await Promise.all([ prisma.grn.findMany({ where, include: grnListInclude, orderBy: { created_at: 'desc' }, skip, take: limit, }), prisma.grn.count({ where }), ]); return { data: rows.map(sanitizeGrn), meta: { page, limit, total } }; }; const getGrnById = async (id) => sanitizeGrn(await getGrnOrThrow(id, { includeItems: true })); const updateGrn = async (id, payload, userId, requestId) => { const existing = await getGrnOrThrow(id, { includeItems: true }); if (existing.status !== 'POSTED') { throw new ApiError(409, 'Only POSTED GRN can be updated'); } if (payload.warehouse_id) { await assertWarehouse(payload.warehouse_id, 'warehouse_id'); } const data = { ...(payload.grn_date !== undefined ? { grn_date: toDateOnly(payload.grn_date) } : {}), ...(payload.warehouse_id !== undefined ? { warehouse_id: BigInt(payload.warehouse_id) } : {}), ...(payload.vendor_invoice_no !== undefined ? { vendor_invoice_no: payload.vendor_invoice_no || null } : {}), ...(payload.vendor_invoice_date !== undefined ? { vendor_invoice_date: payload.vendor_invoice_date ? toDateOnly(payload.vendor_invoice_date) : null, } : {}), ...(payload.vendor_invoice_amount !== undefined ? { vendor_invoice_amount: payload.vendor_invoice_amount } : {}), ...(payload.vehicle_no !== undefined ? { vehicle_no: payload.vehicle_no || null } : {}), ...(payload.lr_no !== undefined ? { lr_no: payload.lr_no || null } : {}), ...(payload.lr_date !== undefined ? { lr_date: payload.lr_date ? toDateOnly(payload.lr_date) : null } : {}), ...(payload.received_by !== undefined ? { received_by: payload.received_by ? BigInt(payload.received_by) : null } : {}), ...(payload.quality_checked_by !== undefined ? { quality_checked_by: payload.quality_checked_by ? BigInt(payload.quality_checked_by) : null, } : {}), ...(payload.remarks !== undefined ? { remarks: payload.remarks || null } : {}), updated_by: userId ? BigInt(userId) : null, }; const updated = await prisma.grn.update({ where: { id: BigInt(id) }, data, include: grnDetailInclude, }); await auditLog({ tableName: 'grn', recordId: id, action: 'UPDATE', oldValue: sanitizeGrn(existing), newValue: sanitizeGrn(updated), userId, requestId, }); return sanitizeGrn(updated); }; const cancelGrn = async (id, payload, userId, requestId) => { const existing = await getGrnOrThrow(id, { includeItems: true }); await repository.cancelGrnWithReversal({ grnId: id, cancellationReason: payload.cancellation_reason, userId, }); const cancelled = await getGrnOrThrow(id, { includeItems: true }); await auditLog({ tableName: 'grn', recordId: id, action: 'CANCEL', oldValue: sanitizeGrn(existing), newValue: sanitizeGrn(cancelled), userId, requestId, }); return sanitizeGrn(cancelled); }; const getGrnPdf = async (id) => { const grn = sanitizeGrn(await getGrnOrThrow(id, { includeItems: true })); const lines = [ `GRN: ${grn.grn_number}`, `Date: ${grn.grn_date ? new Date(grn.grn_date).toISOString().slice(0, 10) : '-'}`, `Status: ${grn.status}`, `PO: ${grn.purchase_order?.po_number || '-'}`, `Vendor: ${grn.vendor?.vendor_name || '-'}`, `Warehouse: ${grn.warehouse?.name || '-'}`, '', 'Line Items:', ...grn.items.map( (line) => `${line.line_no}. ${line.item?.item_name || line.item_id} | Accepted ${line.accepted_qty} / Current ${line.current_qty}` ), ]; return { filename: `${grn.grn_number.replace(/\//g, '-')}.pdf`, buffer: buildSimplePdf(lines), }; }; module.exports = { createGrn, listGrns, getGrnById, updateGrn, cancelGrn, getGrnPdf, };