erp_be/src/modules/grn/grn.service.js

448 lines
14 KiB
JavaScript

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,
};