erp_be/src/modules/purchase-orders/purchase-orders.service.js

947 lines
30 KiB
JavaScript

const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination, isDropdownCall } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode');
const { generatePdf } = require('../../utils/pdf/pdfGenerator');
const { generatePoHtml } = require('../../utils/pdf/templates/po.template');
const { formatDate } = require('../../utils/pdf/helpers/formatDate');
const { getCompanyForDocuments } = require('../settings/settings.service');
const { rowsToCsv } = require('../../utils/csv');
const {
EDITABLE_STATUSES,
SUBMITTABLE_STATUSES,
APPROVABLE_STATUSES,
CANCELLABLE_STATUSES,
AMENDABLE_STATUSES,
DELETABLE_STATUSES,
} = require('./purchase-orders.constants');
const {
computeLineAmounts,
computeHeaderTotals,
splitGst,
toNum,
} = require('./purchase-orders.calculations');
const repository = require('./purchase-orders.repository');
const { assertAnyLocation } = require('../../utils/locations');
const { sanitizeAttachment } = require('./po.attachments.service');
const locationSummarySelect = { id: true, code: true, name: true, type: true };
const poListInclude = {
vendors: { select: { id: true, vendor_code: true, vendor_name: true, vendor_type: true } },
billing_location: { select: locationSummarySelect },
shipping_location: { select: locationSummarySelect },
users_purchase_orders_created_byTousers: { select: { id: true, full_name: true } },
};
const poDetailInclude = {
...poListInclude,
payment_terms: { select: { id: true, code: true, name: true } },
delivery_terms: { select: { id: true, code: true, name: true } },
users_purchase_orders_updated_byTousers: { select: { id: true, full_name: true } },
purchase_orders: { select: { id: true, po_number: true, revision_no: true } },
purchase_order_items: {
orderBy: { line_no: 'asc' },
include: {
items: { select: { id: true, item_code: true, item_name: true, is_asset_item: true, hsn_code_id: true } },
uom: { select: { id: true, code: true, name: true } },
gst_rates: { select: { id: true, rate_pct: true, description: true } },
hsn_codes: { select: { id: true, code: true, description: true } },
},
},
po_approvals: {
orderBy: { approval_level: 'asc' },
include: {
roles: { select: { id: true, name: true } },
users: { select: { id: true, full_name: true } },
},
},
po_attachments: {
orderBy: { created_at: 'desc' },
include: {
users: { select: { id: true, full_name: true, employee_code: true } },
},
},
};
const poPdfInclude = {
...poDetailInclude,
vendors: {
select: {
id: true,
vendor_code: true,
vendor_name: true,
vendor_type: true,
gstin: true,
vendor_addresses: {
where: { is_active: true },
orderBy: { id: 'asc' },
take: 5,
select: {
address_type: true,
address_line1: true,
address_line2: true,
city: true,
state: true,
pincode: true,
gstin: true,
},
},
},
},
billing_location: {
select: {
id: true,
code: true,
name: true,
type: true,
gstin: true,
address: true,
city: true,
state: true,
pincode: true,
},
},
shipping_location: {
select: {
id: true,
code: true,
name: true,
type: true,
gstin: true,
address: true,
city: true,
state: true,
pincode: 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 sanitizePo = (po) => {
if (!po) return null;
const {
vendors,
billing_location,
shipping_location,
payment_terms,
delivery_terms,
users_purchase_orders_created_byTousers,
users_purchase_orders_updated_byTousers,
purchase_orders,
purchase_order_items,
po_approvals,
po_attachments,
...rest
} = po;
return {
...rest,
vendor: vendors || null,
billing: billing_location || null,
shipping: shipping_location || null,
payment_term: payment_terms || null,
delivery_term: delivery_terms || null,
created_by_user: users_purchase_orders_created_byTousers || null,
updated_by_user: users_purchase_orders_updated_byTousers || null,
parent_po: purchase_orders || null,
items: (purchase_order_items || []).map((line) => ({
...line,
item: line.items || null,
uom: line.uom || null,
gst_rate: line.gst_rates || null,
hsn_code: line.hsn_codes || null,
items: undefined,
gst_rates: undefined,
hsn_codes: undefined,
})),
approvals: (po_approvals || []).map((row) => ({
...row,
approver_role: row.roles || null,
approver_user: row.users || null,
roles: undefined,
users: undefined,
})),
attachments: (po_attachments || []).map(sanitizeAttachment),
purchase_order_items: undefined,
po_approvals: undefined,
po_attachments: undefined,
vendors: undefined,
billing_location: undefined,
shipping_location: undefined,
payment_terms: undefined,
delivery_terms: undefined,
users_purchase_orders_created_byTousers: undefined,
users_purchase_orders_updated_byTousers: undefined,
purchase_orders: undefined,
};
};
const assertStatus = (po, allowedStatuses, action) => {
if (!allowedStatuses.includes(po.status)) {
throw new ApiError(409, `Cannot ${action} PO in status ${po.status}`);
}
};
const SOFT_DELETE_TABLES = new Set([
'vendors',
'locations',
'payment_terms',
'delivery_terms',
'items',
'uom',
]);
const assertReference = async (table, id, label, { requireActive = true } = {}) => {
if (!id) return null;
const row = await prisma[table].findFirst({
where: {
id: BigInt(id),
...(SOFT_DELETE_TABLES.has(table) ? { deleted_at: null } : {}),
},
});
if (!row) throw new ApiError(422, `Invalid ${label}`);
if (requireActive && row.is_active === false) throw new ApiError(422, `${label} is inactive`);
return row;
};
const loadGstRateMap = async (itemRows) => {
const gstIds = [...new Set(itemRows.map((row) => row.gst_rate_id).filter(Boolean))];
if (!gstIds.length) return new Map();
const rates = await prisma.gst_rates.findMany({
where: { id: { in: gstIds.map((id) => BigInt(id)) }, is_active: true },
});
return new Map(rates.map((rate) => [rate.id.toString(), Number(rate.rate_pct)]));
};
const validateAndBuildItems = async (items) => {
const lineNos = items.map((row) => row.line_no);
if (new Set(lineNos).size !== lineNos.length) {
throw new ApiError(422, 'Duplicate line_no in items');
}
const gstRateMap = await loadGstRateMap(items);
const builtItems = [];
for (const row of items) {
const item = await assertReference('items', row.item_id, 'item_id');
await assertReference('uom', row.uom_id, 'uom_id');
if (row.hsn_code_id)
await assertReference('hsn_codes', row.hsn_code_id, 'hsn_code_id', { requireActive: false });
let gstRatePct = 0;
if (row.gst_rate_id) {
const gst = await prisma.gst_rates.findFirst({
where: { id: BigInt(row.gst_rate_id), is_active: true },
});
if (!gst) throw new ApiError(422, 'Invalid gst_rate_id');
gstRatePct = Number(gst.rate_pct);
} else if (item.gst_rate_id) {
const gst = await prisma.gst_rates.findFirst({
where: { id: item.gst_rate_id, is_active: true },
});
gstRatePct = gst ? Number(gst.rate_pct) : 0;
} else if (gstRateMap.has(String(row.gst_rate_id))) {
gstRatePct = gstRateMap.get(String(row.gst_rate_id));
}
const amounts = computeLineAmounts(row, gstRatePct);
builtItems.push({
item_id: BigInt(row.item_id),
line_no: row.line_no,
ordered_qty: row.ordered_qty,
uom_id: BigInt(row.uom_id),
rate: row.rate,
discount_pct: row.discount_pct ?? 0,
discount_amount: amounts.discount_amount,
gst_rate_id: row.gst_rate_id ? BigInt(row.gst_rate_id) : item.gst_rate_id || null,
hsn_code_id: row.hsn_code_id ? BigInt(row.hsn_code_id) : item.hsn_code_id || null,
taxable_amount: amounts.taxable_amount,
tax_amount: amounts.tax_amount,
line_total: amounts.line_total,
remarks: row.remarks || null,
});
}
return builtItems;
};
const normalizeState = (value) =>
value === null || value === undefined ? '' : String(value).trim().toLowerCase();
/**
* Inter-state (IGST) when the place of supply (billing location state) differs
* from the vendor's source of supply. When either state is unknown we default
* to intra-state (CGST/SGST).
*/
const isInterStateSupply = (billingLocation, vendor) => {
const placeOfSupply = normalizeState(billingLocation?.state);
const vendorState = normalizeState(vendor?.source_of_supply);
if (!placeOfSupply || !vendorState) return false;
return placeOfSupply !== vendorState;
};
const buildHeaderData = async (payload, builtItems, userId, { applyDefaultTerms = false } = {}) => {
const vendor = await assertReference('vendors', payload.vendor_id, 'vendor_id');
const billing = await assertAnyLocation(payload.billing_id, 'billing_id');
await assertAnyLocation(payload.shipping_id, 'shipping_id');
if (payload.payment_term_id)
await assertReference('payment_terms', payload.payment_term_id, 'payment_term_id');
if (payload.delivery_term_id)
await assertReference('delivery_terms', payload.delivery_term_id, 'delivery_term_id');
const tdsApplicable = Boolean(payload.tds_applicable);
const tdsSectionPct = tdsApplicable ? toNum(payload.tds_section_pct) : 0;
const totals = computeHeaderTotals(
builtItems,
payload.discount_amount,
payload.freight_charges,
payload.other_charges,
{
tdsApplicable,
tdsSectionPct,
adjustment: payload.adjustment,
}
);
const gstSplit = splitGst(totals.tax_total, isInterStateSupply(billing, vendor));
let termsAndConditions =
payload.terms_and_conditions !== undefined && payload.terms_and_conditions !== null
? String(payload.terms_and_conditions).trim()
: '';
if (applyDefaultTerms && !termsAndConditions) {
const { getDefaultNotesText } = require('../masters/terms-notes/terms-notes.service');
const defaultNotes = await getDefaultNotesText('PO');
if (defaultNotes) termsAndConditions = defaultNotes;
}
return {
po_date: toDateOnly(payload.po_date),
vendor_id: BigInt(payload.vendor_id),
billing_id: BigInt(payload.billing_id),
shipping_id: BigInt(payload.shipping_id),
payment_term_id: payload.payment_term_id ? BigInt(payload.payment_term_id) : null,
delivery_term_id: payload.delivery_term_id ? BigInt(payload.delivery_term_id) : null,
expected_delivery_date: payload.expected_delivery_date
? toDateOnly(payload.expected_delivery_date)
: null,
terms_and_conditions: termsAndConditions || null,
remarks: payload.remarks || null,
tds_applicable: tdsApplicable,
tds_section_pct: tdsApplicable ? tdsSectionPct : null,
...totals,
...gstSplit,
updated_by: userId ? BigInt(userId) : null,
};
};
const getPoOrThrow = async (id, { includeItems = false, forPdf = false } = {}) => {
const include = forPdf ? poPdfInclude : includeItems ? poDetailInclude : poListInclude;
const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(id), deleted_at: null },
include,
});
if (!po) throw new ApiError(404, 'Purchase order not found');
return po;
};
const humanizeLabel = (value) => {
if (!value) return '-';
return String(value)
.toLowerCase()
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const pickVendorAddress = (addresses = []) => {
if (!addresses.length) return null;
const preferred =
addresses.find((row) => String(row.address_type || '').toUpperCase() === 'BILLING') ||
addresses.find((row) => String(row.address_type || '').toUpperCase() === 'SHIPPING') ||
addresses[0];
return preferred;
};
const buildVendorAddressLine = (address) => {
if (!address) return '';
return [address.address_line1, address.address_line2].filter(Boolean).join(', ');
};
const buildPoPdfPayload = (po, company) => {
const vendorAddress = pickVendorAddress(po.vendor?.vendor_addresses || []);
return {
company: {
...company,
gstin: company.gstin || '',
},
po: {
po_number: po.po_number,
po_date: po.po_date,
expected_delivery_date: po.expected_delivery_date,
status: humanizeLabel(po.status),
vendor_type: humanizeLabel(po.vendor?.vendor_type),
revision_no: po.revision_no ?? 0,
payment_term: po.payment_term?.name || null,
delivery_term: po.delivery_term?.name || null,
terms_and_conditions: po.terms_and_conditions || null,
remarks: po.remarks || null,
},
vendor: {
vendor_name: po.vendor?.vendor_name || '-',
gstin: po.vendor?.gstin || vendorAddress?.gstin || '',
address: buildVendorAddressLine(vendorAddress),
city: vendorAddress?.city || '',
state: vendorAddress?.state || '',
pincode: vendorAddress?.pincode || '',
},
bill_to: {
name: po.billing?.name || '-',
gstin: po.billing?.gstin || '',
address: po.billing?.address || '',
city: po.billing?.city || '',
state: po.billing?.state || '',
pincode: po.billing?.pincode || '',
},
ship_to: {
name: po.shipping?.name || '-',
gstin: po.shipping?.gstin || '',
address: po.shipping?.address || '',
city: po.shipping?.city || '',
state: po.shipping?.state || '',
pincode: po.shipping?.pincode || '',
},
items: (po.items || []).map((line) => {
const orderedQty = toNum(line.ordered_qty);
const rate = toNum(line.rate);
return {
line_no: line.line_no,
item_name: line.item?.item_name || '-',
hsn_code: line.hsn_code?.code || '-',
remarks: line.remarks || null,
uom: line.uom?.code || line.uom?.name || '-',
ordered_qty: orderedQty,
rate,
discount_pct: toNum(line.discount_pct),
gst_rate_pct: toNum(line.gst_rate?.rate_pct),
amount: toNum(line.taxable_amount != null ? line.taxable_amount : orderedQty * rate),
};
}),
totals: {
sub_total: toNum(po.sub_total),
freight_charges: toNum(po.freight_charges),
other_charges: toNum(po.other_charges),
discount_amount: toNum(po.discount_amount),
taxable_amount: toNum(po.taxable_amount),
cgst: toNum(po.cgst),
sgst: toNum(po.sgst),
igst: toNum(po.igst),
tax_total: toNum(po.tax_total),
tds_amount: toNum(po.tds_amount),
adjustment: toNum(po.adjustment),
grand_total: toNum(po.grand_total),
},
generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,
};
};
const hasReceipts = (po) =>
(po.purchase_order_items || []).some((line) => Number(line.received_qty) > 0);
const createPurchaseOrder = async (payload, userId, requestId) => {
const builtItems = await validateAndBuildItems(payload.items);
const header = await buildHeaderData(payload, builtItems, userId, { applyDefaultTerms: true });
const poNumber = await nextDocumentNumber('PO');
header.po_number = poNumber;
header.status = 'DRAFT';
header.created_by = userId ? BigInt(userId) : null;
const itemsWithPo = builtItems.map((item) => ({ ...item }));
const createdPo = await repository.createPurchaseOrderWithItems({ header, items: itemsWithPo });
const created = await getPoOrThrow(createdPo.id, { includeItems: true });
await auditLog({
tableName: 'purchase_orders',
recordId: created.id,
action: 'CREATE',
oldValue: null,
newValue: sanitizePo(created),
userId,
requestId,
});
return sanitizePo(created);
};
const buildPurchaseOrdersWhere = (query) => ({
deleted_at: null,
...(query.status ? { status: query.status } : {}),
...(query.vendor_id ? { vendor_id: BigInt(query.vendor_id) } : {}),
...(query.vendor_type ? { vendors: { vendor_type: query.vendor_type } } : {}),
...(query.billing_id ? { billing_id: BigInt(query.billing_id) } : {}),
...(query.shipping_id ? { shipping_id: BigInt(query.shipping_id) } : {}),
...(query.search ? { po_number: { contains: query.search, mode: 'insensitive' } } : {}),
...(query.date_from || query.date_to
? {
po_date: {
...(query.date_from ? { gte: toDateOnly(query.date_from) } : {}),
...(query.date_to ? { lte: toDateOnly(query.date_to) } : {}),
},
}
: {}),
});
const listPurchaseOrders = async (query) => {
const where = buildPurchaseOrdersWhere(query);
if (isDropdownCall(query)) {
where.is_active = true;
const rows = await prisma.purchase_orders.findMany({
where,
include: poListInclude,
orderBy: { created_at: 'desc' },
});
const data = rows.map(sanitizePo);
return { data, meta: { page: 1, limit: data.length, total: data.length, dropdown: true } };
}
const { page, limit, skip } = getPagination(query);
const [rows, total] = await Promise.all([
prisma.purchase_orders.findMany({
where,
include: poListInclude,
orderBy: { created_at: 'desc' },
skip,
take: limit,
}),
prisma.purchase_orders.count({ where }),
]);
return { data: rows.map(sanitizePo), meta: { page, limit, total } };
};
/** Approver inbox — only POs in PENDING_APPROVAL. */
const listPendingApprovalPurchaseOrders = async (query) =>
listPurchaseOrders({ ...query, status: 'PENDING_APPROVAL' });
const exportPurchaseOrders = async (query) => {
const rows = await prisma.purchase_orders.findMany({
where: buildPurchaseOrdersWhere(query),
include: poListInclude,
orderBy: { created_at: 'desc' },
});
return rowsToCsv(
[
{ key: 'po_number', header: 'PO Number' },
{ key: 'po_date', header: 'PO Date', type: 'date' },
{ key: (row) => row.vendor?.vendor_type || '', header: 'Vendor Type' },
{ key: 'status', header: 'Status' },
{ key: 'revision_no', header: 'Revision' },
{ key: (row) => row.vendor?.vendor_code || '', header: 'Vendor Code' },
{ key: (row) => row.vendor?.vendor_name || '', header: 'Vendor Name' },
{ key: (row) => row.billing?.name || '', header: 'Billing Location' },
{ key: (row) => row.shipping?.name || '', header: 'Shipping Location' },
{ key: 'expected_delivery_date', header: 'Expected Delivery', type: 'date' },
{ key: 'sub_total', header: 'Sub Total' },
{ key: 'tax_total', header: 'Tax Total' },
{ key: 'cgst', header: 'CGST' },
{ key: 'sgst', header: 'SGST' },
{ key: 'igst', header: 'IGST' },
{ key: 'grand_total', header: 'Grand Total' },
{ key: (row) => row.created_by_user?.full_name || '', header: 'Created By' },
{ key: 'created_at', header: 'Created At', type: 'datetime' },
],
rows.map(sanitizePo)
);
};
const getPurchaseOrderById = async (id) =>
sanitizePo(await getPoOrThrow(id, { includeItems: true }));
const updatePurchaseOrder = async (id, payload, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, EDITABLE_STATUSES, 'update');
const merged = {
po_date: payload.po_date ?? existing.po_date,
vendor_id: payload.vendor_id ?? existing.vendor_id,
billing_id: payload.billing_id ?? existing.billing_id,
shipping_id: payload.shipping_id ?? existing.shipping_id,
payment_term_id:
payload.payment_term_id !== undefined ? payload.payment_term_id : existing.payment_term_id,
delivery_term_id:
payload.delivery_term_id !== undefined ? payload.delivery_term_id : existing.delivery_term_id,
expected_delivery_date:
payload.expected_delivery_date !== undefined
? payload.expected_delivery_date
: existing.expected_delivery_date,
discount_amount: payload.discount_amount ?? Number(existing.discount_amount),
freight_charges: payload.freight_charges ?? Number(existing.freight_charges),
other_charges: payload.other_charges ?? Number(existing.other_charges),
tds_applicable:
payload.tds_applicable !== undefined ? payload.tds_applicable : existing.tds_applicable,
tds_section_pct:
payload.tds_section_pct !== undefined
? payload.tds_section_pct
: existing.tds_section_pct !== null && existing.tds_section_pct !== undefined
? Number(existing.tds_section_pct)
: null,
adjustment:
payload.adjustment !== undefined ? payload.adjustment : Number(existing.adjustment ?? 0),
terms_and_conditions:
payload.terms_and_conditions !== undefined
? payload.terms_and_conditions
: existing.terms_and_conditions,
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
items: payload.items,
};
const sourceItems =
payload.items ||
existing.purchase_order_items.map((line) => ({
item_id: line.item_id,
line_no: line.line_no,
ordered_qty: line.ordered_qty,
uom_id: line.uom_id,
rate: line.rate,
discount_pct: line.discount_pct,
discount_amount: line.discount_amount,
gst_rate_id: line.gst_rate_id,
hsn_code_id: line.hsn_code_id,
remarks: line.remarks,
}));
const builtItems = await validateAndBuildItems(sourceItems);
const header = await buildHeaderData(merged, builtItems, userId);
if (payload.items) {
await repository.replacePurchaseOrderItems(
id,
builtItems.map((item) => ({ ...item, po_id: BigInt(id) }))
);
}
const updated = await prisma.purchase_orders.update({
where: { id: BigInt(id) },
data: header,
include: poDetailInclude,
});
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'UPDATE',
oldValue: sanitizePo(existing),
newValue: sanitizePo(updated),
userId,
requestId,
});
return sanitizePo(updated);
};
const deletePurchaseOrder = async (id, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, DELETABLE_STATUSES, 'delete');
if (hasReceipts(existing)) throw new ApiError(409, 'Cannot delete PO with received quantities');
const deleted = await prisma.purchase_orders.update({
where: { id: BigInt(id) },
data: { deleted_at: new Date(), updated_by: userId ? BigInt(userId) : null },
});
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'DELETE',
oldValue: sanitizePo(existing),
newValue: deleted,
userId,
requestId,
});
};
const submitPurchaseOrder = async (id, payload, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, SUBMITTABLE_STATUSES, 'submit');
if (!existing.purchase_order_items?.length) {
throw new ApiError(422, 'PO must have at least one line item before submit');
}
const updated = await prisma.$transaction(async (tx) => {
await tx.po_approvals.deleteMany({ where: { po_id: BigInt(id) } });
await tx.po_approvals.create({
data: {
po_id: BigInt(id),
approval_level: 1,
status: 'PENDING',
},
});
return tx.purchase_orders.update({
where: { id: BigInt(id) },
data: {
status: 'PENDING_APPROVAL',
remarks: payload.remarks ?? existing.remarks,
updated_by: userId ? BigInt(userId) : null,
},
include: poDetailInclude,
});
});
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'SUBMIT',
oldValue: sanitizePo(existing),
newValue: sanitizePo(updated),
userId,
requestId,
});
return sanitizePo(updated);
};
const approvePurchaseOrder = async (id, payload, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, APPROVABLE_STATUSES, 'approve');
const pendingApproval = existing.po_approvals.find((row) => row.status === 'PENDING');
if (!pendingApproval) throw new ApiError(409, 'No pending approval step found');
const updated = await prisma.$transaction(async (tx) => {
await tx.po_approvals.update({
where: { id: pendingApproval.id },
data: {
status: 'APPROVED',
remarks: payload.remarks || null,
approver_user_id: userId ? BigInt(userId) : null,
acted_at: new Date(),
},
});
return tx.purchase_orders.update({
where: { id: BigInt(id) },
data: {
status: 'APPROVED',
updated_by: userId ? BigInt(userId) : null,
},
include: poDetailInclude,
});
});
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'APPROVE',
oldValue: sanitizePo(existing),
newValue: sanitizePo(updated),
userId,
requestId,
});
return sanitizePo(updated);
};
const rejectPurchaseOrder = async (id, payload, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, APPROVABLE_STATUSES, 'reject');
const pendingApproval = existing.po_approvals.find((row) => row.status === 'PENDING');
if (!pendingApproval) throw new ApiError(409, 'No pending approval step found');
const updated = await prisma.$transaction(async (tx) => {
await tx.po_approvals.update({
where: { id: pendingApproval.id },
data: {
status: 'REJECTED',
remarks: payload.remarks,
approver_user_id: userId ? BigInt(userId) : null,
acted_at: new Date(),
},
});
return tx.purchase_orders.update({
where: { id: BigInt(id) },
data: {
status: 'REJECTED',
updated_by: userId ? BigInt(userId) : null,
},
include: poDetailInclude,
});
});
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'REJECT',
oldValue: sanitizePo(existing),
newValue: sanitizePo(updated),
userId,
requestId,
});
return sanitizePo(updated);
};
const cancelPurchaseOrder = async (id, payload, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, CANCELLABLE_STATUSES, 'cancel');
if (hasReceipts(existing)) throw new ApiError(409, 'Cannot cancel PO with received quantities');
const updated = await prisma.purchase_orders.update({
where: { id: BigInt(id) },
data: {
status: 'CANCELLED',
remarks: payload.remarks ?? existing.remarks,
updated_by: userId ? BigInt(userId) : null,
},
include: poDetailInclude,
});
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'CANCEL',
oldValue: sanitizePo(existing),
newValue: sanitizePo(updated),
userId,
requestId,
});
return sanitizePo(updated);
};
const amendPurchaseOrder = async (id, payload, userId, requestId) => {
const existing = await getPoOrThrow(id, { includeItems: true });
assertStatus(existing, AMENDABLE_STATUSES, 'amend');
const sourceItems = payload.items
? payload.items
: existing.purchase_order_items.map((line) => ({
item_id: line.item_id,
line_no: line.line_no,
ordered_qty: line.ordered_qty,
uom_id: line.uom_id,
rate: line.rate,
discount_pct: line.discount_pct,
discount_amount: line.discount_amount,
gst_rate_id: line.gst_rate_id,
hsn_code_id: line.hsn_code_id,
remarks: line.remarks,
}));
const merged = {
po_date: payload.po_date ?? existing.po_date,
vendor_id: payload.vendor_id ?? existing.vendor_id,
billing_id: payload.billing_id ?? existing.billing_id,
shipping_id: payload.shipping_id ?? existing.shipping_id,
payment_term_id:
payload.payment_term_id !== undefined ? payload.payment_term_id : existing.payment_term_id,
delivery_term_id:
payload.delivery_term_id !== undefined ? payload.delivery_term_id : existing.delivery_term_id,
expected_delivery_date:
payload.expected_delivery_date !== undefined
? payload.expected_delivery_date
: existing.expected_delivery_date,
discount_amount: payload.discount_amount ?? Number(existing.discount_amount),
freight_charges: payload.freight_charges ?? Number(existing.freight_charges),
other_charges: payload.other_charges ?? Number(existing.other_charges),
tds_applicable:
payload.tds_applicable !== undefined ? payload.tds_applicable : existing.tds_applicable,
tds_section_pct:
payload.tds_section_pct !== undefined
? payload.tds_section_pct
: existing.tds_section_pct !== null && existing.tds_section_pct !== undefined
? Number(existing.tds_section_pct)
: null,
adjustment:
payload.adjustment !== undefined ? payload.adjustment : Number(existing.adjustment ?? 0),
terms_and_conditions:
payload.terms_and_conditions !== undefined
? payload.terms_and_conditions
: existing.terms_and_conditions,
remarks: payload.remarks !== undefined ? payload.remarks : existing.remarks,
items: sourceItems,
};
const builtItems = await validateAndBuildItems(sourceItems);
const header = await buildHeaderData(merged, builtItems, userId);
const amended = await repository.createAmendedPurchaseOrder({
header: {
...header,
status: 'DRAFT',
revision_no: existing.revision_no + 1,
parent_po_id: BigInt(id),
created_by: userId ? BigInt(userId) : null,
},
items: builtItems,
});
await prisma.purchase_orders.update({
where: { id: BigInt(id) },
data: {
status: 'CLOSED',
updated_by: userId ? BigInt(userId) : null,
},
});
const created = await getPoOrThrow(amended.id, { includeItems: true });
await auditLog({
tableName: 'purchase_orders',
recordId: id,
action: 'AMEND',
oldValue: sanitizePo(existing),
newValue: sanitizePo(created),
userId,
requestId,
});
return sanitizePo(created);
};
const getPurchaseOrderPdf = async (id) => {
const po = sanitizePo(await getPoOrThrow(id, { forPdf: true }));
const company = await getCompanyForDocuments();
const html = generatePoHtml(buildPoPdfPayload(po, company));
const buffer = await generatePdf(html);
return {
filename: `${po.po_number.replace(/\//g, '-')}.pdf`,
buffer,
};
};
module.exports = {
createPurchaseOrder,
listPurchaseOrders,
listPendingApprovalPurchaseOrders,
exportPurchaseOrders,
getPurchaseOrderById,
updatePurchaseOrder,
deletePurchaseOrder,
submitPurchaseOrder,
approvePurchaseOrder,
rejectPurchaseOrder,
cancelPurchaseOrder,
amendPurchaseOrder,
getPurchaseOrderPdf,
};