// 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: ". 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, };