274 lines
17 KiB
JavaScript
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 };
|