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
+550
View File
@@ -0,0 +1,550 @@
// services/appSettingsStore.js
// Application-wide settings that used to live in .env, now editable from the
// Admin panel and persisted in the app's own database (APP_SQL_*). Values are
// stored as strings (one row per key) and cached in memory; typed getters
// parse them at the point of use. bootstrap() seeds any missing key with its
// built-in default and loads the cache, so synchronous getters are safe once
// startup has run (and fall back to defaults even if called earlier).
'use strict';
const sql = require('mssql');
const TABLE = `[dbo].[ZAPP_SETTINGS]`;
// The full set of admin-editable keys, each with its built-in default (these
// are exactly the values that previously lived in .env). Anything not listed
// here is rejected by setMany() so the settings surface stays closed.
const DEFAULTS = {
bomApprovalLevels: '3',
workOrderSkipQc: 'true',
prodOrderTypes: 'bopotStandard',
// Item Master Data — Create Item's auto-numbering: per-prefix (RM/PK/ICO)
// floor for the NEXT suggested code, so an admin can force the sequence
// past a range that has a mistaken/out-of-order entry in it, without
// waiting for someone to naturally catch up to that number. JSON map,
// e.g. {"RM":1500,"PK":800,"ICO":15800} — the next code is
// max(actual highest existing code + 1, this floor) per prefix; a prefix
// with no entry here (or 0) is unrestricted (natural max+1, as before).
itemCodeSeriesFloor: '{}',
// Production Order creation — block (rather than just warn) when a real
// Item component's Planned Quantity exceeds what's actually on hand in its
// warehouse (OITW.OnHand), across every creation path (manual, from Work
// Order, from-component "+PWO"). Resources and Consumable Orders'
// Service items are never stock-checked (not inventory items). Default
// OFF — matches behavior before this existed (SAP itself allows creating
// a Production Order with insufficient stock; issuance is what actually
// needs it in hand).
poRequireStockAvailability: 'false',
receiptRequireFullQty: 'false',
// When receiptRequireFullQty is on, should a by-product/process-loss line
// (e.g. ICO10791 — a negative-Planned-Qty component, its own BaseLine, NOT
// another warehouse split of the FG) count toward the FG's remaining
// planned quantity? Default true = excluded (the correct behavior — a
// process-loss quantity is tracked separately, in its own unit, and was
// never part of what SAP expects the FG's own receipt to total). Off = old
// behavior, sums every DocumentLine including by-product ones.
receiptExcludeByProductFromTotal: 'true',
closeRequireFullQty: 'true', // Close PO only when Completed Qty = Planned Qty (rejected-receipt orders exempt)
// Close PO only if this app's own local stage tracking confirms it
// actually passed through Issuance → Receipt → Transfer to FG in order.
// The "Out of sequence" block on Close already enforces this whenever a
// local tracking record exists — but if that record is MISSING (e.g. the
// registration call right after SAP creation silently failed, or the
// order was never created through this portal), the sequence can't be
// verified at all today, and Close proceeds anyway. ON (default): refuse
// to close any Production Order with no local tracking record. OFF: keep
// today's looser behavior (needed if you have genuine legacy/external SAP
// orders this portal never tracked, that still need to be closeable).
poCloseRequireTracking: 'true',
// Block Receipt from Production AND Close until every REAL component
// (ItemType != pit_Resource) on the Production Order shows
// IssuedQuantity >= PlannedQuantity in SAP. Today, a Production Order
// moves to the "Receipt" stage after the FIRST issue action, even a
// partial one issuing just one of many components (by design — "you
// don't have to issue every component at once") — this setting adds a
// SEPARATE, stricter gate on top for sites that don't want Receipt/Close
// possible until issuance is fully complete. Default ON.
poRequireFullIssuance: 'true',
// Comma-separated list of allowed Weighing Balance IDs — when non-empty,
// the "Weighing Balance ID" field (Work Order creation's raw material
// rows, and Verify Work Order's per-row inline edit at issuance) becomes a
// dropdown restricted to this list instead of free text. Empty (default)
// = free text, unchanged from before this setting existed.
// Rejection Register's batch lookup: besides this portal's own Work Order
// record and SAP's batch master (OBTN), also search SAP Production
// Orders' own Remarks/Comments field for a match (OWOR.Comments — where
// this portal, and evidently direct SAP entry too, writes the Batch No.
// since Production Orders have no dedicated field for it). Off by
// default — it's a broader/looser match than the other two (matches on
// free text ANYONE could have typed into Remarks for any reason), so an
// admin opts in only if that batch-number convention is actually
// followed at their site.
rejectionLookupProdOrderRemarks: 'false',
// Comma-separated master list of Shift options for the Rejection
// Register's entry wizard (Which batch → Shift radio buttons). Admin-
// editable instead of hardcoded, same pattern as Production Order Types.
rejectionShifts: 'A (06:00–14:00),B (14:00–22:00),C (22:00–06:00)',
// Master list of manufacturing Stages the Rejection Register can count
// against (EBB, Sheet Welding, Moulding, …) — JSON array of
// {v,label,active}. Managed from the Rejection Analytics → Root Cause
// Setup screen itself (routes/rejectionRegister.js's GET/PUT /stages,
// gated by rejection_register:causes_setup — deliberately NOT admin-only,
// since the QA lead who manages causes should manage stages too without
// needing System Settings access) rather than here — this default only
// matters on a brand-new install (empty: no sample/placeholder stages are
// seeded, an admin adds their own real ones). "Deleting" a stage there
// sets active:false rather than removing it from this list — existing
// Rejection Causes/entries still reference its key, so a hard removal
// would leave their stage label unresolvable (see [[rejection-register-soft-delete]]).
rejectionStages: JSON.stringify([]),
woWeighingBalanceIds: '',
// Extra plain (no-conversion) options appended to the "Std. Qty/Unit"
// dropdown on Work Order's Packing Material rows, alongside the built-in
// "No."/"Units" — e.g. "Box, Roll, Set". Behave exactly like "Units": no
// auto-scaling/conversion math, just a distinct label. Comma-separated;
// empty (default) = just the two built-in options.
woCustomQtyUnits: '',
woSolutionBatchMaxEditLtr: '200', // New Work Order: BLOOD BAG Solution Batch Size editable only when < this many Ltr (else locked to the auto value)
woSolutionBatchMaxEditLtrPD: '200', // …same for PD (CAPD) finished goods
woSolutionBatchRoundLtr: '10', // Auto Solution Batch Size rounded to the nearest this many Ltr — BLOOD BAG
woSolutionBatchRoundLtrPD: '50', // …nearest this many Ltr — PD (CAPD)
// New Work Order BOM scan: when a top-level SFG (semi-finished good — an
// item that itself has its own BOM) would otherwise land in the Packing
// Material table alongside real packing items, drop it from there. Only
// takes effect when the table is genuinely "Packing Material" (a Solution/
// raw-material section exists) — when there's no raw material and the
// table is the combined "Component" table instead, SFG items stay put.
// Off by default — no behavior change until an admin opts in.
woHideSfgFromPacking: 'false',
// Raw Material BOM explosion (explodeToLeaves): a Solution sub-assembly's
// recipe is walked down to real leaf ingredients, but WFI (SFG00090 — an
// SFG that has its own SAP BOM, e.g. for its own separate PWO/backflush
// handling) would otherwise get silently recursed THROUGH instead of
// shown as its own Raw Material row, since it isn't a plain leaf item.
// ON = stop at SFG00090 and list it directly as a Raw Material line (its
// own sub-ingredients are not shown). OFF = old behavior, keep exploding
// into it like any other non-leaf BOM node. Scoped to this ONE item code
// only, not a generic "show every SFG" toggle.
woShowWfiInRawMaterial: 'true',
// Packing Material/Components' "Std. Qty/Unit" cell has a manual unit
// picker (mg/gm/Kg/pcs/etc, next to the qty input) alongside the new
// read-only UOM column (SAP's actual stock UOM). ON = hide that picker —
// the qty input alone fills the cell, UOM shown separately is enough.
woHideStdQtyUnitPicker: 'false',
// Whole-feature switch for the admin-managed Item Code/Item Group → Ovg%
// override (services/rmOvgSettingsStore.js): when ON, a raw material row
// whose item code (or its item group) has a configured row gets its
// Ovg. cell pre-filled from that setting, and its "Qty Req./100 ml" is
// recomputed as (BOM value) − (BOM value × Ovg%/100) instead of the plain
// BOM figure — with NO further Ovg% multiplication downstream in Qty Req.
// (the reduction is already baked into the /100ml figure). OFF = today's
// behavior everywhere, completely unchanged.
rmOvgOverrideEnabled: 'false',
// OEE — machine names PER OEE type/tab (e.g. EBB Production has its own
// machine list, separate from PD Production's). JSON object, tab key →
// comma-separated names, e.g. {"ebb":"EBB-01, EBB-02","pd":"PD-Line-1"}.
// A tab with a non-empty list here gets its "Machine Name" field turned
// into a dropdown restricted to that tab's own list; a tab with no entry
// stays free text (see public/oee.html).
oeeMachineNames: '{}',
receiptAutoclaveRejection: 'true',
receiptAutoclaveItemGroups: '101,102,143,144,142',
manpowerTabs: 'bb,packing,autoclave,capd,moulding,sheet,lamination,stent,equipment,needle',
// Work Order print/PDF header — shown on the printable Production Work Order.
woCompanyName: 'MITRA INDUSTRIES PRIVATE LIMITED',
woCompanyAddress: '14/4, Delhi Mathura Road, Faridabad, Haryana',
woFormNo: 'MIPL-QS-029-01K',
woEffectiveDate: '24-03-2025',
woReviewDate: '23-03-2027',
woLogo: '', // base64 data URI of the logo image (optional)
// Production module stage-change email notifications (services/notifyStore.js).
// Global on/off switch — SMTP itself is configured via .env (SMTP_HOST/…),
// not here, since this app has no other admin-editable server credentials.
notifyEmailsEnabled: 'true',
// Company Growth (Board) Dashboard — which SAP Item Groups are allowed to
// appear in the "Sales by Product Group" card and its filter, out of every
// group the SAP company actually has. Comma-separated codes (plain group
// codes, plus the synthetic '103bb'/'103capd' FG EQUIPMENT split codes —
// see routes/board.js). Empty = no restriction (every group shows).
boardProductGroups: '',
// PPC Report — which SAP Item Groups are allowed to appear in its "Item
// Group" filter, out of every group the SAP company actually has (site
// wants just the FG groups — BB, CAPD, Equipment, etc. — not raw material/
// packing groups too). Comma-separated group codes. Empty = no restriction
// (every group shows) — same convention as boardProductGroups above.
ppcItemGroups: '',
// PPC Report — dedicated API key for EXTERNAL (non-portal-login) access to
// GET /api/ppc/invoice-report and /api/ppc/item-groups, via header
// "X-API-Key: <this value>". Empty (default) = external access is off —
// only real portal logins (JWT) can call those routes. A separate
// credential from any user's own login, so it can be rotated/revoked here
// without touching anyone's account. See routes/ppc.js's verifyApiKeyOrToken().
ppcApiKey: '',
// PPC Report — "Order Type" filter options, sent verbatim as SAP's free-
// text U_SalesType UDF (routes/ppc.js). Not a fixed enum in SAP — whoever
// enters Sales Orders can type any value there, so this list was
// previously hardcoded in ppc-report.html and needed a code change every
// time a new one showed up in SAP (e.g. "Institute Ind"). Comma-separated,
// seeded with the values already in use.
ppcOrderTypes: 'Trade,Institute,Institute Ind',
// Requirement Calculator (under Requirements) — which comparison periods
// are enabled. At least one must always stay enabled (enforced in
// setMany() below); disabling one collapses Average Sale/Month to just the
// other period's qty ÷ its own month span, instead of (A+B)/months.
reqCalcPeriodAEnabled: 'true',
reqCalcPeriodBEnabled: 'true',
// Display labels for the two periods (default "Period A"/"Period B") — an
// admin can rename them to something meaningful, e.g. "Last Year" / "This Year".
reqCalcPeriodALabel: 'Period A',
reqCalcPeriodBLabel: 'Period B',
// Batch Issuance Intimation — off by default (blocked): a product's total
// batch qty (Total Units) can never exceed its requirement's pending qty.
// When on, an admin allows exceeding it (e.g. rounding/safety buffer).
biAllowExceedPending: 'false',
// Batch Issuance — SFG (Semi-Finished Good) workflow. Off by default. When
// ON, the "New Intimation" screen gets a second, separate "SFG" tab that
// lets a user search and add items DIRECTLY (no Requirement selection at
// all) — restricted server-side to items whose SAP Item Group is tagged
// 'SFG' in Item Group Rules (services/itemGroupClassStore.js). The
// resulting Batch Intimation has no refNo/requirementId, same as any
// other Intimation otherwise — Work Order generation from it is unchanged
// (already pulls the full BOM regardless of source).
biSfgWorkflowEnabled: 'false',
// Batch Issuance Intimation — Multi-Solution Volumes. Off by default (today's
// behavior, unchanged): a batch's single "Batch Volume (Ltr)" field, when
// present, is broadcast to EVERY Solution group on the Work Order generated
// from it — correct for a one-solution product, WRONG for a multi-solution
// one (e.g. a dual-chamber bag with its own anticoagulant + additive
// solutions), which silently forces every solution to the same batch size.
// When ON: a batch can instead list PER-SOLUTION name+volume pairs
// (public/batch-issuance.html); Work Order generation matches each by name
// against the BOM's own Solution groups and applies only that group's own
// volume — an unmatched group keeps its plain BOM-calculated ratio instead
// of being overridden. A batch with the old single value and NO per-solution
// list still applies it, but ONLY when the product's BOM has exactly one
// Solution group — multi-solution products with no per-solution data fall
// back to pure BOM math for every group (no more silent broadcast).
biMultiSolutionVolumesEnabled: 'false',
// Production Deviations (Shortage / Substitution / Damage, raised after
// Issue for Production) — see services/deviationStore.js. Scrap Warehouse
// is where a Damage/Loss deviation's SAP write-off (Stock Transfer) lands
// when the user chooses to post one. QA Approval, when on, leaves a
// deviation "Pending QA Review" after its SAP posting until someone with
// the deviation step's 'approve' perm signs off — the posting itself is
// never gated by this, only the paper-trail closure.
deviationScrapWarehouse: '',
deviationRequireQaApproval: 'true',
// When on, raising a Shortage/Substitution/Damage deviation does NOT post
// the SAP goods movement immediately — it only adds/updates the affected
// component on the Production Order (Shortage bumps PlannedQuantity,
// Substitution adds the new line). The line stays LOCKED (Issue for
// Production refuses to issue it) until QA approves the deviation — the
// concerned store user then issues it manually via Issue for Production.
// Damage/Loss has no "Issue for Production" equivalent, so it gets its own
// "Post to SAP" button on the deviation once approved.
deviationDeferIssueUntilApproval: 'false',
// Controls how a Substitution's replaced (now-superseded, zeroed-out) old
// item line actually gets deleted from SAP via DI API. ON (default) = show
// the "Remove from SAP" button, someone must press it (manual, reviewed
// per case). OFF = no button shown at all — the app deletes it
// automatically right after the substitution is applied, whenever it's
// safe to (the same live IssuedQuantity==0 check either way — an
// already-issued line is NEVER auto- or manually-removable).
deviationShowRemoveOldLineButton: 'true',
// SAP B1 document numbering Series (the internal Series ID from
// Administration → System Initialization → Document Numbering, NOT the
// series name) that a Damage/Loss deviation's Stock Transfer write-off —
// and its reversal, if QA later rejects it — should be posted under.
// Empty = don't send Series at all, SAP picks its own default series.
deviationDamageSeries: '',
// Comma-separated subset of SHORTAGE/SUBSTITUTION/DAMAGE — which Deviation
// types an admin has chosen to make available at all. Default = all three
// (today's behavior, unchanged unless an admin opts to narrow it). At
// least one must always stay enabled (enforced in setMany()) — a
// Deviation type with nothing enabled would leave "Raise Deviation" with
// no valid choice.
deviationEnabledTypes: 'SHORTAGE,SUBSTITUTION,DAMAGE',
// Whole-feature switch for the Requirement Production↔Store review loop
// (Production shares a Requirement with Store, Store cross-checks it
// against SAP's own MRP Wizard output and edits it, reverts to
// Production) — OFF by default since it may not be needed at every site;
// when OFF, Requirements behave exactly as before this feature existed
// (straight to Batch Intimation, no extra step visible anywhere).
requirementStoreReviewEnabled: 'false',
// Only meaningful when the switch above is ON: whether a Batch Intimation
// can be raised from a Requirement that HASN'T completed the Store review
// round trip yet. ON = hard block (must reach REVIEW_STAGE 2 first). OFF =
// the review loop is available but optional — Production can still skip
// straight to Batch Intimation without waiting on Store.
requirementStoreReviewHardGate: 'false',
// Pre-PWO Store Review (Production Order creation) — OFF by default. When
// ON, "Create Production Order from Work Order" no longer writes to SAP
// immediately: it first creates a portal-only "Pre-PWO" staging record
// (same component/BOM table as today, editable). Production must "Share
// with Store", who can add/remove/substitute component lines outright and
// revert it (single round trip, no further back-and-forth). HARD GATE —
// whenever this setting is ON, a Work-Order-sourced Production Order
// cannot reach SAP until Store has actually completed the review (no
// bypass, enforced server-side in routes/sap.js's production-order
// create route, not just the UI). Once pushed, the Pre-PWO locks
// (read-only) and the real SAP Production Order + existing 6-stage
// lifecycle continues exactly as before, unchanged.
preWoStoreReviewEnabled: 'false',
// JSON map of stepFullKey (or a synthetic module key, e.g.
// 'module:production-batch-issuance') -> comma-separated extra recipient
// emails, ALWAYS notified for that step/module in addition to whoever is
// actually assigned it. Lets an admin cc a supervisor/QA inbox that isn't
// itself an approval-step holder.
notifyExtraEmails: '{}',
// Work Order Raw Material table — per-item SOP display factor for the
// "Qty Req./100 ml" figure ONLY. Some raw materials are labeled by their
// supplied strength (e.g. "SODIUM LACTATE 60% USP") — SAP's BOM quantity
// already reflects the as-purchased, diluted-strength weight needed, so
// the calculated Qty Req./100 ml and final Qty Req. are both correct as-is.
// Some sites' SOP paperwork still wants the /100ml figure shown scaled by
// the strength percentage anyway (purely cosmetic — see conversation with
// chandan.singh@mitraindustries.com, 2026-09-01, item RM00003). JSON map of
// itemCode -> percent (0-100], e.g. {"RM00003":60}. Applied ONLY to the
// displayed Qty Req./100 ml value (create/edit form, View, Print, PDF) —
// never to the stored stdQty used for the Qty Req. calc, never to Qty Req.
// itself, and never to what gets saved/posted. Missing/absent itemCode = 100
// (no adjustment, today's behavior).
woRawPotencyFactors: '{}',
// Work Order — where every material table's "Qty Issued" column value
// comes from (Raw Material, Packing Material, and the combined
// Components table alike). Key name kept as "woRawQtyIssuedSource" for
// backward compatibility with sites that already set it — scope was
// widened to cover Packing Material/Components too without a migration.
// 'issue_for_production' (default) — calculated from the real SAP
// posting. 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 value is prorated: (this issue's
// Quantity ÷ that Solution item's PlannedQuantity) × the row's own
// full Qty Req., persisted cumulatively across multiple issue passes.
// Packing Material/Components rows ARE real PO lines, so their value
// is simply read live off the PO by item code — unaffected by this
// setting either way.
// 'mark_issued' — the manual one-click "Mark Issued" paperwork stamp on
// a row (same action that sets Issued By/Date) sets Qty Issued = that
// row's full Qty Req., regardless of what was actually posted in SAP
// (or whether anything was posted at all) — now for EVERY material
// table, not just Raw Material.
woRawQtyIssuedSource: 'issue_for_production',
// Packing Material/Components rows with NO unit picked in the Std Qty/Unit
// dropdown are treated as a discrete physical count (e.g. "2 caps") and
// Qty Req. rounds UP (Math.ceil) so a real fractional requirement never
// rounds down to "0 needed" — see recalcMaterials() in work-order.html.
// Some items genuinely ARE fractional in SAP even without a picked unit
// (e.g. a roll-based consumable tracked in NOS where SAP's own Planned
// Qty is 0.1 or 0.12, not a whole number) — rounding those up to 1
// overstates the requirement vs SAP. 'true' (default) keeps the existing
// round-up-to-whole-number behavior; 'false' switches to plain 3-decimal
// rounding (matches SAP's own fractional Planned Qty) for every such row.
woPackQtyRoundUp: 'true',
};
let _cache = { ...DEFAULTS };
let _conn = null;
async function getConn() {
if (_conn) return _conn;
_conn = await sql.connect({
server: process.env.APP_SQL_HOST,
port: parseInt(process.env.APP_SQL_PORT),
user: process.env.APP_SQL_USER,
password: process.env.APP_SQL_PASSWORD,
database: process.env.APP_SQL_DATABASE,
options: { encrypt: true, trustServerCertificate: true },
});
return _conn;
}
async function exec(sqlQuery, params = []) {
const conn = await getConn();
const request = conn.request();
params.forEach((param, index) => { request.input(`param${index}`, param); });
const replacedSql = sqlQuery.replace(/\?/g, (m, offset, string) => {
const i = (string.slice(0, offset).match(/\?/g) || []).length;
return `@param${i}`;
});
const result = await request.query(replacedSql);
return result.recordset || [];
}
function isAlreadyExists(e) {
const m = (e.message || '').toLowerCase();
return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object');
}
async function bootstrap() {
console.log('[APP-SETTINGS] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
SETTING_KEY NVARCHAR(100) PRIMARY KEY,
SETTING_VALUE NVARCHAR(MAX),
UPDATED_BY NVARCHAR(50),
UPDATED_AT DATETIME2
)
`).catch(e => { if (isAlreadyExists(e)) { console.log('[APP-SETTINGS] Table exists — OK'); } else throw e; });
// Seed any key that doesn't have a stored value yet (idempotent).
for (const [key, val] of Object.entries(DEFAULTS)) {
await exec(
`IF NOT EXISTS (SELECT 1 FROM ${TABLE} WHERE SETTING_KEY=?)
INSERT INTO ${TABLE} (SETTING_KEY, SETTING_VALUE, UPDATED_AT) VALUES (?,?,SYSUTCDATETIME())`,
[key, key, val]
).catch(e => console.warn('[APP-SETTINGS] seed failed for', key, e.message));
}
await reload();
console.log('[APP-SETTINGS] ✅ Ready');
}
async function reload() {
const rows = await exec(`SELECT SETTING_KEY, SETTING_VALUE FROM ${TABLE}`);
const next = { ...DEFAULTS };
rows.forEach(r => { if (r.SETTING_KEY in DEFAULTS) next[r.SETTING_KEY] = r.SETTING_VALUE; });
_cache = next;
}
function raw(key) { return _cache[key] != null ? _cache[key] : DEFAULTS[key]; }
// All raw string values (defaults merged with stored) — for the admin editor.
function getAll() { return { ...DEFAULTS, ..._cache }; }
// Update one or more keys (only known keys are accepted). Reloads the cache.
async function setMany(obj, updatedBy) {
// At least one Requirement Calculator period must stay enabled — the
// calculator has nothing to compute an average from otherwise.
const nextA = 'reqCalcPeriodAEnabled' in (obj || {}) ? String(obj.reqCalcPeriodAEnabled).toLowerCase() === 'true' : reqCalcPeriodAEnabled();
const nextB = 'reqCalcPeriodBEnabled' in (obj || {}) ? String(obj.reqCalcPeriodBEnabled).toLowerCase() === 'true' : reqCalcPeriodBEnabled();
if (!nextA && !nextB) throw new Error('At least one of Period A / Period B must stay enabled for the Requirement Calculator');
// At least one Deviation type must stay enabled — otherwise "Raise
// Deviation" would have no valid choice left at all.
if ('deviationEnabledTypes' in (obj || {})) {
const nextTypes = String(obj.deviationEnabledTypes || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
if (!nextTypes.length) throw new Error('At least one Deviation type (Shortage / Substitution / Damage-Loss) must stay enabled');
}
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
for (const [key, value] of Object.entries(obj || {})) {
if (!(key in DEFAULTS)) continue; // ignore unknown keys
const val = value == null ? '' : String(value).trim();
await exec(`
MERGE ${TABLE} AS t
USING (SELECT ? AS K, ? AS V) AS s ON t.SETTING_KEY = s.K
WHEN MATCHED THEN UPDATE SET SETTING_VALUE = s.V, UPDATED_BY = ?, UPDATED_AT = ?
WHEN NOT MATCHED THEN INSERT (SETTING_KEY, SETTING_VALUE, UPDATED_BY, UPDATED_AT) VALUES (s.K, s.V, ?, ?);
`, [key, val, updatedBy || null, now, updatedBy || null, now]);
}
await reload();
return getAll();
}
// ── Typed getters (parse at point of use) ───────────────────────
function bomApprovalLevels() { const n = parseInt(raw('bomApprovalLevels')); return (n >= 2 && n <= 4) ? n : 3; }
function workOrderSkipQc() { return String(raw('workOrderSkipQc')).toLowerCase() === 'true'; }
function prodOrderTypes() { return String(raw('prodOrderTypes') || 'bopotStandard').split(',').map(s => s.trim()).filter(Boolean); }
function itemCodeSeriesFloor() {
try {
const obj = JSON.parse(raw('itemCodeSeriesFloor') || '{}');
if (!obj || typeof obj !== 'object') return {};
const out = {};
Object.keys(obj).forEach(k => { const n = parseInt(obj[k]); if (!isNaN(n) && n > 0) out[String(k).toUpperCase()] = n; });
return out;
} catch { return {}; }
}
function poRequireStockAvailability() { return String(raw('poRequireStockAvailability')).toLowerCase() === 'true'; }
function receiptRequireFullQty() { return String(raw('receiptRequireFullQty')).toLowerCase() === 'true'; }
function receiptExcludeByProductFromTotal() { return String(raw('receiptExcludeByProductFromTotal')).toLowerCase() !== 'false'; }
function closeRequireFullQty() { return String(raw('closeRequireFullQty')).toLowerCase() === 'true'; }
function poCloseRequireTracking(){ return String(raw('poCloseRequireTracking')).toLowerCase() === 'true'; }
function poRequireFullIssuance(){ return String(raw('poRequireFullIssuance')).toLowerCase() === 'true'; }
function woWeighingBalanceIds(){ return String(raw('woWeighingBalanceIds') || '').split(',').map(s => s.trim()).filter(Boolean); }
function woCustomQtyUnits(){ return String(raw('woCustomQtyUnits') || '').split(',').map(s => s.trim()).filter(Boolean); }
function woSolutionBatchMaxEditLtr() { return parseFloat(raw('woSolutionBatchMaxEditLtr')) || 200; }
function woSolutionBatchMaxEditLtrPD() { return parseFloat(raw('woSolutionBatchMaxEditLtrPD')) || 200; }
function woSolutionBatchRoundLtr() { return parseFloat(raw('woSolutionBatchRoundLtr')) || 10; }
function woSolutionBatchRoundLtrPD() { return parseFloat(raw('woSolutionBatchRoundLtrPD')) || 50; }
function receiptAutoclaveRejection() { return String(raw('receiptAutoclaveRejection')).toLowerCase() === 'true'; }
function receiptAutoclaveItemGroups() { return String(raw('receiptAutoclaveItemGroups') || '').split(',').map(s => s.trim()).filter(Boolean); }
function manpowerTabs() { return String(raw('manpowerTabs') || '').split(',').map(s => s.trim()).filter(Boolean); }
// Work Order print header (raw string getters).
function woCompanyName() { return String(raw('woCompanyName') || ''); }
function woCompanyAddress() { return String(raw('woCompanyAddress') || ''); }
function woFormNo() { return String(raw('woFormNo') || ''); }
function woEffectiveDate() { return String(raw('woEffectiveDate') || ''); }
function woReviewDate() { return String(raw('woReviewDate') || ''); }
function woLogo() { return String(raw('woLogo') || ''); }
function notifyEmailsEnabled() { return String(raw('notifyEmailsEnabled')).toLowerCase() === 'true'; }
function boardProductGroups() { return String(raw('boardProductGroups') || '').split(',').map(s => s.trim()).filter(Boolean); }
function ppcItemGroups() { return String(raw('ppcItemGroups') || '').split(',').map(s => s.trim()).filter(Boolean); }
function ppcApiKey() { return String(raw('ppcApiKey') || '').trim(); }
function ppcOrderTypes() { return String(raw('ppcOrderTypes') || '').split(',').map(s => s.trim()).filter(Boolean); }
function notifyExtraEmails() { try { return JSON.parse(raw('notifyExtraEmails') || '{}') || {}; } catch { return {}; } }
function reqCalcPeriodAEnabled() { return String(raw('reqCalcPeriodAEnabled')).toLowerCase() === 'true'; }
function reqCalcPeriodBEnabled() { return String(raw('reqCalcPeriodBEnabled')).toLowerCase() === 'true'; }
function reqCalcPeriodALabel() { return String(raw('reqCalcPeriodALabel') || 'Period A').trim() || 'Period A'; }
function reqCalcPeriodBLabel() { return String(raw('reqCalcPeriodBLabel') || 'Period B').trim() || 'Period B'; }
function biAllowExceedPending() { return String(raw('biAllowExceedPending')).toLowerCase() === 'true'; }
function biSfgWorkflowEnabled() { return String(raw('biSfgWorkflowEnabled')).toLowerCase() === 'true'; }
function biMultiSolutionVolumesEnabled() { return String(raw('biMultiSolutionVolumesEnabled')).toLowerCase() === 'true'; }
function woHideSfgFromPacking() { return String(raw('woHideSfgFromPacking')).toLowerCase() === 'true'; }
function woShowWfiInRawMaterial() { return String(raw('woShowWfiInRawMaterial')).toLowerCase() === 'true'; }
function woHideStdQtyUnitPicker() { return String(raw('woHideStdQtyUnitPicker')).toLowerCase() === 'true'; }
function rmOvgOverrideEnabled() { return String(raw('rmOvgOverrideEnabled')).toLowerCase() === 'true'; }
// { tabKey: "comma, separated, names" } — malformed/missing = {}.
function oeeMachineNames() { try { const v = JSON.parse(raw('oeeMachineNames') || '{}'); return (v && typeof v === 'object') ? v : {}; } catch { return {}; } }
function deviationScrapWarehouse() { return String(raw('deviationScrapWarehouse') || '').trim(); }
function deviationRequireQaApproval() { return String(raw('deviationRequireQaApproval')).toLowerCase() === 'true'; }
function deviationDeferIssueUntilApproval() { return String(raw('deviationDeferIssueUntilApproval')).toLowerCase() === 'true'; }
function deviationShowRemoveOldLineButton() { return String(raw('deviationShowRemoveOldLineButton')).toLowerCase() === 'true'; }
// Returns the numeric Series ID, or null if unset/invalid — callers only
// add the field to a SAP payload when this isn't null, so an empty setting
// cleanly leaves SAP's own default series in effect.
function deviationDamageSeries() {
const v = String(raw('deviationDamageSeries') || '').trim();
if (!v) return null;
const n = parseInt(v, 10);
return Number.isNaN(n) ? null : n;
}
function deviationEnabledTypes() {
const v = String(raw('deviationEnabledTypes') || '').trim();
const set = new Set(v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean));
return ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE'].filter(t => set.has(t));
}
function requirementStoreReviewEnabled() { return String(raw('requirementStoreReviewEnabled')).toLowerCase() === 'true'; }
function requirementStoreReviewHardGate() { return String(raw('requirementStoreReviewHardGate')).toLowerCase() === 'true'; }
function preWoStoreReviewEnabled() { return String(raw('preWoStoreReviewEnabled')).toLowerCase() === 'true'; }
function woRawPotencyFactors() { try { return JSON.parse(raw('woRawPotencyFactors') || '{}') || {}; } catch { return {}; } }
function woRawQtyIssuedSource() { const v = String(raw('woRawQtyIssuedSource') || '').trim(); return v === 'mark_issued' ? 'mark_issued' : 'issue_for_production'; }
function woPackQtyRoundUp() { return String(raw('woPackQtyRoundUp')).toLowerCase() !== 'false'; }
function rejectionLookupProdOrderRemarks() { return String(raw('rejectionLookupProdOrderRemarks')).toLowerCase() === 'true'; }
function rejectionShifts() { return String(raw('rejectionShifts') || '').split(',').map(s => s.trim()).filter(Boolean); }
// Always returns EVERY stage (active and soft-deleted alike) — callers that
// need only the pickable ones filter with .filter(s=>s.active!==false)
// themselves (the entry wizard's Stage select, Analytics' filter chips, a
// new cause's Stage select); callers doing label lookups for historical
// data (Batch Log, existing causes) want the full list so a since-deleted
// stage's name still resolves instead of falling back to its raw key.
function rejectionStages() {
try {
const arr = JSON.parse(raw('rejectionStages') || '[]');
if (!Array.isArray(arr)) return [];
return arr.map(s => ({ v: String(s?.v || '').trim(), label: String(s?.label || '').trim(), active: s?.active !== false }))
.filter(s => s.v && s.label);
} catch { return []; }
}
module.exports = {
bootstrap, reload, getAll, setMany, DEFAULTS,
bomApprovalLevels, workOrderSkipQc, prodOrderTypes,
itemCodeSeriesFloor, poRequireStockAvailability, receiptRequireFullQty, receiptExcludeByProductFromTotal, closeRequireFullQty, poCloseRequireTracking, poRequireFullIssuance, woWeighingBalanceIds, woCustomQtyUnits, woSolutionBatchMaxEditLtr, woSolutionBatchMaxEditLtrPD,
woSolutionBatchRoundLtr, woSolutionBatchRoundLtrPD, receiptAutoclaveRejection, receiptAutoclaveItemGroups, manpowerTabs,
woCompanyName, woCompanyAddress, woFormNo, woEffectiveDate, woReviewDate, woLogo,
notifyEmailsEnabled, notifyExtraEmails, boardProductGroups, ppcItemGroups, ppcApiKey, ppcOrderTypes,
reqCalcPeriodAEnabled, reqCalcPeriodBEnabled, reqCalcPeriodALabel, reqCalcPeriodBLabel,
biAllowExceedPending, biSfgWorkflowEnabled, biMultiSolutionVolumesEnabled, woHideSfgFromPacking, woShowWfiInRawMaterial, woHideStdQtyUnitPicker, rmOvgOverrideEnabled, oeeMachineNames,
deviationScrapWarehouse, deviationRequireQaApproval, deviationDeferIssueUntilApproval, deviationShowRemoveOldLineButton, deviationDamageSeries, deviationEnabledTypes,
requirementStoreReviewEnabled, requirementStoreReviewHardGate,
preWoStoreReviewEnabled,
woRawPotencyFactors, woRawQtyIssuedSource, woPackQtyRoundUp,
rejectionLookupProdOrderRemarks, rejectionShifts, rejectionStages,
};