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