first commit
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s

This commit is contained in:
John
2026-09-23 17:31:02 +05:30
commit 69b4e68baf
51657 changed files with 3864077 additions and 0 deletions
+543
View File
@@ -0,0 +1,543 @@
'use strict';
// routes/workOrders.js — Production Work Orders (generated from Batch Intimation)
const express = require('express');
const router = express.Router();
const { verifyToken, requireApprovalStep, requireWorkflowPerm, hasStepPerm } = require('../middleware/auth');
const store = () => require('../services/workOrderStore');
const notify = () => require('../services/notifyStore');
// MFG/EXP accept any of 6 formats (empty allowed) — same set as the
// picker-only date fields in public/work-order.html, public/batch-issuance.html
// and public/receipt-production.html: DD-MMM-YYYY, DD-MM-YYYY, MMM-YYYY,
// MM-YYYY, YYYY-MMM, YYYY-MM. Kept in sync with those — this backend check
// must never fall behind the frontend's accepted formats again.
const DATE_RES = [
/^(\d{1,2})[-/]([A-Za-z]{3})[-/](\d{4})$/, // DD-MMM-YYYY
/^(\d{1,2})-(\d{1,2})-(\d{4})$/, // DD-MM-YYYY
/^([A-Za-z]{3})[-/](\d{4})$/, // MMM-YYYY
/^(\d{1,2})-(\d{4})$/, // MM-YYYY
/^(\d{4})-([A-Za-z]{3})$/, // YYYY-MMM
/^(\d{4})-(\d{1,2})$/, // YYYY-MM
];
function isValidMEDate(v) { const t = String(v || '').trim(); return !t || DATE_RES.some(re => re.test(t)); }
function badDate(v) { return v && !isValidMEDate(v); }
function normRaw(rows) {
return (rows || [])
.filter(r => (r.itemCode || '').trim() || (r.rawMaterial || '').trim())
.map(r => ({
itemCode: (r.itemCode || '').trim(),
rawMaterial: (r.rawMaterial || '').trim(),
spec: (r.spec || '').trim(),
stdQty: (r.stdQty || '').toString().trim(), // Qty Req. (std/per unit)
uom: (r.uom || '').trim(),
ovg: (r.ovg || '').toString().trim(),
qtyReq: (r.qtyReq || '').toString().trim(), // Qty Req. (total)
weighingBalanceId: (r.weighingBalanceId || '').trim(),
arNo: (r.arNo || '').trim(),
// Multi-solution support: which Solution this raw material belongs to,
// and that solution's own Batch Size (Ltr) — different solutions in the
// same product can have different batch sizes.
solCode: (r.solCode || '').trim(),
solName: (r.solName || '').trim(),
solBatchSize: (r.solBatchSize || '').toString().trim(),
solPerUnitLitres: r.solPerUnitLitres != null && r.solPerUnitLitres !== '' ? Number(r.solPerUnitLitres) : '',
solBSManual: !!r.solBSManual,
// Item Group Rules "RAW" override: shown as itself (not exploded from a
// Solution), qty calc = Std Qty/Unit × Total Units × (1+Ovg%) — see
// recalcMaterials() in work-order.html.
directRaw: !!r.directRaw,
}));
}
function normPack(rows) {
return (rows || [])
.filter(r => (r.itemCode || '').trim() || (r.packingMaterial || '').trim())
.map(r => ({
itemCode: (r.itemCode || '').trim(),
packingMaterial: (r.packingMaterial || '').trim(),
artworkNo: (r.artworkNo || '').trim(),
stdQtyPerUnit: (r.stdQtyPerUnit || '').toString().trim(),
// Stock UOM from SAP (display-only column) — was missing from this
// whitelist entirely, so it silently vanished on every save even
// though it displayed correctly right after loading the BOM.
uom: (r.uom || '').trim(),
// Std. Qty/Unit's own chosen display unit (mg/gm/Kg/ml/etc, or blank
// for a plain count) and the resulting Qty Req.(Units) unit label —
// also missing before, so picking a real unit never survived a save.
stdQtyUnit: (r.stdQtyUnit || '').trim(),
qtyUnitLabel: (r.qtyUnitLabel || '').trim(),
ovgPercent: (r.ovgPercent || '').toString().trim(),
qtyReqUnits: (r.qtyReqUnits || '').toString().trim(),
// AR No. is normally set via the separate per-row PATCH on Verify Work
// Order, but was missing here too — meaning a later edit+save of the
// WHOLE Work Order (e.g. a QA correction) would silently erase every
// AR No. already recorded, since this whitelist is what's actually
// persisted, not just what the client happens to send.
arNo: (r.arNo || '').trim(),
// Per-row Round Up/Exact override (work-order.html's round-pill on a
// "no unit picked" row) — same class of bug as the ones above: missing
// from this whitelist, so any edit+save of the Work Order silently
// reset every row back to the global default, even though the pill
// itself displayed the override correctly right up until that save.
roundUp: r.roundUp != null ? !!r.roundUp : null,
}));
}
function pickHeader(b) {
return {
reference: b.reference || '',
productName: b.productName || '',
productDesc: b.productDesc || '',
genericName: b.genericName || '',
productCode: b.productCode || '',
batchNumber: b.batchNumber || '',
batchSize: b.batchSize || '',
totalUnits: b.totalUnits || '',
mfgDate: b.mfgDate || null,
expDate: b.expDate || null,
packSize: b.packSize || '',
type: b.type || '',
market: b.market || '',
remarks: b.remarks || '',
rawMaterials: normRaw(b.rawMaterials),
packingMaterials: normPack(b.packingMaterials),
componentsOnly: !!b.componentsOnly,
};
}
router.get('/', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
try {
const { mine, company, status } = req.query;
const data = await store().listWorkOrders({
mine: mine === '1' ? req.user.username : undefined, company, status,
});
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
router.get('/:id', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
try {
const r = await store().findById(req.params.id);
if (!r || r.isDeleted) return res.status(404).json({ success: false, message: 'Not found' });
res.json({ success: true, data: r });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Create (generate) a work order — this IS the "Prepared By QA" step, so only
// users assigned that approval step (or admin) may create one. Having the
// production-work-order MODULE just lets a user open/view the page; it does
// not by itself grant the right to originate a new work order.
router.post('/', verifyToken, requireApprovalStep('work_order:prepared_qa', 'add'), async (req, res) => {
try {
const b = req.body || {};
if (!(b.productName || b.productCode))
return res.status(400).json({ success: false, message: 'Product Name / Code is required' });
if (badDate(b.mfgDate) || badDate(b.expDate))
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date — use DD-MMM-YYYY or MMM/YYYY' });
// A given (Intimation, Product, Batch No.) combination may only ever
// produce ONE Work Order — its quantity is fully captured the first time.
// Client-side the picker hides/disables already-used batches, but this is
// the enforcing check (the client guard alone can be bypassed).
if (b.intimationId) {
const existing = await store().listWorkOrders({});
const dup = existing.find(w => !w.isDeleted
&& String(w.intimationId) === String(b.intimationId)
&& (w.productCode || '') === (b.productCode || '')
&& (w.batchNumber || '') === (b.batchNumber || ''));
if (dup)
return res.status(409).json({ success: false, message: `A Work Order (${dup.woNo}) has already been generated for this Intimation's batch "${b.batchNumber || b.productCode}" — its full quantity is already captured.` });
}
const saved = await store().insertWorkOrder({
...pickHeader(b),
intimationId: b.intimationId || null,
company: b.company || '',
createdBy: req.user.username, createdByName: req.user.name || req.user.username,
});
res.json({ success: true, data: saved });
if (saved.status === 'IN_PROGRESS') {
const nextKey = WORK_ORDER_STEP_KEYS[saved.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
title: `Work Order ${saved.woNo} — awaiting ${saved.currentStep}`,
lines: [['Work Order', saved.woNo], ['Product', saved.productName || ''], ['Created By', saved.createdByName], ['Pending Step', saved.currentStep]],
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${saved.id}`,
excludeUsernames: [req.user.username],
});
}
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Edit header/materials — normally only while In Progress (requires 'edit'
// perm on whichever step currently owns the record, same step that would
// act next). A REJECTED work order is ALSO editable, but as a combined
// edit+resubmit: the save both applies the changes AND restarts the full
// approval chain from step 1 (see resubmitAfterReject) — since none of the
// original approvers ever saw the edited content. Editing a rejected order
// is gated on the FIRST step's 'edit' permission (the same step it restarts
// at), not the step that happened to reject it.
router.put('/:id', verifyToken, async (req, res) => {
try {
const existing = await store().findById(req.params.id);
if (!existing || existing.isDeleted) return res.status(404).json({ success: false, message: 'Not found' });
if (badDate(req.body?.mfgDate) || badDate(req.body?.expDate))
return res.status(400).json({ success: false, message: 'Invalid MFG/EXP date — use DD-MMM-YYYY or MMM/YYYY' });
if (existing.status === 'REJECTED') {
const firstStepKey = WORK_ORDER_STEP_KEYS[0];
if (!hasStepPerm(req.user, `work_order:${firstStepKey}`, 'edit'))
return res.status(403).json({ success: false, message: `You are not assigned "edit" on approval step: work_order:${firstStepKey}` });
const updated = await store().resubmitAfterReject(req.params.id, pickHeader(req.body || {}), {
by: req.user.username, byName: req.user.name || req.user.username,
});
res.json({ success: true, data: updated });
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
title: `Work Order ${updated.woNo} — Resubmitted, awaiting ${updated.currentStep}`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Resubmitted By', req.user.name || req.user.username], ['Pending Step', updated.currentStep]],
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`,
excludeUsernames: [req.user.username],
});
return;
}
if (existing.status !== 'IN_PROGRESS')
return res.status(409).json({ success: false, message: 'Work order is ' + existing.status.toLowerCase() + ' and cannot be edited' });
const curStepKey = WORK_ORDER_STEP_KEYS[existing.stage];
if (!curStepKey || !hasStepPerm(req.user, `work_order:${curStepKey}`, 'edit'))
return res.status(403).json({ success: false, message: `You are not assigned "edit" on approval step: work_order:${curStepKey || '?'}` });
const updated = await store().updateWorkOrder(req.params.id, pickHeader(req.body || {}));
res.json({ success: true, data: updated });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// Workflow: approve (advance) or reject
// Index-aligned with services/workOrderStore.js STEPS (both are the 5-step
// QA/Production sign-off chain) and services/approvalStepsStore.js's
// workflow:'work_order' step keys.
const WORK_ORDER_STEP_KEYS = ['prepared_qa', 'checked_qc', 'checked_production', 'checked_mgr_production', 'approved_mgr_qa'];
router.patch('/:id/action', verifyToken, async (req, res) => {
try {
const action = (req.body.action || '').toLowerCase();
if (!['approve', 'reject'].includes(action))
return res.status(400).json({ success: false, message: 'action must be approve or reject' });
if (action === 'reject' && !(req.body.remarks || '').trim())
return res.status(400).json({ success: false, message: 'A reason is required to reject a Work Order' });
{
const wo = await store().findById(req.params.id);
if (!wo) return res.status(404).json({ success: false, message: 'Work order not found' });
const stepKey = WORK_ORDER_STEP_KEYS[wo.stage];
if (!stepKey || !hasStepPerm(req.user, `work_order:${stepKey}`, 'approve'))
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: work_order:${stepKey || '?'}` });
}
const updated = await store().workflowAction(req.params.id, {
action, by: req.user.username, byName: req.user.name || req.user.username,
remarks: req.body.remarks || '',
});
res.json({ success: true, data: updated });
const woUrl = `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`;
if (action === 'reject') {
notify().notify({
stepFullKey: 'work_order:prepared_qa',
title: `Work Order ${updated.woNo} — Rejected`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Rejected By', req.user.name || req.user.username], ['Remarks', req.body.remarks || '']],
url: woUrl,
excludeUsernames: [req.user.username],
});
} else if (updated.status === 'IN_PROGRESS') {
const nextKey = WORK_ORDER_STEP_KEYS[updated.stage];
notify().notify({
stepFullKey: nextKey ? `work_order:${nextKey}` : null,
title: `Work Order ${updated.woNo} — awaiting ${updated.currentStep}`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Approved By', req.user.name || req.user.username], ['Pending Step', updated.currentStep]],
url: woUrl,
excludeUsernames: [req.user.username],
});
} else if (updated.status === 'APPROVED') {
notify().notify({
stepFullKey: 'work_order:prepared_qa',
title: `Work Order ${updated.woNo} — Fully Approved`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Final Approval By', req.user.name || req.user.username]],
url: woUrl,
excludeUsernames: [req.user.username],
});
}
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// Admin-only recall of a FULLY APPROVED Work Order back to QA for editing —
// not a normal in-flight rejection (no approval step is checked), just an
// override for a document that already finished its whole chain. Re-uses
// the exact same status ('REJECTED') the normal reject action sets, so the
// existing "Edit & Resubmit" flow (PUT /:id above) picks it up for free —
// once edited, it restarts the full 5-step chain from Prepared By QA.
router.post('/:id/send-to-qa', verifyToken, async (req, res) => {
try {
if (req.user.role !== 'admin' && req.user.role !== 'system_admin')
return res.status(403).json({ success: false, message: 'Only admin/system admin can send a fully approved Work Order back to QA' });
const updated = await store().sendBackToQaForEdit(req.params.id, {
by: req.user.username, byName: req.user.name || req.user.username,
remarks: req.body?.remarks || '',
});
res.json({ success: true, data: updated });
notify().notify({
stepFullKey: 'work_order:prepared_qa',
title: `Work Order ${updated.woNo} — Sent back to QA for edit`,
lines: [['Work Order', updated.woNo], ['Product', updated.productName || ''], ['Sent Back By', req.user.name || req.user.username], ['Remarks', req.body?.remarks || '']],
url: `${process.env.APP_BASE_URL || ''}/work-order?id=${updated.id}`,
excludeUsernames: [req.user.username],
});
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
router.delete('/:id', verifyToken, async (req, res) => {
try {
const existing = await store().findById(req.params.id);
if (!existing) return res.status(404).json({ success: false, message: 'Not found' });
const curStepKey = WORK_ORDER_STEP_KEYS[existing.stage];
if (!hasStepPerm(req.user, `work_order:${curStepKey || 'prepared_qa'}`, 'delete'))
return res.status(403).json({ success: false, message: `You are not assigned "delete" on approval step: work_order:${curStepKey || 'prepared_qa'}` });
await store().softDelete(req.params.id);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Per-row material fields (Weighing Balance ID / AR No.) ─────────────────
// Editable by whoever holds 'edit' on work_order:issue — independent of the
// work order's own approval status/edit-lock (this happens AFTER approval,
// at the moment materials are physically issued).
router.patch('/:id/row/:section/:index', verifyToken, async (req, res) => {
try {
const section = req.params.section;
if (!['raw', 'pack'].includes(section)) return res.status(400).json({ success: false, message: 'section must be "raw" or "pack"' });
if (!hasStepPerm(req.user, 'work_order:issue', 'edit'))
return res.status(403).json({ success: false, message: 'You are not assigned "edit" on approval step: work_order:issue' });
const patch = {};
if (req.body.weighingBalanceId !== undefined && section === 'raw') patch.weighingBalanceId = String(req.body.weighingBalanceId || '').trim();
if (req.body.arNo !== undefined) patch.arNo = String(req.body.arNo || '').trim();
if (!Object.keys(patch).length) return res.status(400).json({ success: false, message: 'Nothing to update' });
const updated = await store().patchMaterialRow(req.params.id, section, req.params.index, patch);
res.json({ success: true, data: updated });
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Per-row Issued/Received/Verified one-click stamps ───────────────────────
// Each is a SEPARATE portal-only sign-off on that ONE material row — not the
// whole work order — because different items can be issued/received/verified
// on different days by different people. Each is gated by 'approve' on its
// own approval step, and can only be stamped once per row.
const ROW_STAMP_STEPS = { issued: 'work_order:issue', received: 'work_order:receive', verified: 'work_order:verify' };
// Enforced order per row: Issued → Received → Verified. Each key names the
// PRECEDING stamp that must already exist before this one can be set.
const ROW_STAMP_PREREQ = { received: 'issued', verified: 'received' };
router.post('/:id/row/:section/:index/mark', verifyToken, async (req, res) => {
try {
const section = req.params.section;
if (!['raw', 'pack'].includes(section)) return res.status(400).json({ success: false, message: 'section must be "raw" or "pack"' });
const which = (req.body.which || '').toLowerCase();
const stepKey = ROW_STAMP_STEPS[which];
if (!stepKey) return res.status(400).json({ success: false, message: 'which must be issued, received, or verified' });
// Production Order Issuance (production_order:issuance) and Work Order
// row-stamping are different screens/responsibilities — no longer
// coupled. Marking a row here always requires its own explicit
// approval-step grant.
const allowed = hasStepPerm(req.user, stepKey, 'approve');
if (!allowed)
return res.status(403).json({ success: false, message: `You are not assigned "approve" on approval step: ${stepKey}` });
const wo = await store().findById(req.params.id);
if (!wo) return res.status(404).json({ success: false, message: 'Work order not found' });
const arr = (section === 'raw' ? wo.rawMaterials : wo.packingMaterials) || [];
const i = parseInt(req.params.index);
if (!(i >= 0) || i >= arr.length) return res.status(404).json({ success: false, message: 'Row not found' });
const row = arr[i];
const atField = `${which}At`;
// A stamp can only be set once — EXCEPT for a woVerifyOverride user (or
// admin), who may re-stamp ANY of the three (issued/received/verified)
// even after it's already signed, to correct who it's recorded as
// signed by and/or its date. That's the actual point of the override:
// catching up/correcting paperwork on any step, not just Verified, and
// not just rows nobody has touched yet.
const canOverrideStamp = req.user.role === 'admin' || req.user.woVerifyOverride;
if (row[atField] && !canOverrideStamp) return res.status(400).json({ success: false, message: `This row's "${which}" was already stamped by ${row[which + 'ByName'] || row[which + 'By']}` });
// Per-user item-group restriction (Admin → user → Issue Items) — same rule
// enforced on the real SAP issuance (routes/sap.js). Marking a row Issued
// must be blocked for item groups this user isn't allowed to issue, even
// if they hold Issue for Production generally.
if (which === 'issued') {
try {
const acting = await require('../services/hanaUsers').findById(req.user.id);
const allowedGroups = Array.isArray(acting && acting.issueItemGroups) ? acting.issueItemGroups.map(String) : [];
if (allowedGroups.length && row.itemCode) {
const { getPool } = require('../services/sqlPool');
// Must query the WO's OWN company database, not whatever the shared
// pool happens to default to (services/sqlPool.js's getPool() is
// per-database now — see [[displayed-sap-company-restriction]]) —
// this route gets no `company` param from the frontend at all, so
// the Work Order record's own stored company is the source of truth.
const pool = await getPool(wo.company || null);
const r = await pool.request().query(`SELECT "ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" = '${String(row.itemCode).replace(/'/g, "''")}'`);
const grp = r.recordset && r.recordset[0] ? String(r.recordset[0].ItmsGrpCod) : null;
if (!grp || !allowedGroups.includes(grp))
return res.status(403).json({ success: false, message: `You are not permitted to issue this item (${row.itemCode}) — its item group is not in your allowed list.` });
}
} catch (e) { console.warn('[WO-ROW-MARK] item-group restriction check failed:', e.message); }
}
// A woVerifyOverride/admin user may sign a stage out of the normal
// Issued→Received→Verified order too (e.g. catching up Verified before
// Received ever gets marked in the portal) — everyone else must still
// follow it.
const prereq = ROW_STAMP_PREREQ[which];
if (prereq && !row[`${prereq}At`] && !canOverrideStamp)
return res.status(400).json({ success: false, message: `Mark "${prereq}" on this row before "${which}".` });
// Normal case: the stamp always records the ACTUAL logged-in user, right
// now — no client input is trusted for who/when. The one narrow
// exception: a user explicitly granted woVerifyOverride (Admin → Edit
// User), or anyone with the admin role (same bypass every approval-step
// check gives admin elsewhere — see hasStepPerm), can, on ANY of the
// three stamps (issued/received/verified), sign as a different user
// and/or backdate the sign date — for catching up paperwork signed on
// paper on an earlier date by someone else. Every other user is
// unaffected regardless of which stamp.
let signerUsername = req.user.username;
let signerName = req.user.name || req.user.username;
let atIso = new Date().toISOString();
if (canOverrideStamp) {
const actAs = String(req.body.actAsUsername || '').trim();
if (actAs) {
const actingUser = await require('../services/hanaUsers').findByUsername(actAs);
if (!actingUser) return res.status(400).json({ success: false, message: `User "${actAs}" not found` });
signerUsername = actingUser.username;
signerName = actingUser.fullName || actingUser.username;
}
if (req.body.signedAt) {
const d = new Date(req.body.signedAt);
if (isNaN(d.getTime())) return res.status(400).json({ success: false, message: 'Invalid sign date' });
if (d.getTime() > Date.now()) return res.status(400).json({ success: false, message: 'Sign date cannot be in the future' });
atIso = d.toISOString();
}
}
const patch = {
[`${which}By`]: signerUsername,
[`${which}ByName`]: signerName,
[atField]: atIso,
};
// Admin → System Settings → "Raw Material Qty Issued Source" picks which
// of two decoupled events is authoritative for EVERY material table's
// Qty Issued (Raw Material, Packing Material, Components alike):
// 'issue_for_production' (default) calculates it from the real SAP
// posting instead (see routes/sap.js's /issue-production, and the live
// qtyByItem lookup for Packing/Components); only under 'mark_issued'
// does THIS one-click paperwork stamp (the same action that sets Issued
// By/Date, above) set Qty Issued = the row's full Qty Req.
if (which === 'issued' && (section === 'raw' || section === 'pack') && require('../services/appSettingsStore').woRawQtyIssuedSource() === 'mark_issued') {
patch.qtyIssued = parseFloat(section === 'raw' ? row.qtyReq : row.qtyReqUnits) || 0;
}
const updated = await store().patchMaterialRow(req.params.id, section, i, patch);
res.json({ success: true, data: updated });
// Notify whoever holds the NEXT stamp in the Issued→Received→Verified
// chain for this row (no next step after Verified — nothing to notify).
const NEXT_STAMP_STEP = { issued: 'work_order:receive', received: 'work_order:verify' };
const nextStep = NEXT_STAMP_STEP[which];
if (nextStep) {
notify().notify({
stepFullKey: nextStep,
title: `Work Order ${wo.woNo} — item ${which}, awaiting ${which === 'issued' ? 'Received By' : 'Verified By'}`,
lines: [['Work Order', wo.woNo], ['Item', row.itemCode || row.rawMaterial || ''], [which === 'issued' ? 'Issued By' : 'Received By', req.user.name || req.user.username]],
url: `${process.env.APP_BASE_URL || ''}/verify-work-order`,
excludeUsernames: [req.user.username],
});
}
} catch (err) { res.status(400).json({ success: false, message: err.message }); }
});
// ── Issuance info for the printable Work Order ─────────────────────────────
// Pulls the linked Production Order(s): per-component issued quantity (live
// from SAP, summed by item code) and the Issuance / Receipt / Close signers
// (name + username + date, from each PO's workflow log). If no PO exists yet,
// or SAP is unreachable, the corresponding pieces come back empty so the print
// view simply leaves those cells blank.
async function computeIssuance(woId, co) {
const poStore = require('../services/productionOrderStore');
const sapSL = require('../services/sapServiceLayer');
const pos = (await poStore.listProductionOrders({ workOrderId: woId, company: co })) || [];
const active = pos.filter(p => !p.isDeleted);
const qtyByItem = {};
let issued = null, received = null, receivedManual = null, verified = null;
const pickLatest = (cur, e) => (!cur || new Date(e.at) >= new Date(cur.at)) ? e : cur;
for (const po of active) {
for (const e of (Array.isArray(po.workflowLog) ? po.workflowLog : [])) {
if (e.action === 'rejected') continue;
if (e.step === 'Issuance') issued = pickLatest(issued, e);
else if (e.step === 'Receipt from Production') received = pickLatest(received, e);
else if (e.step === 'Verified') verified = pickLatest(verified, e); // portal-only post-Issuance sign-off
}
// Portal-only "Received By" sign-off — ALWAYS overrides the SAP receipt
// step's signer above, wherever it exists (see productionOrderStore.receiveManual()).
if (po.receivedManualAt) {
receivedManual = pickLatest(receivedManual, { by: po.receivedManualBy, byName: po.receivedManualByName, at: po.receivedManualAt });
}
if (po.sapAbsEntry) {
try {
const so = await sapSL.sapRequest('GET', `ProductionOrders(${parseInt(po.sapAbsEntry)})`, null, co);
const lines = (so.ProductionOrderLines || []).filter(l => l.ItemType !== 'pit_Resource');
for (const l of lines) {
const code = (l.ItemNo || l.ItemCode || '').toString().trim();
if (!code) continue;
qtyByItem[code] = (qtyByItem[code] || 0) + (Number(l.IssuedQuantity) || 0);
}
} catch (_e) { /* SAP unreachable → leave issued qty blank */ }
}
}
const sig = (e) => e ? { name: e.byName || e.by || '', user: e.by || '', at: e.at || '' } : null;
return { hasPO: active.length > 0, qtyByItem, issuedBy: sig(issued), receivedBy: sig(receivedManual || received), verifiedBy: sig(verified) };
}
router.get('/:id/issuance-info', verifyToken, async (req, res) => {
try {
const data = await computeIssuance(parseInt(req.params.id), req.query.company || null);
res.json({ success: true, data });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
// ── Server-generated PDF of the Production Work Order (pdfmake) ─────────────
router.get('/:id/pdf', verifyToken, requireWorkflowPerm('work_order', 'view'), async (req, res) => {
const co = req.query.company || null;
try {
const wo = await store().findById(req.params.id);
if (!wo) return res.status(404).json({ success: false, message: 'Not found' });
const settings = require('../services/appSettingsStore').getAll();
const iss = await computeIssuance(parseInt(req.params.id), co);
// Gather signature images: the 5 approval signers + every material row's
// own Issued/Received/Verified signer (per-row sign-offs — see
// public/verify-work-order.html; each row is stamped independently).
const users = new Set();
(Array.isArray(wo.workflowLog) ? wo.workflowLog : []).forEach(l => { if (l.by) users.add(l.by); });
[...(wo.rawMaterials || []), ...(wo.packingMaterials || [])].forEach(r => {
['issuedBy', 'receivedBy', 'verifiedBy'].forEach(k => { if (r[k]) users.add(r[k]); });
});
const hu = require('../services/hanaUsers');
const sigs = {};
for (const u of users) { try { const s = await hu.getSignatureByUsername(u); if (s) sigs[u] = s; } catch (_e) {} }
// U_NewItemCode (OITM) for the header Product Code only — printed as
// "(code)" right after it, when SAP has a value (see public/work-order-print.html).
let newItemCode = '';
try {
const { getPool } = require('../services/sqlPool');
const pool = await getPool(co);
const code = String(wo.productCode || '').replace(/'/g, "''");
const r = await pool.request().query(`SELECT "U_NewItemCode" FROM [dbo]."OITM" WHERE "ItemCode"='${code}'`);
newItemCode = r.recordset?.[0]?.U_NewItemCode || '';
} catch (_e) {}
const qtyIssuedSource = require('../services/appSettingsStore').woRawQtyIssuedSource();
const doc = require('../services/workOrderPdf').generate({ wo, settings, iss, sigs, newItemCode, qtyIssuedSource });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="WO-${(wo.woNo || wo.id)}.pdf"`);
doc.pipe(res);
doc.end();
} catch (err) {
if (!res.headersSent) res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;