947 lines
30 KiB
JavaScript
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,
|
|
};
|