Files
sap-erp/services/workOrderPdf.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

274 lines
17 KiB
JavaScript

'use strict';
// services/workOrderPdf.js — server-side Production Work Order PDF via pdfmake
// (pure JS, no headless browser). Uses PDF's built-in Helvetica so no font
// files are needed. Mirrors public/work-order-print.html: configurable header
// band, info box, material tables with auto issuance columns, and the five
// approval sign-offs with signature images. Returns a PdfKit document stream.
let _printer = null;
function printer() {
if (_printer) return _printer;
const PdfPrinter = require('pdfmake/src/printer');
_printer = new PdfPrinter({
Helvetica: { normal: 'Helvetica', bold: 'Helvetica-Bold', italics: 'Helvetica-Oblique', bolditalics: 'Helvetica-BoldOblique' },
});
return _printer;
}
const GREY = '#f2f2f2';
const layout = {
hLineWidth: () => 0.5, vLineWidth: () => 0.5,
hLineColor: () => '#000', vLineColor: () => '#000',
paddingLeft: () => 2, paddingRight: () => 2, paddingTop: () => 1.5, paddingBottom: () => 1.5,
};
const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtD(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return ''; return `${String(d.getDate()).padStart(2,'0')}-${MON[d.getMonth()]}-${d.getFullYear()}`; }
function num3(v) { if (v == null || v === '') return ''; const n = Number(v); if (!isFinite(n) || n === 0) return ''; return String(Math.round(n * 1000) / 1000); }
const S = (v) => (v == null ? '' : String(v));
// Raw Material's "Qty Req./100 ml" / "Qty Req." auto-scale for display —
// EXACT mirror of public/work-order.html's own scaling (its "View" mode is
// the reference this must match; see that file for the full rationale).
// Read-only here: this just reproduces the same scaled numbers/units the
// app itself shows on-screen (Weight family mg/gm/Kg/Ton, Volume family
// µL/mL/Ltrs/KL — ratios are IDENTICAL between the two, so the same
// underlying number relabels cleanly as either), honoring each row's own
// saved display-unit choice (stdQtyDispUnit/qtyReqDispUnit) first and
// falling back to the auto-picked best-fit unit within the item's own
// family (volume for a liquid real UOM like LTR, weight otherwise).
function round3(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
function toStockQty(grams, uom) { const u = (uom || '').toUpperCase().trim();
return (u.startsWith('KG') || u.startsWith('LTR') || u.startsWith('LITR') || u === 'L' || u === 'LT' || u === 'KL') ? grams / 1000 : grams; }
function fromStockQty(qty, uom) { const u = (uom || '').toUpperCase().trim();
return (u.startsWith('KG') || u.startsWith('LTR') || u.startsWith('LITR') || u === 'L' || u === 'LT' || u === 'KL') ? qty * 1000 : qty; }
const UNIT_FAMILIES = {
volume: { 'µL': 1, 'mL': 1000, 'Ltrs': 1e6, 'KL': 1e9 },
weight: { 'mg': 1, 'gm': 1000, 'Kg': 1e6, 'Ton': 1e9 },
};
function defaultRawFamily(uom) {
const u = (uom || '').trim().toUpperCase();
return ['LTR', 'LTRS', 'LITRE', 'LITER', 'LITRES', 'LITERS', 'ML', 'KL', 'L'].includes(u) ? 'volume' : 'weight';
}
const RAW_UNIT_OPTIONS = { ...UNIT_FAMILIES.weight, ...UNIT_FAMILIES.volume };
function scaleGrams(grams, uom) {
const totalBase = (Number(grams) || 0) * 1000;
const units = UNIT_FAMILIES[defaultRawFamily(uom)];
const entries = Object.entries(units).sort((a, b) => b[1] - a[1]);
for (const [u, mult] of entries) { if (totalBase >= mult) return { value: round3(totalBase / mult), label: u }; }
const smallest = entries[entries.length - 1];
return { value: round3(totalBase / smallest[1]), label: smallest[0] };
}
function scaleGramsToUnit(grams, unit) {
const mult = RAW_UNIT_OPTIONS[unit] != null ? RAW_UNIT_OPTIONS[unit] : 1000;
return round3((Number(grams) || 0) * 1000 / mult);
}
// Admin-configured per-item SOP display factor — see work-order.html's
// potencyFactor() for the full rationale. Applies ONLY to stdQty ("Qty
// Req./100 ml"); qtyReq ("Qty Req.") is never adjusted.
const appSettings = require('./appSettingsStore');
function potencyFactor(itemCode) {
const p = parseFloat((appSettings.woRawPotencyFactors() || {})[itemCode]);
return (p > 0) ? p / 100 : 1;
}
// srcField lets a caller display a DIFFERENT row value (e.g. the persisted
// "qtyIssued") using 'field''s own unit/scaling conventions — used by the Qty
// Issued column for Raw Material rows (see rawSection() below).
function rawScaledDisp(r, field, noUnit, srcField) {
const vf = srcField || field;
if (r.directRaw) return S(r[vf]);
const dispField = field === 'stdQty' ? 'stdQtyDispUnit' : 'qtyReqDispUnit';
const raw = parseFloat(r[vf]) || 0;
const grams = field === 'stdQty' ? raw * potencyFactor(r.itemCode) : fromStockQty(raw, r.uom);
const unit = r[dispField] || scaleGrams(grams, r.uom).label;
const value = scaleGramsToUnit(grams, unit);
return noUnit ? String(value) : `${value} ${unit}`;
}
// Scaled unit label alone, for the UOM column — shows Qty Req./100 ml's
// unit (the user's own chosen one, if set) instead of the item's static SAP
// stock UOM.
function rawScaledUnit(r) {
if (r.directRaw) return S(r.uom);
const grams = (parseFloat(r.stdQty) || 0) * potencyFactor(r.itemCode);
return S(r.stdQtyDispUnit || scaleGrams(grams, r.uom).label);
}
function K(text, opts) { return Object.assign({ text: S(text), bold: true, fillColor: GREY, fontSize: 7 }, opts || {}); }
function C(text, opts) { return Object.assign({ text: S(text), fontSize: 7 }, opts || {}); }
// A signature cell: signature image (if any) over name + date.
function sigCell(sig, sigs, opts) {
if (!sig || !sig.name) return Object.assign({ text: '' }, opts || {});
const stack = [];
const img = sig.user && sigs[sig.user];
if (img) stack.push({ image: img, fit: [92, 24], alignment: 'center' });
stack.push({ text: S(sig.name), bold: true, fontSize: 6.5, alignment: 'center' });
if (sig.at) stack.push({ text: fmtD(sig.at), fontSize: 6, color: '#444', alignment: 'center' });
return Object.assign({ stack, alignment: 'center' }, opts || {});
}
// Per-ROW Issued/Received/Verified sign-offs — each material line is stamped
// independently (see public/verify-work-order.html), since different items
// can be issued/received/verified on different days by different people.
function rowSig(row, which) {
const at = row[`${which}At`];
if (!at) return null;
return { name: row[`${which}ByName`] || row[`${which}By`], user: row[`${which}By`], at };
}
function rowSigCells(row, sigs) {
return [sigCell(rowSig(row, 'issued'), sigs, {}), sigCell(rowSig(row, 'received'), sigs, {}), sigCell(rowSig(row, 'verified'), sigs, {})];
}
function headerBand(settings, pageBreak) {
const logo = settings.woLogo
? { image: settings.woLogo, fit: [74, 40], alignment: 'center', rowSpan: 2, margin: [0, 4, 0, 0] }
: { text: '', rowSpan: 2 };
const t = {
table: { widths: [80, 72, '*', 86, '*', 86, '*'], body: [
[ logo,
{ stack: [
{ text: S(settings.woCompanyName), bold: true, fontSize: 13, alignment: 'center' },
{ text: S(settings.woCompanyAddress), fontSize: 8, alignment: 'center' },
], colSpan: 6, margin: [0, 4, 0, 4] }, {}, {}, {}, {}, {} ],
[ {}, K('FORM NO.', { alignment: 'center' }), C(settings.woFormNo, { alignment: 'center' }),
K('EFFECTIVE DATE', { alignment: 'center' }), C(settings.woEffectiveDate, { alignment: 'center' }),
K('REVIEW DATE', { alignment: 'center' }), C(settings.woReviewDate, { alignment: 'center' }) ],
] },
layout,
};
if (pageBreak) t.pageBreak = 'before';
return t;
}
function titleBar(suffix) {
return { table: { widths: ['*'], body: [[{ text: 'PRODUCTION WORK ORDER' + (suffix || ''), bold: true, alignment: 'center', fillColor: GREY, fontSize: 10 }]] }, layout };
}
function infoBox(wo, newItemCode) {
const pn = [wo.productName, wo.productDesc].filter(Boolean).join(' — ');
const sameCode = newItemCode && String(newItemCode).trim().toUpperCase() === String(wo.productCode || '').trim().toUpperCase();
const productCode = wo.productCode + (newItemCode && !sameCode ? ` (${newItemCode})` : '');
const body = [
[K('Product Name & Description'), Object.assign(C(pn), { colSpan: 5 }), {}, {}, {}, {}],
[K('Generic Name'), Object.assign(C(wo.genericName), { colSpan: 5 }), {}, {}, {}, {}],
[K('Batch Number'), C(wo.batchNumber), K('Batch Size'), C(wo.batchSize), K('Total Units'), C(wo.totalUnits)],
[K('MFG. Date'), C(wo.mfgDate), K('EXP. Date'), C(wo.expDate), K('Pack Size'), C(wo.packSize)],
[K('Reference'), C(wo.reference), K('Product code'), C(productCode), K('Type'), C(wo.type)],
[K('Market'), C(wo.market), K('Intimation No.'), Object.assign(C(wo.intimationDocNo), { colSpan: 3 }), {}, {}],
];
return { table: { widths: [112, '*', 82, '*', 66, '*'], body }, layout };
}
function rawSection(wo, iss, sigs) {
const map = new Map();
// solBatchSize can legitimately be 0 (tiny Total Units rounded down) — check
// for null/undefined specifically so a real 0 still prints, not silently
// dropped by `|| ''`/truthiness checks.
(wo.rawMaterials || []).forEach(r => { const k = r.solCode || ''; if (!map.has(k)) map.set(k, { name: r.solName || '', batchSize: r.solBatchSize != null ? r.solBatchSize : '', rows: [] }); map.get(k).rows.push(r); });
const nodes = [];
for (const g of map.values()) {
if (g.name) nodes.push({ text: g.name + (g.batchSize !== '' ? ` — Batch Size ${g.batchSize} Ltr` : ''), bold: true, fontSize: 8, alignment: 'center', fillColor: GREY, margin: [0, 4, 0, 0] });
const widths = [38, '*', 24, 36, 22, 20, 40, 48, 34, 34, 58, 58, 58];
const head = ['Item code','Raw materials','Spec','Qty Req./100 ml','UOM','Ovg.','Qty Req.','Weighing Balance ID','AR No.','Qty issued','Issued by /date','Received by','Verified by'].map(h => K(h, { alignment: 'center' }));
const body = [head];
g.rows.forEach((r) => {
// Raw Material rows are never SAP Production Order lines themselves
// (they're the exploded recipe of a Solution/SFG item, which IS the PO
// line), so their Qty Issued can't come from iss.qtyByItem like
// Packing Material's does — it's prorated and PERSISTED on the row
// itself server-side (see routes/sap.js's /issue-production).
const issuedQty = r.qtyIssued ? rawScaledDisp(r, 'qtyReq', false, 'qtyIssued') : '';
const row = [C(r.itemCode, { alignment: 'center' }), C(r.rawMaterial), C(r.spec, { alignment: 'center' }), C(rawScaledDisp(r, 'stdQty', true), { alignment: 'center' }), C(rawScaledUnit(r), { alignment: 'center' }), C(r.ovg, { alignment: 'center' }), C(rawScaledDisp(r, 'qtyReq'), { alignment: 'center' }), C(r.weighingBalanceId, { alignment: 'center' }), C(r.arNo, { alignment: 'center' }), C(issuedQty, { alignment: 'center' }), ...rowSigCells(r, sigs)];
body.push(row);
});
nodes.push({ table: { widths, headerRows: 1, body }, layout, margin: [0, 0, 0, 4] });
}
return nodes;
}
// Packing Material/Components rows ARE real SAP Production Order lines, so
// under the default 'issue_for_production' their Qty Issued reads live off
// iss.qtyByItem; only under 'mark_issued' (Admin → System Settings → "Raw
// Material Qty Issued Source") do they ALSO switch to the server-PERSISTED
// row.qtyIssued, matching Raw Material's own always-persisted behavior.
function packQtyIssued(r, iss, qtyIssuedSource) {
if (qtyIssuedSource === 'mark_issued') return r.qtyIssued ? num3(r.qtyIssued) : '';
return num3(iss.qtyByItem[(r.itemCode || '').trim()]);
}
function packSection(wo, iss, sigs, qtyIssuedSource) {
const rows = wo.packingMaterials || [];
const widths = [40, '*', 60, 50, 30, 30, 54, 34, 40, 60, 60, 60];
const head = ['Item code','Packing Materials','Artwork No.','Std. Qty/Unit','UOM','Ovg.%','Qty Req.(Units)','AR No.','Qty issued','Issued by /date','Received by','Verified by'].map(h => K(h, { alignment: 'center' }));
const body = [head];
if (!rows.length) body.push([{ text: '—', colSpan: 12, alignment: 'center' }, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}]);
rows.forEach((r) => {
const std = r.stdQtyUnit ? `${S(r.stdQtyPerUnit)} ${S(r.stdQtyUnit)}` : S(r.stdQtyPerUnit);
const qty = r.qtyUnitLabel ? `${S(r.qtyReqUnits)} ${S(r.qtyUnitLabel)}` : S(r.qtyReqUnits);
const row = [C(r.itemCode, { alignment: 'center' }), C(r.packingMaterial), C(r.artworkNo, { alignment: 'center' }), C(std, { alignment: 'center' }), C(r.uom || '-', { alignment: 'center' }), C(r.ovgPercent, { alignment: 'center' }), C(qty, { alignment: 'center' }), C(r.arNo, { alignment: 'center' }), C(packQtyIssued(r, iss, qtyIssuedSource), { alignment: 'center' }), ...rowSigCells(r, sigs)];
body.push(row);
});
return { table: { widths, headerRows: 1, body }, layout, margin: [0, 0, 0, 4] };
}
function componentsSection(wo, iss, sigs, qtyIssuedSource) {
const rows = wo.packingMaterials || []; // components-only mode stores rows here
const widths = [44, '*', 60, 30, 36, 70, 40, 44, 64, 64, 64];
const head = ['Item code','Components','Std. Qty/Unit','UOM','Ovg.','Qty Req.','AR No.','Qty issued','Issued by /date','Received by','Verified by'].map(h => K(h, { alignment: 'center' }));
const body = [head];
if (!rows.length) body.push([{ text: '—', colSpan: 11, alignment: 'center' }, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}]);
rows.forEach((r) => {
const std = r.stdQtyUnit ? `${S(r.stdQtyPerUnit)} ${S(r.stdQtyUnit)}` : S(r.stdQtyPerUnit);
const qty = r.qtyUnitLabel ? `${S(r.qtyReqUnits)} ${S(r.qtyUnitLabel)}` : S(r.qtyReqUnits);
const row = [C(r.itemCode, { alignment: 'center' }), C(r.packingMaterial), C(std, { alignment: 'center' }), C(r.uom || '-', { alignment: 'center' }), C(r.ovgPercent, { alignment: 'center' }), C(qty, { alignment: 'center' }), C(r.arNo, { alignment: 'center' }), C(packQtyIssued(r, iss, qtyIssuedSource), { alignment: 'center' }), ...rowSigCells(r, sigs)];
body.push(row);
});
return { table: { widths, headerRows: 1, body }, layout, margin: [0, 0, 0, 4] };
}
// 'aliases' covers a step whose display name changed later (e.g. "Checked
// By Production" → "Checked By Store In-Charge") — older Work Orders'
// workflowLog entries were recorded with the OLD text at the time and that
// stays exactly as-is (it's an immutable historical record), so the lookup
// below must still match it to print that sign-off correctly.
const STEP_LABELS = [
{ step: 'Prepared By QA', label: 'Prepared By (QA)' },
{ step: 'Checked By QC', label: 'Checked By (QC)' },
{ step: 'Checked By Store In-Charge', aliases: ['Checked By Production'], label: 'Checked By (Store In-Charge)' },
{ step: 'Checked By (Manager Production)', label: 'Checked By (Manager Production)' },
{ step: 'Approved By (Manager QA)', label: 'Approved By (Manager QA)' },
];
function signoffs(wo, sigs) {
const log = Array.isArray(wo.workflowLog) ? wo.workflowLog : [];
// A step this WO auto-skipped (e.g. "Checked By QC" while workOrderSkipQc
// is on) is left off the printed sign-off list entirely — matches the
// stepper on the Work Order page itself (public/work-order.html stepper()).
const skipped = new Set(log.filter(l => l.action === 'auto-skipped').map(l => l.step));
const visible = STEP_LABELS.filter(s => !skipped.has(s.step) && !(s.aliases || []).some(a => skipped.has(a)));
const logFor = (step, aliases) => { for (let i = log.length - 1; i >= 0; i--) { const l = log[i]; if ((l.step === step || (aliases || []).includes(l.step)) && l.action !== 'rejected' && l.action !== 'auto-skipped') return l; } return null; };
const body = visible.map(s => {
const l = logFor(s.step, s.aliases);
const sig = l ? { name: l.byName || l.by, user: l.by, at: l.at } : null;
return [Object.assign(K(s.label), { margin: [2, 8, 2, 8] }), sigCell(sig, sigs, { margin: [2, 3, 2, 3], minHeight: 30 })];
});
return { table: { widths: [240, '*'], body }, layout, margin: [0, 6, 0, 0] };
}
function buildDoc({ wo, settings, iss, sigs, newItemCode, qtyIssuedSource }) {
// Authoritative flag, not inferred from rawMaterials.length — a
// componentsOnly save also writes a legacy-compatible copy of the same
// components into rawMaterials, so its length alone can't be used to tell
// components-only orders apart from real split Raw/Packing ones.
const componentsMode = !!wo.componentsOnly;
const content = [];
if (componentsMode) {
content.push(headerBand(settings), titleBar(''), infoBox(wo, newItemCode), componentsSection(wo, iss, sigs, qtyIssuedSource), signoffs(wo, sigs));
} else {
content.push(headerBand(settings), titleBar(' (RAW MATERIAL)'), infoBox(wo, newItemCode), ...rawSection(wo, iss, sigs), signoffs(wo, sigs));
content.push(headerBand(settings, true), titleBar(' (PACKING MATERIAL)'), infoBox(wo, newItemCode), packSection(wo, iss, sigs, qtyIssuedSource), signoffs(wo, sigs));
}
return {
pageSize: 'A4', pageOrientation: 'landscape', pageMargins: [16, 14, 16, 24],
defaultStyle: { font: 'Helvetica', fontSize: 7 }, content,
footer: (currentPage, pageCount) => ({ text: `Page ${currentPage} of ${pageCount}`, alignment: 'center', fontSize: 8, margin: [0, 6, 0, 0] }),
};
}
// Returns a PdfKit document stream (call .pipe(res) then it self-ends).
function generate(data) {
const doc = printer().createPdfKitDocument(buildDoc(data));
return doc;
}
module.exports = { generate, buildDoc };