Files
sap-erp/services/appSettingsStore.js
T
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

635 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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',
// Item Master — Item Approval Workflow (routes/itemApproval.js). Default ON
// (current/original behavior): admin/sap_adder/system_admin push a new
// item straight to SAP; everyone else's submission queues as PENDING for
// one of those to review under Approvals → Item Approvals. OFF = no queue
// at all — every submission (any role) pushes directly, same as an
// approver's own submission today.
itemApprovalEnabled: 'true',
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',
// Per-step opt-out — comma-separated stepFullKey/moduleKey values (same
// keys as notifyExtraEmails' targets, e.g. "work_order:prepared_qa" or
// "module:production-batch-issuance") for which stage-change email is
// switched OFF entirely, regardless of who's assigned to that step. Empty
// (default) = every step still emails, same as before this setting
// existed. Distinct from the global notifyEmailsEnabled switch above (that
// silences everything at once) and from a user's own emailNotify opt-out
// (that silences everything for just them) — this is the middle ground:
// one specific stage stays silent for EVERYONE, on purpose.
notifyDisabledSteps: '',
// Short in Stock — Auto Email Alerts (services/shortStockAlertStore.js).
// Fully independent of the notification settings above and of the
// Inventory Status Report screen — a background job (started once from
// server.js) periodically sums each watched item's on-hand quantity and
// emails the concerned users the first time it hits zero. Off by default
// until an admin actually defines a watch-list.
shortStockAlertEnabled: 'false',
// JSON array of {id, type:'ITEM'|'GROUP', value, label, recipients:[username,...]}.
shortStockAlertRules: '[]',
// 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: '',
// SAP B1 document numbering Series ID (not the series name) that every
// Inventory Transfer this portal posts should use — e.g. series "IC2627"
// is internal ID 28615. Empty = don't send Series at all, SAP picks its
// own default series.
inventoryTransferSeries: '28615',
// 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,ADDITION',
// 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: '{}',
// JSON map of SAP Item Group code (string, e.g. "116") -> comma-separated
// extra recipient emails, notified in ADDITION to whoever's already getting
// a stage-change email for that Work Order/Production Order (approval-step
// holders + notifyExtraEmails above) — routed off the ORDER'S MAIN PRODUCT
// item group only, not every component's group, so one order never blasts
// every group-owner touched by its BOM. Lets an admin route notifications
// by product category (e.g. all "Solutions" emails go to one inbox) without
// that person needing to hold an approval step at all. See notifyStore.js's
// groupExtraEmailsFor().
notifyItemGroupEmails: '{}',
// Downtime Analysis report config (admin-editable, Admin → System
// Settings → "Downtime Analysis"). `causes` is the canonical downtime
// taxonomy shown on the report (admin can add more any time); `tabFieldMap`
// translates each OEE tab's OWN raw field keys (public/oee.html's
// OEE_SCHEMA — different tabs use different field names for conceptually
// the same cause, e.g. Sheet's "Head Clean" vs EBB's "Micro Stops") into
// one of those canonical causes, so machines logged under different tabs
// still roll up onto the same report. A tab/field left out of the map
// (or mapped to "") simply doesn't contribute to any cause total — set
// once with a reasonable starting guess, meant to be corrected/extended
// via the admin UI rather than being authoritative on day one. `baseDays`
// is the common "working days" calendar count for `periodFrom`..`periodTo`
// (e.g. 330) — a machine whose own first OEE entry falls after periodFrom
// gets this prorated down automatically (see services/downtimeAnalysisStore.js).
downtimeAnalysisConfig: JSON.stringify({
periodFrom: '', periodTo: '', baseDays: 330,
causes: [
'Water/tea Break', 'Meal break', 'Breakdown', 'Cleaning', 'Changeover',
'Slow speed/High Cycle Time', 'Micro Stops < 10 min (Utility failure etc)',
'Empty run (Shortage of material etc)', 'Start up/shut down',
],
tabFieldMap: {
ebb: { waterTea:'Water/tea Break', meal:'Meal break', breakdown:'Breakdown', cleaning:'Cleaning', changeOver:'Changeover', slowSpeed:'Slow speed/High Cycle Time', microStops:'Micro Stops < 10 min (Utility failure etc)', emptyRun:'Empty run (Shortage of material etc)', startShut:'Start up/shut down' },
pd: { waterTea:'Water/tea Break', meal:'Meal break', breakdown:'Breakdown', cleaning:'Cleaning', changeOver:'Changeover', slowSpeed:'Slow speed/High Cycle Time', microStops:'Micro Stops < 10 min (Utility failure etc)', emptyRun:'Empty run (Shortage of material etc)', startShut:'Start up/shut down' },
sheet: { waterTea:'Water/tea Break', meal:'Meal break', breakdown:'Breakdown', cleaning:'Cleaning', changeOver:'Changeover', slowSpeed:'Slow speed/High Cycle Time', headClean:'Micro Stops < 10 min (Utility failure etc)', otherProblem:'Empty run (Shortage of material etc)', mcStartShut:'Start up/shut down' },
moulding: { waterTea:'Water/tea Break', meal:'Meal break', breakdown:'Breakdown', cleaning:'Cleaning', changeOver:'Changeover', powerCutSlow:'Slow speed/High Cycle Time', headClean:'Micro Stops < 10 min (Utility failure etc)', nozzleClean:'Empty run (Shortage of material etc)', mcStartShut:'Start up/shut down' },
autoclave: {},
},
}),
// 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 / Addition) 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 itemApprovalEnabled() { return String(raw('itemApprovalEnabled')).toLowerCase() !== 'false'; }
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 notifyDisabledSteps() { return String(raw('notifyDisabledSteps') || '').split(',').map(s => s.trim()).filter(Boolean); }
function isNotifyStepEnabled(key) { return key ? !notifyDisabledSteps().includes(key) : true; }
function shortStockAlertEnabled() { return String(raw('shortStockAlertEnabled')).toLowerCase() === 'true'; }
function shortStockAlertRules() { try { const v = JSON.parse(raw('shortStockAlertRules') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }
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 notifyItemGroupEmails() { try { return JSON.parse(raw('notifyItemGroupEmails') || '{}') || {}; } catch { return {}; } }
function downtimeAnalysisConfig() { try { return JSON.parse(raw('downtimeAnalysisConfig') || '{}') || {}; } 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;
}
// Same null-if-unset convention as deviationDamageSeries() above.
function inventoryTransferSeries() {
const v = String(raw('inventoryTransferSeries') || '').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', 'ADDITION'].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, itemApprovalEnabled, receiptRequireFullQty, receiptExcludeByProductFromTotal, closeRequireFullQty, poCloseRequireTracking, poRequireFullIssuance, woWeighingBalanceIds, woCustomQtyUnits, woSolutionBatchMaxEditLtr, woSolutionBatchMaxEditLtrPD,
woSolutionBatchRoundLtr, woSolutionBatchRoundLtrPD, receiptAutoclaveRejection, receiptAutoclaveItemGroups, manpowerTabs,
woCompanyName, woCompanyAddress, woFormNo, woEffectiveDate, woReviewDate, woLogo,
notifyEmailsEnabled, notifyExtraEmails, notifyItemGroupEmails, downtimeAnalysisConfig, notifyDisabledSteps, isNotifyStepEnabled, shortStockAlertEnabled, shortStockAlertRules, boardProductGroups, ppcItemGroups, ppcApiKey, ppcOrderTypes,
reqCalcPeriodAEnabled, reqCalcPeriodBEnabled, reqCalcPeriodALabel, reqCalcPeriodBLabel,
biAllowExceedPending, biSfgWorkflowEnabled, biMultiSolutionVolumesEnabled, woHideSfgFromPacking, woShowWfiInRawMaterial, woHideStdQtyUnitPicker, rmOvgOverrideEnabled, oeeMachineNames,
deviationScrapWarehouse, deviationRequireQaApproval, deviationDeferIssueUntilApproval, deviationShowRemoveOldLineButton, deviationDamageSeries, deviationEnabledTypes, inventoryTransferSeries,
requirementStoreReviewEnabled, requirementStoreReviewHardGate,
preWoStoreReviewEnabled,
woRawPotencyFactors, woRawQtyIssuedSource, woPackQtyRoundUp,
rejectionLookupProdOrderRemarks, rejectionShifts, rejectionStages,
};