448 lines
14 KiB
JavaScript
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,
|
|
};
|