551 lines
36 KiB
JavaScript
551 lines
36 KiB
JavaScript
// 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,
|
||
};
|