sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+e725a6571b8f7cf3ba46124951b9afef77456a80")]
[assembly: System.Reflection.AssemblyProductAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyTitleAttribute("SapDiConsole")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
bd88c037973a1a968556ea51ee8cddc9d960f7dcdfb631602883b428ec252d76
f6f2e13ca6eb9ef36ce10a5a95808ccff303567b320bcdf3a957f01bec5441b8
+90 -6
View File
@@ -34,6 +34,13 @@ const DEFAULTS = {
// 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
@@ -160,6 +167,25 @@ const DEFAULTS = {
// 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
@@ -256,13 +282,18 @@ const DEFAULTS = {
// 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',
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
@@ -295,6 +326,45 @@ const DEFAULTS = {
// 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
@@ -420,7 +490,7 @@ async function setMany(obj, updatedBy) {
// 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');
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);
@@ -452,6 +522,7 @@ function itemCodeSeriesFloor() {
} 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'; }
@@ -474,11 +545,17 @@ 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'; }
@@ -505,10 +582,17 @@ function deviationDamageSeries() {
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'].filter(t => set.has(t));
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'; }
@@ -536,13 +620,13 @@ function rejectionStages() {
module.exports = {
bootstrap, reload, getAll, setMany, DEFAULTS,
bomApprovalLevels, workOrderSkipQc, prodOrderTypes,
itemCodeSeriesFloor, poRequireStockAvailability, receiptRequireFullQty, receiptExcludeByProductFromTotal, closeRequireFullQty, poCloseRequireTracking, poRequireFullIssuance, woWeighingBalanceIds, woCustomQtyUnits, woSolutionBatchMaxEditLtr, woSolutionBatchMaxEditLtrPD,
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, boardProductGroups, ppcItemGroups, ppcApiKey, ppcOrderTypes,
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,
deviationScrapWarehouse, deviationRequireQaApproval, deviationDeferIssueUntilApproval, deviationShowRemoveOldLineButton, deviationDamageSeries, deviationEnabledTypes, inventoryTransferSeries,
requirementStoreReviewEnabled, requirementStoreReviewHardGate,
preWoStoreReviewEnabled,
woRawPotencyFactors, woRawQtyIssuedSource, woPackQtyRoundUp,
+22
View File
@@ -38,6 +38,14 @@ const BUILTIN_STEPS = [
// Issued/Received/Verified stamps since different items can be issued,
// received and verified on different days by different people.
{ workflow: 'work_order', key: 'issue', label: 'Issued By (per-row post-Issuance sign-off)', order: 8 },
// Send back to QA — previously Admin/System Admin role ONLY, no
// assignable step at all: recalls a FULLY APPROVED Work Order back to the
// first QA step for editing (re-uses the normal Reject status, so once
// edited it restarts the full 5-step chain from Prepared By QA — see
// routes/workOrders.js's POST /:id/send-to-qa). Now also grantable to a
// regular user via 'approve' on this key; admin/system_admin still always
// bypass regardless.
{ workflow: 'work_order', key: 'send_to_qa', label: 'Send back to QA (recall a fully approved WO for editing)', order: 9 },
// BOM — up to 4 levels (BOM_APPROVAL_LEVELS controls how many are actually used)
{ workflow: 'bom', key: 'level1', label: 'Level 1 (Manager)', order: 1 },
{ workflow: 'bom', key: 'level2', label: 'Level 2 (Sr. Manager)', order: 2 },
@@ -123,6 +131,12 @@ const BUILTIN_STEPS = [
{ workflow: 'production_order', key: 'consumable_release', label: 'Consumable Order — Release', order: 15 },
{ workflow: 'production_order', key: 'consumable_issue', label: 'Consumable Order — Issue', order: 16 },
{ workflow: 'production_order', key: 'consumable_close', label: 'Consumable Order — Close', order: 17 },
// Cancel — previously Admin/System Admin role ONLY, no assignable step at
// all (an explicit, deliberate restriction — see routes/sap.js's
// isPoAdminOverride()). Now also grantable to a regular user like any
// other step, via 'approve' on this key; admin/system_admin still always
// bypass regardless, same as every other production_order step.
{ workflow: 'production_order', key: 'cancel', label: 'Cancel (bypasses all normal Close checks — irreversible)', order: 18 },
// Man Power — single entry step; its view/add/edit perms gate the whole
// Man Power module CRUD (routes/manpower.js). 'edit' also gates Cancel.
{ workflow: 'man_power', key: 'entry', label: 'Man Power Data Entry', order: 1 },
@@ -166,6 +180,10 @@ const BUILTIN_STEPS = [
// plan, stamping Approved By/Date on it (used in the Excel export).
{ workflow: 'production_planning', key: 'entry', label: 'Production Planning Data Entry', order: 1 },
{ workflow: 'production_planning', key: 'approve', label: 'Production Planning Approval', order: 2 },
// Sales Order module (replacement for the msale portal) — order workflow,
// sample requests and masters. Defined in services/sales/constants.js
// (also the source of the per-"Sales User Type" default templates).
...require('./sales/constants').STEPS,
];
const WORKFLOW_LABELS = {
@@ -179,6 +197,10 @@ const WORKFLOW_LABELS = {
requirement: 'Requirements',
rejection_register: 'Rejection Register',
production_planning: 'Production Planning',
sales_order: 'Sales Order',
sales_sample: 'Sample Request (Domestic)',
sales_export_sample: 'Sample Request (Export)',
sales_master: 'Sales Masters',
};
async function exec(sqlQuery, params = []) {
+8 -1
View File
@@ -12,7 +12,14 @@
const sql = require('mssql');
const TABLE = `[dbo].[ZPRODUCTION_DEVIATIONS]`;
const TYPES = ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE'];
// ADDITION: a genuinely new item/component that was never on the Production
// Order at all — distinct from Substitution (which replaces an EXISTING
// component) and Shortage (which tops up an EXISTING component's qty).
// Reuses ITEM_CODE/ITEM_NAME for the new item (no "old" item involved, so no
// OLD_LINE_NUM either), and shares Substitution's own "add a real component
// line" SAP posting/apply/reversal logic in routes/sap.js — just always with
// oldLineNum=null, since applySubstitutionLine() already handles that case.
const TYPES = ['SHORTAGE', 'SUBSTITUTION', 'DAMAGE', 'ADDITION'];
let _conn = null;
async function getConn() {
+290
View File
@@ -0,0 +1,290 @@
'use strict';
// services/downtimeAnalysisStore.js — "Downtime Analysis" report: aggregates
// raw OEE shift entries (services/oeeStore.js) across EVERY tab, into one
// standardized downtime taxonomy (Admin → System Settings → "Downtime
// Analysis" — see appSettingsStore.downtimeAnalysisConfig()). Different OEE
// tabs use different field names for conceptually the same cause (e.g.
// Sheet's "Head Clean" vs EBB's "Micro Stops") — tabFieldMap is what
// reconciles that, so a machine logged under one tab still rolls up next to
// a machine logged under another.
//
// Three views over the same underlying data:
// - buildReport() — grouped by the shift's own Machine Name field.
// - buildTabReport() — grouped by OEE FORM (tab) instead — "how much
// downtime did EBB Production log overall", not
// "how much did this one machine log".
// - buildMonthlyReport() — grouped by month, every machine/tab combined into
// one Down Time vs M/C Run Time total.
//
// "Base Days" is a company working-day calendar count for the whole report
// period (e.g. 330 days for a ~13-month period) — admin-entered, not derived
// from OEE data, since it reflects the plant's real working calendar
// (weekly-offs/holidays), not anything OEE rows can tell us. A group (machine
// or tab) that only started being logged partway through the period (its own
// first OEE entry falls after periodFrom) gets this prorated down
// automatically — same daily "working-day density" applied to a shorter
// span, not the full period's Base Days.
const appSettings = require('./appSettingsStore');
const oeeStore = require('./oeeStore');
function daysBetween(fromStr, toStr) {
const a = new Date(fromStr + 'T00:00:00Z');
const b = new Date(toStr + 'T00:00:00Z');
return Math.round((b - a) / 86400000);
}
function resolvePeriod({ periodFrom, periodTo, baseDays }, cfg) {
const from = (periodFrom || cfg.periodFrom || '').slice(0, 10);
const to = (periodTo || cfg.periodTo || '').slice(0, 10);
if (!from || !to) throw new Error('periodFrom and periodTo are required (set a default in Admin → System Settings → Downtime Analysis, or pass them explicitly).');
const baseDaysCommon = Number(baseDays != null ? baseDays : cfg.baseDays) || 0;
return { from, to, baseDaysCommon };
}
// Shared aggregation core — buckets every in-range shift row by whatever
// `keyOf(row, doc)` returns (a machine name, or a tab label), sums its
// mapped cause minutes + shift minutes into that bucket, then turns each
// bucket into the same derived-metrics shape (Base Days, % by cause, Net/
// Total/Remainder %) buildReport()/buildTabReport() both return.
async function aggregate({ company, from, to, baseDaysCommon, causes, tabFieldMap, keyOf }) {
const monthFrom = from.slice(0, 7);
const monthTo = to.slice(0, 7);
const docs = await oeeStore.listAllInRange({ company, monthFrom, monthTo });
const totalPeriodDays = Math.max(1, daysBetween(from, to) + 1);
const buckets = {}; // key -> { causeTotals, totalShiftMin, activeDates:Set, firstDate }
docs.forEach(doc => {
const map = tabFieldMap[doc.tab] || {};
(doc.rows || []).forEach(row => {
const d = String(row.date || '').slice(0, 10);
if (!d || d < from || d > to) return;
const key = keyOf(row, doc);
if (!key) return;
if (!buckets[key]) buckets[key] = { causeTotals: {}, totalShiftMin: 0, activeDates: new Set(), firstDate: null };
const b = buckets[key];
const shiftHrs = parseFloat(row.shiftLenHrs) || 0;
b.totalShiftMin += shiftHrs * 60;
b.activeDates.add(d);
if (!b.firstDate || d < b.firstDate) b.firstDate = d;
Object.keys(map).forEach(field => {
const cause = map[field];
if (!cause) return;
const v = parseFloat(row[field]) || 0;
b.causeTotals[cause] = (b.causeTotals[cause] || 0) + v;
});
});
});
const keys = Object.keys(buckets).sort();
const perGroup = keys.map(key => {
const b = buckets[key];
let bDays = baseDaysCommon;
if (b.firstDate && b.firstDate > from) {
const daysSinceStart = Math.max(1, daysBetween(b.firstDate, to) + 1);
bDays = Math.round((daysSinceStart / totalPeriodDays) * baseDaysCommon);
}
const baseMin = bDays * 1440;
const totalShiftMin = Math.round(b.totalShiftMin);
const plantInoperationMin = Math.max(0, baseMin - totalShiftMin);
const causeTotals = {};
causes.forEach(c => { causeTotals[c] = Math.round(b.causeTotals[c] || 0); });
const causePcts = {};
causes.forEach(c => { causePcts[c] = baseMin > 0 ? (causeTotals[c] / baseMin * 100) : 0; });
const netDowntimeMin = causes.reduce((s, c) => s + causeTotals[c], 0);
const netDowntimePct = baseMin > 0 ? (netDowntimeMin / baseMin * 100) : 0;
const plantInoperationPct = baseMin > 0 ? (plantInoperationMin / baseMin * 100) : 0;
const totalPct = netDowntimePct + plantInoperationPct;
return {
group: key, baseDays: bDays, baseMin,
actualActiveDays: b.activeDates.size, totalShiftMin,
plantInoperationMin, plantInoperationPct,
causeTotals, causePcts, netDowntimeMin, netDowntimePct,
totalPct, remainderPct: 100 - totalPct,
firstActiveDate: b.firstDate,
};
});
const net = {
group: 'NET',
baseDays: perGroup.reduce((s, g) => s + g.baseDays, 0),
baseMin: perGroup.reduce((s, g) => s + g.baseMin, 0),
actualActiveDays: perGroup.reduce((s, g) => s + g.actualActiveDays, 0),
totalShiftMin: perGroup.reduce((s, g) => s + g.totalShiftMin, 0),
plantInoperationMin: perGroup.reduce((s, g) => s + g.plantInoperationMin, 0),
};
const netCauseTotals = {};
causes.forEach(c => { netCauseTotals[c] = perGroup.reduce((s, g) => s + (g.causeTotals[c] || 0), 0); });
net.causeTotals = netCauseTotals;
const netCausePcts = {};
causes.forEach(c => { netCausePcts[c] = net.baseMin > 0 ? (netCauseTotals[c] / net.baseMin * 100) : 0; });
net.causePcts = netCausePcts;
net.netDowntimeMin = causes.reduce((s, c) => s + netCauseTotals[c], 0);
net.netDowntimePct = net.baseMin > 0 ? (net.netDowntimeMin / net.baseMin * 100) : 0;
net.plantInoperationPct = net.baseMin > 0 ? (net.plantInoperationMin / net.baseMin * 100) : 0;
net.totalPct = net.netDowntimePct + net.plantInoperationPct;
net.remainderPct = 100 - net.totalPct;
return { perGroup, net };
}
async function buildReport({ company, periodFrom, periodTo, baseDays } = {}) {
const cfg = appSettings.downtimeAnalysisConfig();
const { from, to, baseDaysCommon } = resolvePeriod({ periodFrom, periodTo, baseDays }, cfg);
const causes = Array.isArray(cfg.causes) ? cfg.causes.filter(Boolean) : [];
const tabFieldMap = cfg.tabFieldMap || {};
const { perGroup, net } = await aggregate({
company, from, to, baseDaysCommon, causes, tabFieldMap,
keyOf: row => String(row.machineName || '').trim() || null,
});
const machines = perGroup.map(g => ({ ...g, machine: g.group }));
return { periodFrom: from, periodTo: to, baseDaysCommon, causes, machines, net: { ...net, machine: 'NET' } };
}
const TAB_LABELS = { ebb: 'EBB Production', pd: 'PD Production', sheet: 'Plastic Sheet Plant', moulding: 'Moulding', autoclave: 'Autoclave' };
// Same shape as buildReport(), grouped by OEE FORM (tab) instead of Machine
// Name — "how much downtime did EBB Production log overall" rather than
// "how much did this one machine log". A tab with no rows in range is simply
// absent (not shown as a zero row).
async function buildTabReport({ company, periodFrom, periodTo, baseDays } = {}) {
const cfg = appSettings.downtimeAnalysisConfig();
const { from, to, baseDaysCommon } = resolvePeriod({ periodFrom, periodTo, baseDays }, cfg);
const causes = Array.isArray(cfg.causes) ? cfg.causes.filter(Boolean) : [];
const tabFieldMap = cfg.tabFieldMap || {};
const { perGroup, net } = await aggregate({
company, from, to, baseDaysCommon, causes, tabFieldMap,
keyOf: (row, doc) => doc.tab || null,
});
const tabs = perGroup.map(g => ({ ...g, tab: g.group, tabLabel: TAB_LABELS[g.group] || g.group }));
return { periodFrom: from, periodTo: to, baseDaysCommon, causes, tabs, net: { ...net, tab: 'NET', tabLabel: 'NET' } };
}
// Shift-level detail, one sheet's worth per OEE tab — every individual shift
// row (date/shift/shift length/cause minutes/computed Total Down Time & %),
// NOT aggregated by machine or summed into one number, matching the OEE
// module's own per-shift sheet layout. A tab absent from the result had no
// rows in range at all.
// - Total Down Time (per row) = sum of every cause column mapped for that
// tab (both "Availability" and "Performance" causes together).
// - % Of Down Time (per row) = Total Down Time ÷ that row's own Shift
// Length (Equ. Min) × 100.
// - The TOTAL row's % column is the SUM of every row's own % (not a
// recomputed ratio from the totals) — matches the source Master Sheet's
// own convention.
async function buildDetailByTab({ company, periodFrom, periodTo } = {}) {
const cfg = appSettings.downtimeAnalysisConfig();
const from = (periodFrom || cfg.periodFrom || '').slice(0, 10);
const to = (periodTo || cfg.periodTo || '').slice(0, 10);
if (!from || !to) throw new Error('periodFrom and periodTo are required.');
const causes = Array.isArray(cfg.causes) ? cfg.causes.filter(Boolean) : [];
const tabFieldMap = cfg.tabFieldMap || {};
const monthFrom = from.slice(0, 7);
const monthTo = to.slice(0, 7);
const docs = await oeeStore.listAllInRange({ company, monthFrom, monthTo });
const byTab = {}; // tab -> { rows:[], causesUsed:Set }
docs.forEach(doc => {
const map = tabFieldMap[doc.tab] || {};
const mappedFields = Object.keys(map).filter(f => map[f]);
if (!mappedFields.length) return; // tab has no cause mapping at all (e.g. Autoclave by default) — nothing meaningful to show
(doc.rows || []).forEach(row => {
const d = String(row.date || '').slice(0, 10);
if (!d || d < from || d > to) return;
if (!byTab[doc.tab]) byTab[doc.tab] = [];
const equMin = (parseFloat(row.shiftLenHrs) || 0) * 60;
const causeMins = {};
let totalDownMin = 0;
mappedFields.forEach(field => {
const v = parseFloat(row[field]) || 0;
const cause = map[field];
causeMins[cause] = (causeMins[cause] || 0) + v;
totalDownMin += v;
});
byTab[doc.tab].push({
date: d, shift: row.shift || '', shiftLenHrs: parseFloat(row.shiftLenHrs) || 0,
equMin: Math.round(equMin), goldStd: parseFloat(row.goldStd) || 0,
causeMins, totalDownMin: Math.round(totalDownMin),
pctDown: equMin > 0 ? (totalDownMin / equMin * 100) : 0,
});
});
});
const tabs = {};
Object.keys(byTab).forEach(tab => {
const rows = byTab[tab].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
const totals = {
equMin: rows.reduce((s, r) => s + r.equMin, 0),
totalDownMin: rows.reduce((s, r) => s + r.totalDownMin, 0),
pctDownSum: rows.reduce((s, r) => s + r.pctDown, 0),
};
tabs[tab] = { tabLabel: TAB_LABELS[tab] || tab, rows, totals };
});
return { periodFrom: from, periodTo: to, causes, tabs };
}
function monthLabel(ym) {
const [y, m] = ym.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
}
// Month-wise summary — ALL machines/tabs combined into one Down Time total
// and one M/C Run Time total per month (not split by machine or cause; see
// buildReport() above for the per-machine/per-cause breakdown). % of Down
// Time here is Down Time ÷ M/C Run Time (actual logged shift time), NOT ÷
// Base Days×1440 like the per-machine report — this is "of the time the
// machines actually ran, how much was downtime", a different question than
// "of the full working calendar, how much was downtime".
async function buildMonthlyReport({ company, periodFrom, periodTo } = {}) {
const cfg = appSettings.downtimeAnalysisConfig();
const from = (periodFrom || cfg.periodFrom || '').slice(0, 10);
const to = (periodTo || cfg.periodTo || '').slice(0, 10);
if (!from || !to) throw new Error('periodFrom and periodTo are required.');
const tabFieldMap = cfg.tabFieldMap || {};
const monthFrom = from.slice(0, 7);
const monthTo = to.slice(0, 7);
const docs = await oeeStore.listAllInRange({ company, monthFrom, monthTo });
const months = {}; // 'YYYY-MM' -> { downMin, runMin }
docs.forEach(doc => {
const map = tabFieldMap[doc.tab] || {};
(doc.rows || []).forEach(row => {
const d = String(row.date || '').slice(0, 10);
if (!d || d < from || d > to) return;
const ym = d.slice(0, 7);
if (!months[ym]) months[ym] = { downMin: 0, runMin: 0 };
months[ym].runMin += (parseFloat(row.shiftLenHrs) || 0) * 60;
Object.keys(map).forEach(field => {
if (!map[field]) return;
months[ym].downMin += parseFloat(row[field]) || 0;
});
});
});
const rows = Object.keys(months).sort().map((ym, i) => {
const d = months[ym];
const downMin = Math.round(d.downMin);
const runMin = Math.round(d.runMin);
const netRunMin = Math.max(0, runMin - downMin);
return {
no: i + 1, month: ym, monthLabel: monthLabel(ym),
downMin, runMin, netRunMin,
pctDown: runMin > 0 ? (downMin / runMin * 100) : 0,
};
});
const total = {
downMin: rows.reduce((s, r) => s + r.downMin, 0),
runMin: rows.reduce((s, r) => s + r.runMin, 0),
};
total.netRunMin = Math.max(0, total.runMin - total.downMin);
total.pctDown = total.runMin > 0 ? (total.downMin / total.runMin * 100) : 0;
return { periodFrom: from, periodTo: to, rows, total };
}
module.exports = { buildReport, buildTabReport, buildDetailByTab, buildMonthlyReport };
+20
View File
@@ -384,6 +384,15 @@ async function findByUsername(username) {
return { ...fromRow(r), passwordHash: r.PASSWORD };
}
// ── Find by email (for central-auth SSO — the only identity it hands us) ──────
async function findByEmail(email) {
const rows = await exec(
`SELECT * FROM ${TABLE} WHERE LOWER(EMAIL) = ? AND ACTIVE = 1`,
[(email || '').trim().toLowerCase()]
);
return rows.length ? fromRow(rows[0]) : null;
}
// ── List all users ────────────────────────────────────────────────────────────
async function listUsers() {
const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY ROLE, USERNAME`);
@@ -450,8 +459,17 @@ async function deleteUser(id) {
// ── Verify password ───────────────────────────────────────────────────────────
async function verifyPassword(plaintext, hash) {
// "md5:<hex>" = an account migrated from the old msale portal (unsalted
// MD5, see scripts/migrate-msale.js). Accepted once; the login route
// re-hashes it to bcrypt immediately on success (upgradeLegacyPassword).
if (typeof hash === 'string' && hash.startsWith('md5:')) {
return require('crypto').createHash('md5').update(String(plaintext)).digest('hex') === hash.slice(4).toLowerCase();
}
return bcrypt.compare(plaintext, hash);
}
async function upgradeLegacyPassword(id, plaintext, hash) {
if (typeof hash === 'string' && hash.startsWith('md5:')) await updateUser(id, { password: plaintext });
}
// ── Per-user SAP credentials ──────────────────────────────────────────────────
// Set the current/given user's own SAP Service-Layer login. Password is
@@ -534,12 +552,14 @@ module.exports = {
bootstrap,
createUser,
findByUsername,
findByEmail,
listUsers,
findById,
updateUser,
deleteUser,
touchLastLogin,
verifyPassword,
upgradeLegacyPassword,
setSapCredentials,
getSapCredentials,
getSapLoginMapRaw,
+29 -3
View File
@@ -51,6 +51,18 @@ function extraEmailsFor(key) {
return String(map[key] || '').split(',').map(s => s.trim()).filter(Boolean);
}
// Item-Group-routed recipients (Admin → System Settings → "Notify by Item
// Group") — ALWAYS notified in addition to the step/module recipients above,
// keyed by the order's MAIN PRODUCT's SAP Item Group (ItmsGrpCod), not every
// component's group. Callers resolve the group code themselves (they already
// have SAP/HANA access; notifyStore.js deliberately doesn't) and pass it in
// as `itemGroupCode`.
function groupExtraEmailsFor(groupCode) {
if (groupCode == null || groupCode === '') return [];
const map = appSettingsStore.notifyItemGroupEmails();
return String(map[String(groupCode)] || '').split(',').map(s => s.trim()).filter(Boolean);
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function buildHtml({ title, lines, url }) {
@@ -67,19 +79,33 @@ function buildHtml({ title, lines, url }) {
// from both are combined and de-duplicated. `excludeUsernames` drops the
// actor who just performed the action (no need to notify yourself) — by
// username rather than email since the JWT payload doesn't carry email.
async function notify({ stepFullKey, moduleKey, title, lines, url, excludeUsernames }) {
async function notify({ stepFullKey, moduleKey, itemGroupCode, title, lines, url, excludeUsernames }) {
try {
if (!appSettingsStore.notifyEmailsEnabled()) return;
const exclU = new Set((excludeUsernames || []).filter(Boolean).map(u => u.toLowerCase()));
const emails = new Set();
if (stepFullKey) {
let anyStageEnabled = false;
// Per-step opt-out (Admin → System Settings → "Stage-Change Email
// Notifications" → per-step list) — when a step/module is listed there,
// NOTHING fires for it: not the assigned users, not the fixed extra
// recipients either. Distinct from the global notifyEmailsEnabled
// switch checked above (silences everything) and a user's own
// emailNotify opt-out (silences everything for just them) — this keeps
// one specific stage silent for everyone, on purpose.
if (stepFullKey && appSettingsStore.isNotifyStepEnabled(stepFullKey)) {
anyStageEnabled = true;
(await usersForStep(stepFullKey)).forEach(u => u.email && !exclU.has((u.username || '').toLowerCase()) && emails.add(u.email.toLowerCase()));
extraEmailsFor(stepFullKey).forEach(e => emails.add(e.toLowerCase()));
}
if (moduleKey) {
if (moduleKey && appSettingsStore.isNotifyStepEnabled(moduleKeyOf(moduleKey))) {
anyStageEnabled = true;
(await usersForModule(moduleKey)).forEach(u => u.email && !exclU.has((u.username || '').toLowerCase()) && emails.add(u.email.toLowerCase()));
extraEmailsFor(moduleKeyOf(moduleKey)).forEach(e => emails.add(e.toLowerCase()));
}
// Item-Group routing rides on the same stage being enabled at all — if
// this step/module's email is switched off entirely, the group-mapped
// recipients shouldn't fire for it either.
if (anyStageEnabled) groupExtraEmailsFor(itemGroupCode).forEach(e => emails.add(e.toLowerCase()));
if (!emails.size) return;
await mailer.sendMail({ to: [...emails], subject: title, html: buildHtml({ title, lines, url }) });
} catch (e) {
+28 -3
View File
@@ -69,11 +69,13 @@ function fromRow(r, withRows) {
// List documents (headers only). Optional filters: company, tab, month, and
// an allow-set of tabs (per-user restriction — omit for no restriction).
async function list({ company, tab, month, allowTabs, top = 30, skip = 0 } = {}) {
async function list({ company, tab, month, monthFrom, monthTo, allowTabs, top = 30, skip = 0 } = {}) {
const where = []; const params = [];
if (company) { where.push(`COMPANY = ?`); params.push(company); }
if (tab) { where.push(`TAB = ?`); params.push(tab); }
if (month) { where.push(`MONTH = ?`); params.push(month); }
if (monthFrom) { where.push(`MONTH >= ?`); params.push(monthFrom); }
if (monthTo) { where.push(`MONTH <= ?`); params.push(monthTo); }
if (Array.isArray(allowTabs) && allowTabs.length) {
where.push(`TAB IN (${allowTabs.map(() => '?').join(',')})`);
allowTabs.forEach(t => params.push(t));
@@ -83,7 +85,16 @@ async function list({ company, tab, month, allowTabs, top = 30, skip = 0 } = {})
`SELECT * FROM ${TABLE} ${w} ORDER BY ID DESC OFFSET ${Math.max(0, skip | 0)} ROWS FETCH NEXT ${Math.min(100, top | 0)} ROWS ONLY`,
params
);
return rows.map(r => fromRow(r, false));
// Genuine total (same filter, no paging) — drives "Page X of Y" on the
// client instead of an open-ended "Page X". Wrapped so a COUNT failure
// never breaks the list itself — the page still loads, just without a total.
let total = null;
try {
const countRows = await exec(`SELECT COUNT(*) AS n FROM ${TABLE} ${w}`, params);
const n = Number(countRows?.[0]?.n);
total = isNaN(n) ? null : n;
} catch (e) { console.warn('[OEE-STORE] COUNT(*) failed — pager will show no total:', e.message); }
return { data: rows.map(r => fromRow(r, false)), total };
}
async function getById(id) {
@@ -91,6 +102,20 @@ async function getById(id) {
return rows.length ? fromRow(rows[0], true) : null;
}
// Every non-cancelled document (WITH its full row data) across a month
// range, for a report that needs to scan actual shift rows rather than just
// list documents — e.g. Downtime Analysis, which pulls every shift entry
// across every tab/machine for a date range. Unbounded (no top/skip): this
// is for server-side aggregation, not a paginated UI list.
async function listAllInRange({ company, monthFrom, monthTo } = {}) {
const where = [`(CANCELED = 0 OR CANCELED IS NULL)`]; const params = [];
if (company) { where.push(`COMPANY = ?`); params.push(company); }
if (monthFrom){ where.push(`MONTH >= ?`); params.push(monthFrom); }
if (monthTo) { where.push(`MONTH <= ?`); params.push(monthTo); }
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ${where.join(' AND ')}`, params);
return rows.map(r => fromRow(r, true));
}
async function create({ company, tab, month, rows, remark, createdBy, createdById }) {
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
const idRows = await exec(
@@ -117,4 +142,4 @@ async function cancel(id) {
await exec(`UPDATE ${TABLE} SET CANCELED = 1, UPDATED_AT = SYSUTCDATETIME() WHERE ID = ?`, [parseInt(id)]);
}
module.exports = { bootstrap, list, getById, create, update, cancel };
module.exports = { bootstrap, list, getById, create, update, cancel, listAllInRange };
+40 -1
View File
@@ -140,6 +140,23 @@ async function bootstrap() {
function toTs(iso) { return iso ? iso.replace('T', ' ').replace('Z', '').substring(0, 23) : null; }
function safeJson(v, f) { if (!v) return f; try { return JSON.parse(v); } catch { return f; } }
// This Production Order's item's SAP Item Group (ItmsGrpCod), for
// notifyStore's Item-Group email routing (Admin → System Settings → "Notify
// by Item Group"). Queried against the SAP company's OWN database via
// services/sqlPool.js's getPool(company) — NOT this file's own getConn(),
// which only ever points at the portal's local APP_SQL_* tracking DB, not
// SAP. Never throws — a lookup failure just means no group-routed
// recipients get added, the normal step/module recipients still fire.
async function itemGroupOf(itemCode, company) {
if (!itemCode) return null;
try {
const { getPool } = require('./sqlPool');
const pool = await getPool(company || null);
const r = await pool.request().query(`SELECT "ItmsGrpCod" FROM [dbo].[OITM] WHERE "ItemCode" = '${String(itemCode).replace(/'/g, "''")}'`);
return r.recordset && r.recordset[0] ? String(r.recordset[0].ItmsGrpCod) : null;
} catch (e) { console.warn('[PO-STORE] itemGroupOf lookup failed:', e.message); return null; }
}
function fromRow(row) {
if (!row) return null;
const stage = row.STAGE || 0;
@@ -211,6 +228,7 @@ async function insertProductionOrder(p) {
const created = await findById(idRows[0].ID);
notify().notify({
stepFullKey: notifyStepFor(created, STEP_KEYS[0]),
itemGroupCode: await itemGroupOf(created.itemCode, created.company),
title: `Production Order ${created.sapDocNum || '#' + created.id} — awaiting ${STEPS[0]}`,
lines: [['Production Order', created.sapDocNum || '#' + created.id], ['Item', created.itemName || created.itemCode], ['Created By', created.createdByName], ['Pending Step', STEPS[0]]],
url: `${process.env.APP_BASE_URL || ''}/production-order?id=${created.id}`,
@@ -338,6 +356,7 @@ async function advanceStage(id, { action, stepKey, by, byName, remarks }) {
const nextStepKey = STEP_KEYS[newStage];
notify().notify({
stepFullKey: notifyStepFor(advanced, nextStepKey),
itemGroupCode: await itemGroupOf(advanced.itemCode, advanced.company),
title: `Production Order ${advanced.sapDocNum || '#' + advanced.id} — awaiting ${STEPS[newStage]}`,
lines: [['Production Order', advanced.sapDocNum || '#' + advanced.id], ['Item', advanced.itemName || advanced.itemCode], ['Completed By', byName || by], ['Pending Step', STEPS[newStage]]],
url,
@@ -424,6 +443,7 @@ async function catchUpStage(id, targetStage, { by, byName, remarks }) {
const nextStepKey = STEP_KEYS[targetStage];
notify().notify({
stepFullKey: notifyStepFor(caughtUp, nextStepKey),
itemGroupCode: await itemGroupOf(caughtUp.itemCode, caughtUp.company),
title: `Production Order ${caughtUp.sapDocNum || '#' + caughtUp.id} — awaiting ${STEPS[targetStage]}`,
lines: [['Production Order', caughtUp.sapDocNum || '#' + caughtUp.id], ['Item', caughtUp.itemName || caughtUp.itemCode], ['Pending Step', STEPS[targetStage]]],
url,
@@ -478,8 +498,27 @@ async function receiveManual(id, { by, byName, remarks }) {
return findById(id);
}
// BATCH_NUMBER/MFG_DATE/EXP_DATE are copied from the Work Order at
// insertProductionOrder() time and never touched again after that — a
// deliberate snapshot everywhere else (see LOCKED_BATCH_FIELDS in
// routes/batchIntimations.js), EXCEPT a rejected Work Order can still be
// edited+resubmitted with a corrected batch no. even after its Production
// Order already exists (rejection only checks WO status, not whether a PO
// was generated earlier). Without this, the Production Order — and every
// screen that searches/displays batch no. off it (Production Order list,
// Issue for Production) — permanently keeps showing the OLD batch even
// though the Work Order and SAP's own OWOR.Comments have the corrected one.
// Called from workOrderStore.resubmitAfterReject(); a no-op if no Production
// Order was ever generated for this Work Order.
async function syncBatchFromWorkOrder(workOrderId, { batchNumber, mfgDate, expDate }) {
await exec(
`UPDATE ${TABLE} SET BATCH_NUMBER=?, MFG_DATE=?, EXP_DATE=?, UPDATED_AT=? WHERE WORK_ORDER_ID=? AND (IS_DELETED=0 OR IS_DELETED IS NULL)`,
[batchNumber || '', mfgDate || '', expDate || '', toTs(new Date().toISOString()), parseInt(workOrderId)]
);
}
module.exports = {
bootstrap, STEPS, STEP_KEYS, ISSUANCE_STAGE, insertProductionOrder,
listProductionOrders, findById, findBySapAbsEntry, advanceStage, verify, receiveManual, softDelete,
adminForceClose, adminForceCancel, catchUpStage,
adminForceClose, adminForceCancel, catchUpStage, syncBatchFromWorkOrder,
};
+184
View File
@@ -0,0 +1,184 @@
// services/sales/constants.js
// Status codes, approval steps and default "Sales User Type" permission
// templates for the Sales Order module. Status numbers deliberately match the
// old msale portal's sales_order.status column 1:1 so migrated orders keep
// their meaning without any remapping.
'use strict';
const S = {
CREATED: 1, // customer placed it; awaiting BUH/Tender review
LOGISTICS: 2, // sent to Logistics to confirm qty & dispatch time
QTY_CONFIRMED: 3, // Logistics confirmed (direct orders wait here for BUH)
APPROVED: 4, // approved — customer must pay
MODIFY: 5, // sent back for modification
PAYMENT_SUBMITTED: 6,// customer paid; Accounts must verify
READY_FOR_SAP: 7, // payment received / direct order placed → post to SAP
IN_SAP: 8, // SAP Sales Order created; invoicing in progress
CLOSED: 9, // fully invoiced / closed
CANCELLED: 10,
DELETED: 11,
};
// Employee-facing labels (direct orders read slightly differently, as in msale).
function statusLabel(status, orderType) {
const direct = orderType === 'DIRECT';
switch (Number(status)) {
case 1: return 'Review the sale order';
case 2: return 'To be confirmed by Logistics';
case 3: return 'Qty & dispatch time confirmed by Logistics';
case 4: return 'Approved — payment pending';
case 5: return direct ? 'Pending modification' : 'Pending modification by customer';
case 6: return 'Payment to be confirmed by Accounts';
case 7: return direct ? 'To be uploaded in SAP' : 'Payment received — to be uploaded in SAP';
case 8: return 'Uploaded in SAP';
case 9: return 'Closed';
case 10: return 'Cancelled';
case 11: return 'Deleted';
default: return 'Unknown';
}
}
// Customer-facing labels (msale get_so_status_type() with no user_type).
function customerStatusLabel(status) {
switch (Number(status)) {
case 1: case 2: case 3: return 'Order is under verification';
case 4: return 'Order is approved, please make payment';
case 5: return 'Modify order as required';
case 6: return 'Payment to be confirmed by MIPL';
case 7: case 8: return 'Invoice processing';
case 9: return 'Closed';
case 10: return 'Cancelled';
default: return 'Unknown';
}
}
const SO_TYPES = { 1: 'Trade', 2: 'Institution' };
// Value written to SAP ORDR.U_SalesType — matches what the old external job wrote.
function sapSalesType(soType, orderType) {
if (Number(soType) === 1) return 'Trade';
return orderType === 'DIRECT' ? 'Institute Dir' : 'Institute Ind';
}
// Sample request (domestic) statuses — msale sample_request.status.
const SAMPLE = { SUBMITTED: 1, APPROVED: 2, MODIFICATION: 3, REJECTED: 4, RESUBMITTED: 5, QA_PENDING: 6, IN_PRODUCTION: 7, SHIPPED: 8, COMPLETED: 9 };
function sampleStatusLabel(s) {
return ({ 1: 'Submitted', 2: 'Approved by BUH', 3: 'Modification requested', 4: 'Rejected', 5: 'Resubmitted after modification',
6: 'QA verification pending', 7: 'In production', 8: 'Shipped — feedback awaited', 9: 'Completed' })[Number(s)] || 'Unknown';
}
// Export sample statuses — msale sample_request_export.sample_status.
const EXPORT_SAMPLE = { SUBMITTED: 1, IN_PRODUCTION: 2, QA_PENDING: 3, DISPATCHED: 4 };
function exportSampleStatusLabel(s) {
return ({ 1: 'Submitted', 2: 'In production', 3: 'QA verification pending', 4: 'Dispatched' })[Number(s)] || 'Unknown';
}
// Approval steps registered in ZAPPROVAL_STEPS (see approvalStepsStore.js).
const STEPS = [
{ workflow: 'sales_order', key: 'view', label: 'View sales orders (own divisions)', order: 1 },
{ workflow: 'sales_order', key: 'create_direct', label: 'Create / modify Direct Order (on behalf of customer)', order: 2 },
{ workflow: 'sales_order', key: 'review', label: 'Review customer order (send to Logistics / back for modification)', order: 3 },
{ workflow: 'sales_order', key: 'logistics_confirm', label: 'Logistics — confirm qty & dispatch time', order: 4 },
{ workflow: 'sales_order', key: 'final_approve', label: 'Final approval after Logistics (Direct orders)', order: 5 },
{ workflow: 'sales_order', key: 'payment_verify', label: 'Accounts — verify customer payment', order: 6 },
{ workflow: 'sales_order', key: 'payment_entry', label: 'Accounts — on-account payment entry & credit limit', order: 7 },
{ workflow: 'sales_order', key: 'sap_post', label: 'Post order to SAP / close order', order: 8 },
{ workflow: 'sales_order', key: 'cancel', label: 'Cancel sales order', order: 9 },
{ workflow: 'sales_sample', key: 'view', label: 'View sample requests (domestic)', order: 1 },
{ workflow: 'sales_sample', key: 'create', label: 'Raise / modify sample request', order: 2 },
{ workflow: 'sales_sample', key: 'buh_review', label: 'BUH — approve / modify / reject sample', order: 3 },
{ workflow: 'sales_sample', key: 'logistics', label: 'Logistics — stock, QA verification, dispatch', order: 4 },
{ workflow: 'sales_sample', key: 'feedback', label: 'Sales — record customer feedback', order: 5 },
{ workflow: 'sales_export_sample', key: 'view', label: 'View export sample requests', order: 1 },
{ workflow: 'sales_export_sample', key: 'create', label: 'Raise / modify export sample request', order: 2 },
{ workflow: 'sales_export_sample', key: 'logistics', label: 'Logistics — stock, QA verification, dispatch', order: 3 },
{ workflow: 'sales_master', key: 'products', label: 'Maintain products & prices', order: 1 },
{ workflow: 'sales_master', key: 'mapping', label: 'Maintain sales person ↔ customer mapping', order: 2 },
{ workflow: 'sales_master', key: 'customers', label: 'Maintain customer portal logins', order: 3 },
];
const ALL = ['view', 'add', 'edit', 'approve', 'delete'];
const st = (step, perms) => ({ step, perms });
// Default permission template per msale user_type (ids kept identical so the
// employee_master.user_type migration is a straight copy). `scope`:
// all — every customer in the user's divisions
// mapped — only customers mapped to this user as a sales person (msale SM)
// own — only orders this user created (msale Tender)
// `listStatuses` = the default status filter on the order list (msale
// restricted Accounts to 6-9 and Logistics to 2,7,8,9).
const VIEW_ONLY_ORDERS = [st('sales_order:view', ['view'])];
const USER_TYPES = [
{ id: 1, name: 'Superuser', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports', 'sales-admin'],
steps: STEPS.map(s => st(`${s.workflow}:${s.key}`, ALL)) },
{ id: 2, name: 'Managing Director', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_sample:view', ['view']), st('sales_export_sample:view', ['view'])] },
{ id: 3, name: 'Vice President', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_sample:view', ['view']), st('sales_export_sample:view', ['view'])] },
{ id: 4, name: 'Account', scope: 'all', listStatuses: '6,7,8,9', modules: ['sales-orders', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_order:payment_verify', ['view', 'approve']), st('sales_order:payment_entry', ['view', 'add', 'edit']),
st('sales_order:sap_post', ['view', 'approve']), st('sales_order:cancel', ['view', 'delete'])] },
{ id: 5, name: 'Business Unit Head', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_order:create_direct', ['view', 'add', 'edit']), st('sales_order:review', ['view', 'approve']),
st('sales_order:final_approve', ['view', 'approve']), st('sales_order:cancel', ['view', 'delete']),
st('sales_sample:view', ['view']), st('sales_sample:create', ['view', 'add', 'edit']), st('sales_sample:buh_review', ['view', 'approve'])] },
{ id: 6, name: 'Tender', scope: 'own', listStatuses: '', modules: ['sales-orders', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_order:create_direct', ['view', 'add', 'edit']), st('sales_order:review', ['view', 'approve']),
st('sales_order:final_approve', ['view', 'approve']), st('sales_order:cancel', ['view', 'delete'])] },
{ id: 7, name: 'Logistics', scope: 'all', listStatuses: '2,7,8,9', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_order:logistics_confirm', ['view', 'approve']), st('sales_order:sap_post', ['view', 'approve']),
st('sales_order:cancel', ['view', 'delete']), st('sales_sample:view', ['view']), st('sales_sample:logistics', ['view', 'approve']),
st('sales_export_sample:view', ['view']), st('sales_export_sample:logistics', ['view', 'approve'])] },
{ id: 8, name: 'Sale Manager', scope: 'mapped', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_sample:view', ['view']), st('sales_sample:create', ['view', 'add', 'edit']), st('sales_sample:feedback', ['view', 'approve'])] },
{ id: 9, name: 'National Sale Manager', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-reports'], steps: [...VIEW_ONLY_ORDERS] },
{ id: 10, name: 'Regional Sale Manager', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-reports'], steps: [...VIEW_ONLY_ORDERS] },
{ id: 11, name: 'Area Sale Manager', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-reports'], steps: [...VIEW_ONLY_ORDERS] },
{ id: 12, name: 'Zonal Sale Manager', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_sample:view', ['view'])] },
{ id: 13, name: 'H (NP-CS)', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-reports'], steps: [...VIEW_ONLY_ORDERS] },
{ id: 14, name: 'Plant Head', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples', 'sales-reports'],
steps: [...VIEW_ONLY_ORDERS, st('sales_sample:view', ['view'])] },
{ id: 15, name: 'Production', scope: 'all', listStatuses: '', modules: ['sales-orders', 'sales-samples'],
steps: [...VIEW_ONLY_ORDERS, st('sales_sample:view', ['view'])] },
{ id: 16, name: 'IPQA', scope: 'all', listStatuses: '', modules: ['sales-samples'],
steps: [st('sales_sample:view', ['view']), st('sales_export_sample:view', ['view'])] },
{ id: 17, name: 'Export', scope: 'all', listStatuses: '', modules: ['sales-samples'],
steps: [st('sales_export_sample:view', ['view']), st('sales_export_sample:create', ['view', 'add', 'edit'])] },
{ id: 18, name: 'BUH-Export', scope: 'all', listStatuses: '', modules: ['sales-samples'],
steps: [st('sales_export_sample:view', ['view']), st('sales_export_sample:create', ['view', 'add', 'edit'])] },
];
const DEFAULT_DIVISIONS = [
{ id: 101, name: 'FG BLOOD BAG', code: 'TM', itemGroups: '101', warehouse: '01', sort: 1 },
{ id: 102, name: 'FG CAPD', code: 'PD', itemGroups: '102,143', warehouse: '01', sort: 2 },
{ id: 105, name: 'FG STENT', code: 'TT', itemGroups: '', warehouse: '01', sort: 3 },
{ id: 106, name: 'STENT ACCESSORIES', code: 'TT', itemGroups: '', warehouse: '01', sort: 4 },
];
// Module-wide settings (ZSO_SETTINGS). Defaults reproduce msale behaviour.
const DEFAULT_SETTINGS = {
company: '', // SAP company DB for this module ('' = .env default)
productSource: 'catalog', // 'catalog' (ERP product list) | 'sap' (OITM + SAP price list)
sapPriceList: 1, // SAP price list number when productSource = 'sap'
companyState: 'HR', // ship-to in this state → CGST+SGST, else IGST
intraTaxCode: 'NC2NS2', cgstRate: 2.5, sgstRate: 2.5,
interTaxCode: 'NIG5', igstRate: 5,
tcsRate: 0, // msale had TCS switched off
sapSeries: null, // SAP ORDR Series (null = SAP default)
autoPostToSap: false, // post automatically (shared .env SAP user) when an order reaches status 7
roundingTolerance: 5, // msale "Recede" — under-payment up to ₹5 written off as rebate
bankApiUrl: '', // optional bank payment auto-match API (msale sapapi/payment_details)
bankApiKey: '',
orderEmailCc: '', // extra CC on every order email
emailNotifications: true, // Sales-only email on/off (the ERP-wide switch must ALSO be on)
// Test mode: while ON, every Sales email goes ONLY to emailTestRecipient
// (subject prefixed [TEST], real intended recipients listed in the body).
// Turn OFF at go-live so customers/employees get their emails.
invoiceEmail: true, // email "invoice raised" to customer + all concerned when SAP invoices an order
invoiceEmailSince: '', // set at first deploy; invoices dated earlier are never emailed
emailTestMode: true,
emailTestRecipient: 'chandan.singh@mitraindustries.com',
};
module.exports = {
S, statusLabel, customerStatusLabel, SO_TYPES, sapSalesType,
SAMPLE, sampleStatusLabel, EXPORT_SAMPLE, exportSampleStatusLabel,
STEPS, USER_TYPES, DEFAULT_DIVISIONS, DEFAULT_SETTINGS,
};
+58
View File
@@ -0,0 +1,58 @@
// services/sales/db.js
// Thin helpers over the portal's own app DB (services/appSqlPool.js) for the
// Sales Order module (replacement for the old msale/MySQL portal). Every
// Sales table lives in the APP database (ZSO_* prefix) — never the SAP DB.
'use strict';
const sql = require('mssql');
const { getPool } = require('../appSqlPool');
// Bind `?` placeholders positionally (same convention as appSqlPool.query),
// but with explicit types for numbers so decimals don't round-trip as FLOAT
// surprises and big ints don't overflow INT.
function bind(req, text, params) {
let i = 0;
return text.replace(/\?/g, () => {
const name = `p${i}`;
const v = params[i];
if (v === undefined || v === null) req.input(name, sql.NVarChar, null);
else if (typeof v === 'number') req.input(name, Number.isInteger(v) && Math.abs(v) < 2147483647 ? sql.Int : sql.Decimal(19, 4), v);
else if (typeof v === 'boolean') req.input(name, sql.Bit, v ? 1 : 0);
else if (v instanceof Date) req.input(name, sql.DateTime2, v);
else req.input(name, sql.NVarChar(sql.MAX), String(v));
i++;
return `@${name}`;
});
}
async function query(text, params = [], runner = null) {
const r = runner ? runner.request() : (await getPool()).request();
const res = await r.query(bind(r, text, params));
return res.recordset || [];
}
async function one(text, params = [], runner = null) {
const rows = await query(text, params, runner);
return rows[0] || null;
}
// Run fn(tx) inside a SQL transaction; fn receives a runner usable as the
// 3rd arg of query()/one(). Rolls back on any throw.
async function tx(fn) {
const pool = await getPool();
const t = new sql.Transaction(pool);
await t.begin();
try {
const out = await fn(t);
await t.commit();
return out;
} catch (e) {
try { await t.rollback(); } catch (_e) {}
throw e;
}
}
function json(v, fb) { if (v == null || v === '') return fb; try { return JSON.parse(v); } catch (_e) { return fb; } }
function csv(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
function round2(n) { return Math.round((Number(n) || 0) * 100) / 100; }
module.exports = { query, one, tx, json, csv, round2, sql };
+305
View File
@@ -0,0 +1,305 @@
// services/sales/masters.js
// Settings, divisions, product catalog, Sales User Types (permission
// templates), per-employee sales profiles, sales-person ↔ customer mapping
// and customer portal accounts for the Sales Order module.
'use strict';
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { query, one, json, csv } = require('./db');
const { DEFAULT_SETTINGS } = require('./constants');
const { DEFAULT_COMPANY } = require('../companyConfig');
// ── Settings (cached 30s) ───────────────────────────────────────────────────
let _settings = null, _settingsAt = 0;
async function getSettings(force = false) {
if (!force && _settings && Date.now() - _settingsAt < 30000) return _settings;
const rows = await query(`SELECT SKEY, SVALUE FROM dbo.ZSO_SETTINGS`);
const s = { ...DEFAULT_SETTINGS };
rows.forEach(r => { s[r.SKEY] = json(r.SVALUE, r.SVALUE); });
if (!s.company) s.company = DEFAULT_COMPANY;
_settings = s; _settingsAt = Date.now();
return s;
}
async function saveSettings(patch, by) {
for (const [k, v] of Object.entries(patch || {})) {
if (!(k in DEFAULT_SETTINGS)) continue;
await query(`MERGE dbo.ZSO_SETTINGS AS t USING (SELECT ? AS K) s ON t.SKEY=s.K
WHEN MATCHED THEN UPDATE SET SVALUE=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=?
WHEN NOT MATCHED THEN INSERT (SKEY,SVALUE,UPDATED_BY) VALUES (?,?,?);`, [k, JSON.stringify(v), by, k, JSON.stringify(v), by]);
}
return getSettings(true);
}
// Settings safe to show to every logged-in employee/customer (no API keys).
function publicSettings(s) { const { bankApiKey, ...rest } = s; return rest; }
// ── Divisions ───────────────────────────────────────────────────────────────
function divFromRow(r) { return { id: r.ID, name: r.NAME, code: r.CODE || '', itemGroups: r.ITEM_GROUPS || '', warehouse: r.WAREHOUSE || '', sort: r.SORT || 0, active: !!r.ACTIVE }; }
async function listDivisions(includeInactive = false) {
const rows = await query(`SELECT * FROM dbo.ZSO_DIVISIONS ${includeInactive ? '' : 'WHERE ACTIVE=1'} ORDER BY SORT, ID`);
return rows.map(divFromRow);
}
async function saveDivision(d) {
const id = parseInt(d.id);
if (!id) throw new Error('Division ID is required');
await query(`MERGE dbo.ZSO_DIVISIONS AS t USING (SELECT ? AS ID) s ON t.ID=s.ID
WHEN MATCHED THEN UPDATE SET NAME=?, CODE=?, ITEM_GROUPS=?, WAREHOUSE=?, SORT=?, ACTIVE=?
WHEN NOT MATCHED THEN INSERT (ID,NAME,CODE,ITEM_GROUPS,WAREHOUSE,SORT,ACTIVE) VALUES (?,?,?,?,?,?,?);`,
[id, d.name, d.code || '', d.itemGroups || '', d.warehouse || '', parseInt(d.sort) || 0, d.active !== false,
id, d.name, d.code || '', d.itemGroups || '', d.warehouse || '', parseInt(d.sort) || 0, d.active !== false]);
}
async function getDivision(id) { const r = await one(`SELECT * FROM dbo.ZSO_DIVISIONS WHERE ID=?`, [parseInt(id)]); return r ? divFromRow(r) : null; }
// ── Products ────────────────────────────────────────────────────────────────
// Two sources (Admin → Settings → Product source):
// catalog — ZSO_PRODUCTS, maintained in the Sales admin screen (msale's
// product + price_list tables, migrated)
// sap — live OITM items of the division's SAP item groups, priced from
// the configured SAP price list
// Either way callers get the same shape, keyed by `key` (P:<id> / S:<code>).
function prodFromRow(r) {
return { key: `P:${r.ID}`, id: r.ID, catalog: r.CATALOG, divisionId: r.DIVISION_ID, itemCode: r.ITEM_CODE, displayCode: r.DISPLAY_CODE || r.ITEM_CODE,
description: r.DESCRIPTION || '', shortDesc: r.SHORT_DESC || '', packSize: r.PACK_SIZE || 1, hsn: r.HSN || '', price: Number(r.UNIT_PRICE) || 0,
isInstrument: !!r.IS_INSTRUMENT, active: !!r.ACTIVE };
}
async function listCatalog({ catalog = 'domestic', divisionId, includeInactive = false } = {}) {
const w = ['CATALOG=?']; const p = [catalog];
if (divisionId) { w.push('DIVISION_ID=?'); p.push(parseInt(divisionId)); }
if (!includeInactive) w.push('ACTIVE=1');
const rows = await query(`SELECT * FROM dbo.ZSO_PRODUCTS WHERE ${w.join(' AND ')} ORDER BY DIVISION_ID, DISPLAY_CODE`, p);
return rows.map(prodFromRow);
}
async function saveProduct(d, by) {
const vals = [d.catalog || 'domestic', parseInt(d.divisionId), String(d.itemCode || '').trim(), d.displayCode || d.itemCode, d.description || '',
d.shortDesc || '', parseInt(d.packSize) || 1, d.hsn || '', Number(d.price) || 0, !!d.isInstrument, d.active !== false, by];
if (!vals[1] || !vals[2]) throw new Error('Division and SAP item code are required');
if (d.id) {
await query(`UPDATE dbo.ZSO_PRODUCTS SET CATALOG=?, DIVISION_ID=?, ITEM_CODE=?, DISPLAY_CODE=?, DESCRIPTION=?, SHORT_DESC=?, PACK_SIZE=?, HSN=?,
UNIT_PRICE=?, IS_INSTRUMENT=?, ACTIVE=?, UPDATED_BY=?, UPDATED_AT=SYSDATETIME() WHERE ID=?`, [...vals, parseInt(d.id)]);
return parseInt(d.id);
}
const r = await one(`INSERT INTO dbo.ZSO_PRODUCTS (CATALOG,DIVISION_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,SHORT_DESC,PACK_SIZE,HSN,UNIT_PRICE,IS_INSTRUMENT,ACTIVE,UPDATED_BY)
OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, vals);
return r.ID;
}
// Products orderable in a division, from whichever source is configured.
async function listOrderableProducts(divisionId, catalog = 'domestic') {
const s = await getSettings();
if (s.productSource === 'sap' && catalog === 'domestic') {
const div = await getDivision(divisionId);
return require('./sap').listSapProducts(s.company, csv(div && div.itemGroups), s.sapPriceList, divisionId);
}
return listCatalog({ catalog, divisionId });
}
// Resolve one product key → product (used to price order lines server-side;
// the client's price is never trusted).
async function resolveProduct(key, divisionId, catalog = 'domestic') {
if (String(key).startsWith('P:')) {
const r = await one(`SELECT * FROM dbo.ZSO_PRODUCTS WHERE ID=? AND ACTIVE=1`, [parseInt(String(key).slice(2))]);
return r ? prodFromRow(r) : null;
}
if (String(key).startsWith('S:')) {
const list = await listOrderableProducts(divisionId, catalog);
return list.find(p => p.key === key) || null;
}
return null;
}
// ── Sales User Types (permission templates) ─────────────────────────────────
function utFromRow(r) {
return { id: r.ID, name: r.NAME, scope: r.SCOPE, listStatuses: r.LIST_STATUSES || '', defaultSteps: json(r.DEFAULT_STEPS, []),
defaultModules: json(r.DEFAULT_MODULES, []), active: !!r.ACTIVE };
}
async function listUserTypes() { return (await query(`SELECT * FROM dbo.ZSO_USER_TYPES ORDER BY ID`)).map(utFromRow); }
async function getUserType(id) { const r = await one(`SELECT * FROM dbo.ZSO_USER_TYPES WHERE ID=?`, [parseInt(id)]); return r ? utFromRow(r) : null; }
async function saveUserType(d) {
const id = parseInt(d.id);
if (!id || !d.name) throw new Error('ID and name are required');
if (!['all', 'mapped', 'own'].includes(d.scope)) throw new Error('Scope must be all / mapped / own');
const steps = JSON.stringify(Array.isArray(d.defaultSteps) ? d.defaultSteps : []);
const mods = JSON.stringify(Array.isArray(d.defaultModules) ? d.defaultModules : []);
await query(`MERGE dbo.ZSO_USER_TYPES AS t USING (SELECT ? AS ID) s ON t.ID=s.ID
WHEN MATCHED THEN UPDATE SET NAME=?, SCOPE=?, LIST_STATUSES=?, DEFAULT_STEPS=?, DEFAULT_MODULES=?, ACTIVE=?
WHEN NOT MATCHED THEN INSERT (ID,NAME,SCOPE,LIST_STATUSES,DEFAULT_STEPS,DEFAULT_MODULES,ACTIVE) VALUES (?,?,?,?,?,?,?);`,
[id, d.name, d.scope, d.listStatuses || '', steps, mods, d.active !== false, id, d.name, d.scope, d.listStatuses || '', steps, mods, d.active !== false]);
}
// ── Employee sales profiles ─────────────────────────────────────────────────
function profFromRow(r) {
return { userId: r.USER_ID, userTypeId: r.USER_TYPE_ID, divisions: csv(r.DIVISIONS).map(Number), empCode: r.EMP_CODE || '',
territory: r.TERRITORY || '', ccEmails: r.CC_EMAILS || '' };
}
// Profile + resolved user type for an ERP user. ERP admins with no profile
// act as Superuser across every division.
const _profCache = new Map();
async function getProfile(user) {
const uid = parseInt(user && user.id);
const hit = _profCache.get(uid);
if (hit && Date.now() - hit.at < 20000) return hit.v;
const r = uid ? await one(`SELECT * FROM dbo.ZSO_USER_PROFILES WHERE USER_ID=?`, [uid]) : null;
let prof = r ? profFromRow(r) : null;
const ut = prof && prof.userTypeId ? await getUserType(prof.userTypeId) : null;
// All categories incl. disabled — disabling only stops NEW orders; existing
// ones in it must stay visible. (Creation checks division.active itself.)
const allDivs = (await listDivisions(true)).map(d => d.id);
const isAdmin = user && user.role === 'admin';
const v = {
userId: uid,
configured: !!prof,
userTypeId: prof ? prof.userTypeId : null,
userTypeName: ut ? ut.name : (isAdmin ? 'Administrator' : null),
scope: isAdmin ? 'all' : (ut ? ut.scope : 'all'),
listStatuses: ut ? csv(ut.listStatuses).map(Number) : [],
divisions: isAdmin ? allDivs : (prof && prof.divisions.length ? prof.divisions : []),
empCode: prof ? prof.empCode : '',
ccEmails: prof ? prof.ccEmails : '',
};
_profCache.set(uid, { at: Date.now(), v });
return v;
}
async function listProfiles() {
return (await query(`SELECT * FROM dbo.ZSO_USER_PROFILES`)).map(profFromRow);
}
// Save an employee's sales profile. With applyTemplate=true, the user type's
// default steps/modules are written onto the ERP user (replacing any existing
// sales_* steps and sales-* modules, leaving everything else untouched).
async function saveProfile(d, by) {
const uid = parseInt(d.userId);
if (!uid) throw new Error('userId is required');
const divs = (Array.isArray(d.divisions) ? d.divisions : csv(d.divisions)).map(Number).filter(Boolean).join(',');
await query(`MERGE dbo.ZSO_USER_PROFILES AS t USING (SELECT ? AS U) s ON t.USER_ID=s.U
WHEN MATCHED THEN UPDATE SET USER_TYPE_ID=?, DIVISIONS=?, EMP_CODE=?, TERRITORY=?, CC_EMAILS=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY=?
WHEN NOT MATCHED THEN INSERT (USER_ID,USER_TYPE_ID,DIVISIONS,EMP_CODE,TERRITORY,CC_EMAILS,UPDATED_BY) VALUES (?,?,?,?,?,?,?);`,
[uid, parseInt(d.userTypeId) || null, divs, d.empCode || '', d.territory || '', d.ccEmails || '', by,
uid, parseInt(d.userTypeId) || null, divs, d.empCode || '', d.territory || '', d.ccEmails || '', by]);
_profCache.delete(uid);
if (d.applyTemplate && d.userTypeId) await applyUserTypeTemplate(uid, parseInt(d.userTypeId));
}
async function applyUserTypeTemplate(userId, userTypeId) {
const users = require('../hanaUsers');
const u = await users.findById(userId);
const ut = await getUserType(userTypeId);
if (!u || !ut) throw new Error('User or user type not found');
const isSalesStep = s => /^sales_/.test(typeof s === 'string' ? s : (s && s.step) || '');
const steps = (u.approvalSteps || []).filter(s => !isSalesStep(s)).concat(ut.defaultSteps || []);
const mods = Array.isArray(u.modules) ? u.modules.filter(m => !/^sales-/.test(m)) : [];
await users.updateUser(userId, { approvalSteps: steps, modules: mods.concat(ut.defaultModules || []) });
}
// ── Sales persons & customer mapping (msale sales_person_master) ────────────
async function listSalesPersons() {
const rows = await query(`SELECT p.*, (SELECT COUNT(*) FROM dbo.ZSO_SP_CUSTOMER_MAP m WHERE m.SALES_PERSON_ID=p.ID AND m.ACTIVE=1) AS CUST_COUNT
FROM dbo.ZSO_SALES_PERSONS p ORDER BY p.ACTIVE DESC, p.NAME`);
return rows.map(r => ({ id: r.ID, name: r.NAME, email: r.EMAIL || '', designation: r.DESIGNATION || '', divisions: csv(r.DIVISIONS).map(Number),
userId: r.USER_ID, active: !!r.ACTIVE, customerCount: r.CUST_COUNT }));
}
async function saveSalesPerson(d) {
const divs = (Array.isArray(d.divisions) ? d.divisions : csv(d.divisions)).join(',');
if (!d.name) throw new Error('Name is required');
if (d.id) {
await query(`UPDATE dbo.ZSO_SALES_PERSONS SET NAME=?, EMAIL=?, DESIGNATION=?, DIVISIONS=?, USER_ID=?, ACTIVE=? WHERE ID=?`,
[d.name, d.email || '', d.designation || '', divs, parseInt(d.userId) || null, d.active !== false, parseInt(d.id)]);
return parseInt(d.id);
}
const r = await one(`INSERT INTO dbo.ZSO_SALES_PERSONS (NAME,EMAIL,DESIGNATION,DIVISIONS,USER_ID,ACTIVE) OUTPUT INSERTED.ID VALUES (?,?,?,?,?,1)`,
[d.name, d.email || '', d.designation || '', divs, parseInt(d.userId) || null]);
return r.ID;
}
async function listMappedCustomers(salesPersonId) {
return (await query(`SELECT ID, CARD_CODE FROM dbo.ZSO_SP_CUSTOMER_MAP WHERE SALES_PERSON_ID=? AND ACTIVE=1 ORDER BY CARD_CODE`, [parseInt(salesPersonId)]))
.map(r => ({ id: r.ID, cardCode: r.CARD_CODE }));
}
async function mapCustomers(salesPersonId, cardCodes, by) {
for (const cc of cardCodes) {
await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_SP_CUSTOMER_MAP WHERE SALES_PERSON_ID=? AND CARD_CODE=? AND ACTIVE=1)
INSERT INTO dbo.ZSO_SP_CUSTOMER_MAP (SALES_PERSON_ID,CARD_CODE,ACTIVE,CREATED_BY) VALUES (?,?,1,?)`,
[parseInt(salesPersonId), cc, parseInt(salesPersonId), cc, by]);
}
}
async function unmapCustomer(mapId) { await query(`UPDATE dbo.ZSO_SP_CUSTOMER_MAP SET ACTIVE=0 WHERE ID=?`, [parseInt(mapId)]); }
// Customers visible to a 'mapped'-scope employee: those mapped to any sales
// person linked to this user (by USER_ID, or by matching email — msale keyed on email).
async function mappedCardCodesForUser(userId, email) {
const rows = await query(`SELECT DISTINCT m.CARD_CODE FROM dbo.ZSO_SP_CUSTOMER_MAP m JOIN dbo.ZSO_SALES_PERSONS p ON p.ID=m.SALES_PERSON_ID
WHERE m.ACTIVE=1 AND p.ACTIVE=1 AND (p.USER_ID=? OR (?<>'' AND LOWER(p.EMAIL)=LOWER(?)))`, [parseInt(userId) || 0, email || '', email || '']);
return rows.map(r => r.CARD_CODE);
}
// Sales-person emails for a customer in a division (order email CCs — msale get_zsm_email_by_dealer_id).
async function salesPersonEmailsFor(cardCode, divisionId) {
const rows = await query(`SELECT DISTINCT p.EMAIL, p.DIVISIONS FROM dbo.ZSO_SP_CUSTOMER_MAP m JOIN dbo.ZSO_SALES_PERSONS p ON p.ID=m.SALES_PERSON_ID
WHERE m.ACTIVE=1 AND p.ACTIVE=1 AND m.CARD_CODE=?`, [cardCode]);
return rows.filter(r => r.EMAIL && (!divisionId || csv(r.DIVISIONS).map(Number).includes(Number(divisionId)))).map(r => r.EMAIL);
}
// ── Customer portal accounts ────────────────────────────────────────────────
// Passwords migrated from msale are unsalted MD5 — stored as "md5:<hex>" and
// transparently upgraded to bcrypt on the customer's first successful login.
async function hashPassword(p) { return bcrypt.hash(String(p), 10); }
async function verifyPassword(plain, stored) {
if (!stored) return false;
if (stored.startsWith('md5:')) return crypto.createHash('md5').update(String(plain)).digest('hex') === stored.slice(4).toLowerCase();
return bcrypt.compare(String(plain), stored);
}
function custFromRow(r) {
return { cardCode: r.CARD_CODE, cardName: r.CARD_NAME || '', email: r.EMAIL || '', active: !!r.ACTIVE, locked: !!r.LOCKED,
loginAttempts: r.LOGIN_ATTEMPTS || 0, mustChangePwd: !!r.MUST_CHANGE_PWD, lastLogin: r.LAST_LOGIN, creditLimit: Number(r.CREDIT_LIMIT) || 0,
hasPassword: !!r.PASSWORD_HASH };
}
async function getCustomerAccount(cardCode) {
const r = await one(`SELECT * FROM dbo.ZSO_CUSTOMERS WHERE CARD_CODE=?`, [cardCode]);
return r ? { ...custFromRow(r), _hash: r.PASSWORD_HASH } : null;
}
async function listCustomerAccounts(search) {
const s = `%${String(search || '').trim()}%`;
const rows = await query(`SELECT TOP 500 * FROM dbo.ZSO_CUSTOMERS WHERE (?='%%' OR CARD_CODE LIKE ? OR CARD_NAME LIKE ?) ORDER BY CARD_CODE`, [s, s, s]);
return rows.map(custFromRow);
}
async function upsertCustomerAccount(d, by) {
const cc = String(d.cardCode || '').trim();
if (!cc) throw new Error('Customer code is required');
const existing = await one(`SELECT CARD_CODE FROM dbo.ZSO_CUSTOMERS WHERE CARD_CODE=?`, [cc]);
if (d.password && String(d.password).length < 8) throw new Error('Password must be at least 8 characters');
const hash = d.password ? await hashPassword(d.password) : null;
if (existing) {
await query(`UPDATE dbo.ZSO_CUSTOMERS SET CARD_NAME=COALESCE(?,CARD_NAME), EMAIL=?, ACTIVE=?, LOCKED=?, CREDIT_LIMIT=?,
PASSWORD_HASH=COALESCE(?,PASSWORD_HASH), MUST_CHANGE_PWD=CASE WHEN ? IS NULL THEN MUST_CHANGE_PWD ELSE 1 END,
LOGIN_ATTEMPTS=CASE WHEN ?=0 THEN 0 ELSE LOGIN_ATTEMPTS END, UPDATED_AT=SYSDATETIME(), UPDATED_BY=? WHERE CARD_CODE=?`,
[d.cardName || null, d.email || '', d.active !== false, !!d.locked, Number(d.creditLimit) || 0, hash, hash, d.locked ? 1 : 0, by, cc]);
return { created: false, passwordReset: !!hash };
} else {
if (!hash) throw new Error('An initial password is required for a new customer login');
await query(`INSERT INTO dbo.ZSO_CUSTOMERS (CARD_CODE,CARD_NAME,EMAIL,PASSWORD_HASH,ACTIVE,LOCKED,CREDIT_LIMIT,MUST_CHANGE_PWD,UPDATED_BY) VALUES (?,?,?,?,?,0,?,1,?)`,
[cc, d.cardName || '', d.email || '', hash, d.active !== false, Number(d.creditLimit) || 0, by]);
return { created: true, passwordReset: false };
}
}
const MAX_LOGIN_ATTEMPTS = 5;
async function customerLogin(cardCode, password) {
const acc = await getCustomerAccount(String(cardCode || '').trim());
if (!acc || !acc.active) return { ok: false, message: 'Invalid customer code or password' };
if (acc.locked) return { ok: false, message: 'This login is locked after too many failed attempts. Please contact MIPL to unlock it.' };
const ok = await verifyPassword(password, acc._hash);
if (!ok) {
const n = acc.loginAttempts + 1;
await query(`UPDATE dbo.ZSO_CUSTOMERS SET LOGIN_ATTEMPTS=?, LOCKED=? WHERE CARD_CODE=?`, [n, n >= MAX_LOGIN_ATTEMPTS, acc.cardCode]);
return { ok: false, message: n >= MAX_LOGIN_ATTEMPTS ? 'Too many failed attempts — this login is now locked. Contact MIPL.' : 'Invalid customer code or password' };
}
const upgrade = acc._hash.startsWith('md5:') ? await hashPassword(password) : null;
await query(`UPDATE dbo.ZSO_CUSTOMERS SET LOGIN_ATTEMPTS=0, LAST_LOGIN=SYSDATETIME(), PASSWORD_HASH=COALESCE(?,PASSWORD_HASH) WHERE CARD_CODE=?`, [upgrade, acc.cardCode]);
return { ok: true, account: acc };
}
async function changeCustomerPassword(cardCode, oldPwd, newPwd) {
const acc = await getCustomerAccount(cardCode);
if (!acc || !(await verifyPassword(oldPwd, acc._hash))) throw new Error('Current password is incorrect');
if (String(newPwd || '').length < 8) throw new Error('New password must be at least 8 characters');
await query(`UPDATE dbo.ZSO_CUSTOMERS SET PASSWORD_HASH=?, MUST_CHANGE_PWD=0, UPDATED_AT=SYSDATETIME() WHERE CARD_CODE=?`, [await hashPassword(newPwd), cardCode]);
}
module.exports = {
getSettings, saveSettings, publicSettings,
listDivisions, saveDivision, getDivision,
listCatalog, saveProduct, listOrderableProducts, resolveProduct,
listUserTypes, getUserType, saveUserType,
getProfile, listProfiles, saveProfile, applyUserTypeTemplate,
listSalesPersons, saveSalesPerson, listMappedCustomers, mapCustomers, unmapCustomer, mappedCardCodesForUser, salesPersonEmailsFor,
getCustomerAccount, listCustomerAccounts, upsertCustomerAccount, customerLogin, changeCustomerPassword, verifyPassword, hashPassword,
};
+186
View File
@@ -0,0 +1,186 @@
// services/sales/notify.js
// Email routing for the Sales Order module. Mirrors msale's recipients
// (BUH for the division, Logistics, Accounts, the customer, mapped sales
// persons as CC) but resolves "who is BUH/Logistics/Accounts" from the ERP's
// Approval Steps + each user's Sales divisions instead of hardcoded user_type.
// Never throws — a mail problem must not break an order action.
'use strict';
const { sendMail } = require('../mailer');
const { usersForStep } = require('../notifyStore');
const masters = require('./masters');
const { query } = require('./db');
const appSettingsStore = require('../appSettingsStore');
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
const base = () => (process.env.APP_BASE_URL || '').replace(/\/$/, '');
// Active users explicitly assigned `stepKey` whose sales divisions include
// `divisionId` (users with no division restriction configured are skipped
// unless they're admin, so a BUH of TM isn't mailed about PD orders).
async function emailsForStep(stepKey, divisionId) {
try {
// Admin → System Settings → per-step email opt-out (same list the
// Production module honours) — a listed step mails nobody.
if (!appSettingsStore.isNotifyStepEnabled(stepKey)) return [];
const users = await usersForStep(stepKey);
if (!users.length) return [];
const profs = await query(`SELECT USER_ID, DIVISIONS FROM dbo.ZSO_USER_PROFILES`);
const divMap = new Map(profs.map(p => [p.USER_ID, String(p.DIVISIONS || '').split(',').map(Number).filter(Boolean)]));
return users.filter(u => u.email && (u.role === 'admin' || !divisionId || (divMap.get(u.id) || []).includes(Number(divisionId)))).map(u => u.email);
} catch (e) { console.warn('[SALES-NOTIFY] recipient lookup failed:', e.message); return []; }
}
function html({ title, lines, link }) {
return `<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#1f2937">
<h3 style="margin:0 0 10px;color:#0f3d91">${esc(title)}</h3>
${lines.map(l => `<p style="margin:4px 0">${l}</p>`).join('')}
${link ? `<p style="margin:14px 0 0"><a href="${esc(link)}" style="background:#1a6fff;color:#fff;padding:8px 14px;border-radius:6px;text-decoration:none">Open</a></p>` : ''}
<p style="margin-top:18px;font-size:12px;color:#6b7280">Mitra Industries Pvt. Ltd. — Sales Order Portal</p></div>`;
}
async function send(to, cc, subject, body) {
// Two switches, both must be on: the ERP-wide email switch (Admin →
// System Settings → Stage-Change Email Notifications) and the Sales-only
// switch (Sales Admin → Settings → "Send Sales email notifications").
if (!appSettingsStore.notifyEmailsEnabled()) return;
const s = await masters.getSettings();
if (s.emailNotifications === false) return;
const list = [...new Set([...(to || []), ...(cc || []), ...String(s.orderEmailCc || '').split(',')].map(x => String(x || '').trim()).filter(Boolean))];
// Test mode never falls through to real recipients: if it's on, the mail
// goes to the test address only (even when nobody real would have got it,
// so routing gaps are visible too) — or nowhere if no test address is set.
if (s.emailTestMode !== false) {
const testTo = String(s.emailTestRecipient || '').split(',').map(x => x.trim()).filter(Boolean);
if (!testTo.length) { console.log(`[SALES-NOTIFY] test mode, no test recipient — skipped "${subject}"`); return; }
const note = `<div style="background:#fff7e6;border:1px solid #f5a623;padding:8px 10px;margin-bottom:12px;font-size:12px">
<b>TEST MODE</b> — this email was NOT sent to its real recipients.<br>Would have gone to: <b>${list.length ? list.map(esc).join(', ') : '(nobody — no recipient found)'}</b></div>`;
try { await sendMail({ to: testTo, subject: `[TEST] ${subject}`, html: note + html(body) }); } catch (e) { console.warn('[SALES-NOTIFY] send failed:', e.message); }
return;
}
if (!list.length) return;
try { await sendMail({ to: list, subject, html: html(body) }); } catch (e) { console.warn('[SALES-NOTIFY] send failed:', e.message); }
}
// Order status-change notifications. `order` is the full order object.
async function orderEvent(event, order, { customerEmail = '', remarks = '' } = {}) {
try {
const div = order.divisionId;
const spCc = await masters.salesPersonEmailsFor(order.cardCode, div);
const who = `<b>${esc(order.cardName)}</b> (${esc(order.cardCode)})`;
const ref = `<b>${esc(order.refNo)}</b>`;
const empLink = `${base()}/sales-orders?id=${order.id}`;
const custLink = `${base()}/customer-portal#order/${order.id}`;
const rem = remarks ? [`Remarks: ${esc(remarks)}`] : [];
switch (event) {
case 'created': {
if (order.orderType === 'DIRECT') {
return send(await emailsForStep('sales_order:logistics_confirm', div), [], `Direct Sales Order ${order.refNo} generated`,
{ title: 'Direct sales order generated', lines: [`${esc(order.createdByName)} generated direct order ${ref} for ${who}.`, 'Please confirm quantity & dispatch time.'], link: empLink });
}
const to = order.status === 2 ? await emailsForStep('sales_order:logistics_confirm', div) : await emailsForStep('sales_order:review', div);
return send(to, spCc, `Sales Order ${order.refNo} generated`,
{ title: 'New sales order', lines: [`${who} placed sales order ${ref}.`, order.status === 2 ? 'Prices are standard — sent straight to Logistics for confirmation.' : 'Special prices were requested — please review.'], link: empLink });
}
case 'to_logistics':
return send(await emailsForStep('sales_order:logistics_confirm', div), spCc, `Confirm quantity & dispatch date — ${order.refNo}`,
{ title: 'Confirmation required', lines: [`Please confirm quantity & dispatch date for order ${ref} of ${who}.`, ...rem], link: empLink });
case 'qty_confirmed':
return send([...await emailsForStep('sales_order:final_approve', div), ...await emailsForStep('sales_order:review', div)], [], `Logistics confirmed ${order.refNo}`,
{ title: 'Quantity & dispatch time confirmed', lines: [`Logistics confirmed order ${ref} of ${who}.`, ...rem], link: empLink });
case 'approved':
return send([customerEmail], [...spCc, ...await emailsForStep('sales_order:logistics_confirm', div)], `Sales Order ${order.refNo} approved — please make payment`,
{ title: 'Order approved', lines: [`Your sales order ${ref} is approved.`, `Amount payable: <b>₹ ${Number(order.grandTotal).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</b>.`, 'Please log in and submit the payment details.'], link: custLink });
case 'modify':
return send(order.orderType === 'DIRECT' ? await emailsForStep('sales_order:create_direct', div) : [customerEmail],
[...spCc, ...await emailsForStep('sales_order:review', div)], `Modification required — ${order.refNo}`,
{ title: 'Order sent back for modification', lines: [`Order ${ref} of ${who} needs modification.`, ...rem], link: order.orderType === 'DIRECT' ? empLink : custLink });
case 'payment_submitted':
return send(await emailsForStep('sales_order:payment_verify', div), [...spCc, ...await emailsForStep('sales_order:review', div)],
`Payment submitted for ${order.refNo} — verification required`,
{ title: 'Payment submitted', lines: [`${who} submitted payment for order ${ref}.`, `Mode: ${esc(order.paymentMode)} · Ref: ${esc(order.paymentRef)} · Amount: ₹ ${esc(order.paidAmount)}`], link: `${base()}/sales-orders?tab=payments` });
case 'payment_received':
return send([customerEmail], [...spCc, ...await emailsForStep('sales_order:sap_post', div)], `Payment received for ${order.refNo}`,
{ title: 'Payment received', lines: [`Payment for order ${ref} is confirmed. The order is now being processed for invoicing.`], link: custLink });
case 'payment_rejected':
return send([customerEmail], spCc, `Payment not verified for ${order.refNo}`,
{ title: 'Payment could not be verified', lines: [`The payment submitted for order ${ref} could not be verified. Please check and submit again.`, ...rem], link: custLink });
case 'ready_for_sap':
return send(await emailsForStep('sales_order:sap_post', div), [], `Order ${order.refNo} ready to upload in SAP`,
{ title: 'Ready for SAP', lines: [`Order ${ref} of ${who} is ready to be posted to SAP.`], link: empLink });
case 'in_sap':
return send([customerEmail], spCc, `Order ${order.refNo} placed in SAP`,
{ title: 'Order placed', lines: [`Your order ${ref} has been placed (SAP SO ${esc(order.sapDocNum || '')}).`], link: custLink });
case 'cancelled':
return send([customerEmail], [...spCc, ...await emailsForStep('sales_order:review', div)], `Sales Order ${order.refNo} cancelled`,
{ title: 'Order cancelled', lines: [`Order ${ref} of ${who} has been cancelled.`, ...rem], link: custLink });
default: return undefined;
}
} catch (e) { console.warn('[SALES-NOTIFY] orderEvent failed:', e.message); }
}
async function sampleEvent(kind, title, lines, stepKey, divisionId, extraTo = []) {
try {
const to = [...(stepKey ? await emailsForStep(stepKey, divisionId) : []), ...extraTo];
await send(to, [], title, { title, lines, link: `${base()}/sales-samples${kind === 'export' ? '?tab=export' : ''}` });
} catch (e) { console.warn('[SALES-NOTIFY] sampleEvent failed:', e.message); }
}
// "Invoice raised" — to the customer and everyone concerned with the order:
// mapped sales persons, BUH of the category, Logistics, Accounts and (Direct
// orders) the employee who raised it.
async function invoiceEvent(order, inv, lines, { customerEmail = '' } = {}) {
try {
const div = order.divisionId;
const staff = [
...await masters.salesPersonEmailsFor(order.cardCode, div),
...await emailsForStep('sales_order:review', div),
...await emailsForStep('sales_order:logistics_confirm', div),
...await emailsForStep('sales_order:payment_verify', div),
];
if (order.createdByType === 'user' && order.createdBy) {
const u = await query(`SELECT EMAIL FROM dbo.ZCUST_USERS WHERE ID=?`, [parseInt(order.createdBy) || 0]).catch(() => []);
if (u[0] && u[0].EMAIL) staff.push(u[0].EMAIL);
}
const d = inv.dispatch || {};
const fmt = v => (v ? new Date(v).toLocaleDateString('en-GB').replace(/\//g, '-') : '');
const money = v => '₹ ' + Number(v || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const table = `<table style="border-collapse:collapse;font-size:13px;margin:6px 0" cellpadding="5">
<tr style="background:#eef3ff"><th align="left">Item</th><th align="left">Description</th><th align="right">Qty</th></tr>
${lines.map(l => `<tr><td style="border-top:1px solid #e5e7eb">${esc(l.itemCode)}</td><td style="border-top:1px solid #e5e7eb">${esc(l.description)}</td><td align="right" style="border-top:1px solid #e5e7eb">${esc(l.qty)}</td></tr>`).join('')}</table>`;
await send([customerEmail], staff, `Invoice ${inv.invoiceNo} raised for your order ${order.refNo}`, {
title: 'Invoice raised',
lines: [
`An invoice has been raised against sales order <b>${esc(order.refNo)}</b> of <b>${esc(order.cardName)}</b> (${esc(order.cardCode)})${order.custOrderNo ? ` — your ref. <b>${esc(order.custOrderNo)}</b>` : ''}.`,
`Invoice no.: <b>${esc(inv.invoiceNo)}</b> &nbsp; Date: <b>${esc(fmt(inv.invoiceDate))}</b> &nbsp; Amount: <b>${money(inv.total)}</b>`,
table,
d.lrNo || d.transporter || d.vehicle ? `Dispatch: LR/GR/AWB <b>${esc(d.lrNo || '—')}</b>${d.lrDate ? ' dated ' + esc(fmt(d.lrDate)) : ''}${d.transporter ? ' · ' + esc(d.transporter) : ''}${d.vehicle ? ' · vehicle ' + esc(d.vehicle) : ''}${d.ewayBill ? ' · e-Way bill ' + esc(d.ewayBill) : ''}` : '',
'The invoice copy and COA certificates can be downloaded from the order in the Customer Portal.',
].filter(Boolean),
link: `${base()}/customer-portal#order/${order.id}`,
});
} catch (e) { console.warn('[SALES-NOTIFY] invoiceEvent failed:', e.message); }
}
// Customer portal login enabled / password reset by MIPL. The password is a
// one-time password — the customer is forced to change it at first login.
async function customerLoginEvent(kind, { cardCode, cardName, email, password }) {
try {
const link = `${base()}/customer-portal`;
const created = kind === 'created';
await send([email], [], created ? 'Your MIPL Customer Portal login' : 'Your MIPL Customer Portal password was reset', {
title: created ? 'Welcome to the MIPL Customer Portal' : 'Password reset',
lines: [
`Dear ${esc(cardName || cardCode)},`,
created ? 'A login has been created for you on the Mitra Industries Customer Portal, where you can place and track sales orders, make payments, view your ledger and download invoices & COA certificates.'
: 'Your Customer Portal password has been reset by MIPL.',
`Portal: <a href="${esc(link)}">${esc(link)}</a>`,
`Customer code: <b>${esc(cardCode)}</b>`,
password ? `Temporary password: <b>${esc(password)}</b> — you will be asked to set your own password when you log in.` : '',
'If you did not expect this email, please contact your MIPL sales representative.',
].filter(Boolean),
link,
});
} catch (e) { console.warn('[SALES-NOTIFY] customerLoginEvent failed:', e.message); }
}
module.exports = { orderEvent, sampleEvent, emailsForStep, customerLoginEvent, invoiceEvent };
+776
View File
@@ -0,0 +1,776 @@
// services/sales/orders.js
// Sales Order lifecycle — the msale workflow, re-implemented on the app DB:
//
// Customer order (Trade / Institution in-direct)
// 1 Review ──review──▶ 2 Logistics ──logistics_confirm──▶ 4 Approved ──customer pays──▶ 6 / 7
// (1 is skipped — straight to 2 — when no special price was requested)
// 1/2 ──▶ 5 Modify ──customer resubmits──▶ 1/2
// 6 ──payment_verify──▶ 7 (or back to 4 if rejected)
// Direct order (raised by BUH/Tender for a customer, no payment step)
// 2 ──logistics_confirm──▶ 3 ──final_approve──▶ 7 ; 2 ──▶ 5 ──creator resubmits──▶ 2
// Both: 7 ──sap_post──▶ 8 (SAP Sales Order) ──SAP sync / manual close──▶ 9 ; cancel ▶ 10
//
// Every employee action is checked against the actor's Approval Step perms
// AND their sales divisions / visibility scope (see canSee()).
'use strict';
const { query, one, tx, json, round2 } = require('./db');
const { S, statusLabel, customerStatusLabel } = require('./constants');
const masters = require('./masters');
const sap = require('./sap');
const notify = require('./notify');
const { hasStepPerm } = require('../../middleware/auth');
const { runWithSapUser } = require('../sapServiceLayer');
// ── helpers ─────────────────────────────────────────────────────────────────
function stamp(name) {
const d = new Date(); const p = n => String(n).padStart(2, '0');
return `${name} (${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())})`;
}
function appendRemark(old, name, text) {
const t = String(text || '').trim();
if (!t) return old || null;
return (old ? old + '\n\n' : '') + stamp(name) + '\n' + t;
}
function refNo(id, date) {
const d = date ? new Date(date) : new Date();
return `${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getFullYear()).slice(-2)}-${String(id).padStart(5, '0')}`;
}
function isEmp(actor) { return actor && actor.type === 'user'; }
function can(actor, key, perm) { return isEmp(actor) && hasStepPerm(actor.user, `sales_order:${key}`, perm); }
function fail(msg, code = 400) { const e = new Error(msg); e.status = code; throw e; }
function orderFromRow(r) {
if (!r) return null;
return {
id: r.ID, refNo: r.REF_NO, company: r.COMPANY, soType: r.SO_TYPE, orderType: r.ORDER_TYPE || (r.SO_TYPE === 1 ? 'TRADE' : 'IN-DIRECT'),
cardCode: r.CARD_CODE, cardName: r.CARD_NAME, divisionId: r.DIVISION_ID, custOrderNo: r.CUST_ORDER_NO || '',
orderDate: r.ORDER_DATE, deliveryDate: r.DELIVERY_DATE, contactPerson: r.CONTACT_PERSON || '', salesEmployee: r.SALES_EMPLOYEE || '',
shipToCode: r.SHIP_TO_CODE || '', shipToText: r.SHIP_TO_TEXT || '', shipState: r.SHIP_STATE || '', billToCode: r.BILL_TO_CODE || '', billToText: r.BILL_TO_TEXT || '',
taxCode: r.TAX_CODE || '', igstRate: Number(r.IGST_RATE), cgstRate: Number(r.CGST_RATE), sgstRate: Number(r.SGST_RATE), tcsRate: Number(r.TCS_RATE),
igstVal: Number(r.IGST_VAL), cgstVal: Number(r.CGST_VAL), sgstVal: Number(r.SGST_VAL), tcsVal: Number(r.TCS_VAL),
subTotal: Number(r.SUB_TOTAL), grandTotal: Number(r.GRAND_TOTAL), creditDays: r.CREDIT_DAYS,
paymentMode: r.PAYMENT_MODE || '', paymentRef: r.PAYMENT_REF || '', paymentDate: r.PAYMENT_DATE, paymentDetail: r.PAYMENT_DETAIL || '',
paidAmount: r.PAID_AMOUNT == null ? null : Number(r.PAID_AMOUNT), tdsVal: Number(r.TDS_VAL) || 0, walletUsed: r.WALLET_USED == null ? null : Number(r.WALLET_USED),
rebate: r.REBATE == null ? null : Number(r.REBATE), paidStatus: r.PAID_STATUS || '',
customerRemarks: r.CUSTOMER_REMARKS || '', employeeRemarks: r.EMPLOYEE_REMARKS || '', internalRemarks: r.INTERNAL_REMARKS || '',
status: r.STATUS, statusLabel: statusLabel(r.STATUS, r.ORDER_TYPE), customerStatusLabel: customerStatusLabel(r.STATUS),
isMultiConsignee: !!r.IS_MULTI_CONSIGNEE, needsReview: !!r.NEEDS_REVIEW,
sapDocEntry: r.SAP_DOC_ENTRY, sapDocNum: r.SAP_DOC_NUM, sapOrderDate: r.SAP_ORDER_DATE, sapPostedAt: r.SAP_POSTED_AT, sapPostedBy: r.SAP_POSTED_BY || '',
sapError: r.SAP_ERROR || '', invoices: json(r.INVOICE_JSON, null), lastSapSync: r.LAST_SAP_SYNC,
createdByType: r.CREATED_BY_TYPE, createdBy: r.CREATED_BY, createdByName: r.CREATED_BY_NAME || '', createdAt: r.CREATED_AT,
updatedAt: r.UPDATED_AT, updatedByName: r.UPDATED_BY_NAME || '', legacy: !!r.LEGACY,
orderQty: r.ORDER_QTY != null ? Number(r.ORDER_QTY) : undefined,
};
}
function itemFromRow(r) {
return { id: r.ID, lineNo: r.LINE_NO, productId: r.PRODUCT_ID, itemCode: r.ITEM_CODE, displayCode: r.DISPLAY_CODE || r.ITEM_CODE, description: r.DESCRIPTION || '',
qty: Number(r.QTY), nop: r.NOP, price: Number(r.PRICE), specialPrice: Number(r.SPECIAL_PRICE) || 0, commission: Number(r.COMMISSION) || 0,
hsn: r.HSN || '', lineTotal: Number(r.LINE_TOTAL) };
}
// ── Tax & pricing (msale rules) ─────────────────────────────────────────────
// Ship-to in the company's own state → CGST+SGST, otherwise IGST. TCS on
// (base + GST) when a TCS rate is configured.
function taxFor(settings, shipState) {
const intra = String(shipState || '').toUpperCase() === String(settings.companyState || '').toUpperCase();
return intra
? { taxCode: settings.intraTaxCode, igst: 0, cgst: Number(settings.cgstRate) || 0, sgst: Number(settings.sgstRate) || 0, tcs: Number(settings.tcsRate) || 0 }
: { taxCode: settings.interTaxCode, igst: Number(settings.igstRate) || 0, cgst: 0, sgst: 0, tcs: Number(settings.tcsRate) || 0 };
}
function totals(items, tax) {
const sub = round2(items.reduce((a, it) => a + it.lineTotal, 0));
const igst = round2(sub * tax.igst / 100), cgst = round2(sub * tax.cgst / 100), sgst = round2(sub * tax.sgst / 100);
const tcs = tax.tcs > 0 ? round2((sub + igst + cgst + sgst) * tax.tcs / 100) : 0;
return { sub, igst, cgst, sgst, tcs, grand: round2(sub + igst + cgst + sgst + tcs) };
}
// Re-price every line from the product master (client prices are never
// trusted). specialPrice > 0 and ≠ list price ⇒ the order needs review;
// the line is billed at the special price (that's what msale posted to SAP).
async function priceLines(lines, divisionId, { allowCommission = false } = {}) {
if (!Array.isArray(lines) || !lines.length) fail('Please add at least one product');
const out = []; let needsReview = false;
for (const [i, l] of lines.entries()) {
const qty = Number(l.qty);
if (!(qty > 0)) fail(`Line ${i + 1}: quantity must be greater than 0`);
const p = await masters.resolveProduct(l.key || (l.productId ? `P:${l.productId}` : `S:${l.itemCode}`), divisionId);
if (!p) fail(`Line ${i + 1}: product not found or inactive`);
if (Number(p.divisionId) !== Number(divisionId)) fail(`Line ${i + 1}: ${p.displayCode} does not belong to the selected product category`);
const sp = Math.max(0, round2(l.specialPrice));
if (sp > 0 && Math.abs(sp - p.price) > 0.0001) needsReview = true;
const eff = sp > 0 ? sp : p.price;
if (!(eff > 0)) fail(`Line ${i + 1}: ${p.displayCode} has no price — enter a special price or ask MIPL to set one`);
out.push({ productId: p.id, itemCode: p.itemCode, displayCode: p.displayCode, description: p.description, qty, nop: Math.max(1, Math.ceil(qty / (p.packSize || 1))),
price: p.price, specialPrice: sp, commission: allowCommission ? Math.max(0, Number(l.commission) || 0) : 0, hsn: p.hsn, lineTotal: round2(qty * eff) });
}
return { items: out, needsReview };
}
// Validate ship/bill addresses against SAP and build their display text.
async function resolveAddresses(company, cardCode, shipToCode, billToCode) {
const c = await sap.getCustomer(company, cardCode);
if (!c) fail('Customer not found in SAP');
if (c.frozen) fail('This customer is inactive (frozen) in SAP');
const ship = c.shipTo.find(a => a.code === shipToCode);
const bill = c.billTo.find(a => a.code === billToCode);
if (!ship) fail('Please select a valid Ship-To address');
if (!bill) fail('Please select a valid Bill-To address');
return { customer: c, ship, bill };
}
// ── Visibility ──────────────────────────────────────────────────────────────
// Employee: needs sales_order:view, the order's division in their profile,
// and their scope (mapped customers / own orders). Customer: own orders only.
async function scopeFor(actor) {
if (!isEmp(actor)) return null;
const prof = await masters.getProfile(actor.user);
let cards = null;
if (prof.scope === 'mapped') cards = await masters.mappedCardCodesForUser(actor.user.id, actor.user.email || actor.email);
return { prof, cards };
}
async function canSee(actor, o) {
if (!o || o.status === S.DELETED) return false;
if (actor.type === 'customer') return o.cardCode === actor.cardCode;
if (!can(actor, 'view', 'view')) return false;
const { prof, cards } = await scopeFor(actor);
if (!prof.divisions.includes(Number(o.divisionId))) return false;
if (prof.scope === 'mapped' && !(cards || []).includes(o.cardCode)) return false;
if (prof.scope === 'own' && !(o.createdByType === 'user' && String(o.createdBy) === String(actor.user.id))) return false;
return true;
}
// ── Reads ───────────────────────────────────────────────────────────────────
async function getOrderRaw(id, runner) {
return orderFromRow(await one(`SELECT * FROM dbo.ZSO_ORDERS WHERE ID=?`, [parseInt(id)], runner));
}
async function getItems(id, runner) {
return (await query(`SELECT * FROM dbo.ZSO_ORDER_ITEMS WHERE ORDER_ID=? AND ACTIVE=1 ORDER BY LINE_NO`, [parseInt(id)], runner)).map(itemFromRow);
}
async function getConsignees(id) {
return (await query(`SELECT * FROM dbo.ZSO_ORDER_CONSIGNEES WHERE ORDER_ID=? AND ACTIVE=1 ORDER BY ID`, [parseInt(id)]))
.map(r => ({ id: r.ID, shipToCode: r.SHIP_TO_CODE || '', shipToText: r.SHIP_TO_TEXT || '', seName: r.SE_NAME || '', seEmail: r.SE_EMAIL || '',
items: json(r.ITEMS_JSON, []), sapDocEntry: r.SAP_DOC_ENTRY, sapDocNum: r.SAP_DOC_NUM }));
}
async function getDocs(entity, id) {
return (await query(`SELECT * FROM dbo.ZSO_DOCS WHERE ENTITY=? AND ENTITY_ID=? AND ACTIVE=1 ORDER BY ID DESC`, [entity, parseInt(id)]))
.map(r => ({ id: r.ID, docType: r.DOC_TYPE, title: r.TITLE || '', fileName: r.FILE_NAME, origName: r.ORIG_NAME || '', createdAt: r.CREATED_AT, createdByName: r.CREATED_BY_NAME || '' }));
}
async function getLog(entity, id, { includeInternal = true } = {}) {
const rows = await query(`SELECT * FROM dbo.ZSO_LOG WHERE ENTITY=? AND ENTITY_ID=? ORDER BY ID`, [entity, parseInt(id)]);
return rows.map(r => ({ action: r.ACTION, fromStatus: r.FROM_STATUS, toStatus: r.TO_STATUS, remarks: includeInternal || r.ACTION !== 'internal_note' ? (r.REMARKS || '') : '',
actorType: r.ACTOR_TYPE, actorName: r.ACTOR_NAME || '', at: r.AT })).filter(l => includeInternal || l.action !== 'internal_note');
}
async function log(entity, id, action, from, to, remarks, actor, runner) {
await query(`INSERT INTO dbo.ZSO_LOG (ENTITY,ENTITY_ID,ACTION,FROM_STATUS,TO_STATUS,REMARKS,ACTOR_TYPE,ACTOR,ACTOR_NAME) VALUES (?,?,?,?,?,?,?,?,?)`,
[entity, parseInt(id), action, from, to, remarks || null, actor.type, String(actor.type === 'customer' ? actor.cardCode : actor.user.id), actor.name], runner);
}
// Full order for a viewer (customer gets no internal remarks).
async function getOrder(id, actor) {
const o = await getOrderRaw(id);
if (!o || !(await canSee(actor, o))) fail('Order not found', 404);
o.items = await getItems(id);
o.consignees = await getConsignees(id);
o.docs = await getDocs('order', id);
o.log = await getLog('order', id, { includeInternal: isEmp(actor) });
o.payments = await walletTxnsForOrder(id);
if (!isEmp(actor)) { delete o.internalRemarks; o.items.forEach(i => { delete i.commission; }); }
else o.actions = await availableActions(actor, o);
if (actor.type === 'customer' || o.status === S.APPROVED) o.wallet = await walletSummary(o.cardCode);
return o;
}
// List with filters; employee lists are scoped (division/scope), customers see their own.
async function listOrders(actor, f = {}) {
const w = ['o.STATUS<>11']; const p = [];
if (actor.type === 'customer') { w.push('o.CARD_CODE=?'); p.push(actor.cardCode); }
else {
if (!can(actor, 'view', 'view')) fail('You are not assigned "view" on Sales Orders', 403);
const { prof, cards } = await scopeFor(actor);
if (!prof.divisions.length) return [];
w.push(`o.DIVISION_ID IN (${prof.divisions.map(Number).join(',')})`);
if (prof.scope === 'mapped') { if (!cards.length) return []; w.push(`o.CARD_CODE IN (${cards.map(() => '?').join(',')})`); p.push(...cards); }
if (prof.scope === 'own') { w.push(`o.CREATED_BY_TYPE='user' AND o.CREATED_BY=?`); p.push(String(actor.user.id)); }
}
if (f.soType) { w.push('o.SO_TYPE=?'); p.push(parseInt(f.soType)); }
if (f.orderType) { w.push('o.ORDER_TYPE=?'); p.push(f.orderType); }
if (f.divisionId) { w.push('o.DIVISION_ID=?'); p.push(parseInt(f.divisionId)); }
if (f.statuses) { const st = String(f.statuses).split(',').map(Number).filter(Boolean); if (st.length) w.push(`o.STATUS IN (${st.join(',')})`); }
if (f.cardCode) { w.push('o.CARD_CODE=?'); p.push(f.cardCode); }
if (f.from) { w.push('o.CREATED_AT>=?'); p.push(f.from); }
if (f.to) { w.push('o.CREATED_AT<DATEADD(day,1,CAST(? AS date))'); p.push(f.to); }
if (f.search) { w.push('(o.REF_NO LIKE ? OR o.CARD_NAME LIKE ? OR o.CARD_CODE LIKE ? OR o.CUST_ORDER_NO LIKE ? OR CAST(o.SAP_DOC_NUM AS nvarchar(20)) LIKE ?)'); const s = `%${f.search}%`; p.push(s, s, s, s, s); }
const top = Math.min(parseInt(f.limit) || 1000, 5000);
const rows = await query(`SELECT TOP ${top} o.*, (SELECT SUM(QTY) FROM dbo.ZSO_ORDER_ITEMS i WHERE i.ORDER_ID=o.ID AND i.ACTIVE=1) AS ORDER_QTY
FROM dbo.ZSO_ORDERS o WHERE ${w.join(' AND ')} ORDER BY o.ID DESC`, p);
return rows.map(orderFromRow).map(o => { if (!isEmp(actor)) delete o.internalRemarks; return o; });
}
// ── Customer's own order reference ("Your order ref. no.") ──────────────────
// Auto-suggested per customer as <CARDCODE>/<FY>/<0001>, Indian financial
// year (Apr–Mar, e.g. 26-27). The customer may type their own instead.
function finYear(d = new Date()) {
const y = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
return `${String(y).slice(-2)}-${String(y + 1).slice(-2)}`;
}
async function nextCustOrderNo(cardCode, runner) {
const prefix = `${cardCode}/${finYear()}/`;
const rows = await query(`SELECT CUST_ORDER_NO FROM dbo.ZSO_ORDERS WITH (UPDLOCK, HOLDLOCK) WHERE CARD_CODE=? AND CUST_ORDER_NO LIKE ?`,
[cardCode, prefix.replace(/[\[%_]/g, '[$&]') + '%'], runner);
const max = rows.reduce((m, r) => Math.max(m, parseInt(String(r.CUST_ORDER_NO).slice(prefix.length)) || 0), 0);
return prefix + String(max + 1).padStart(4, '0');
}
// The same customer may not reuse a reference on another live order.
async function assertCustOrderNoFree(cardCode, no, exceptId, runner) {
const r = await one(`SELECT TOP 1 REF_NO FROM dbo.ZSO_ORDERS WHERE CARD_CODE=? AND CUST_ORDER_NO=? AND STATUS NOT IN (10,11) AND ID<>?`,
[cardCode, no, parseInt(exceptId) || 0], runner);
if (r) fail(`Order ref. no. "${no}" is already used on your order ${r.REF_NO} — please use a different one`);
}
// ── Create ──────────────────────────────────────────────────────────────────
// data: { soType, divisionId, cardCode (employee only), shipToCode, billToCode, custOrderNo, orderDate, deliveryDate,
// contactPerson, salesEmployee, remarks, lines:[{key,qty,specialPrice,commission}], consignees:[{shipToCode,seName,seEmail,items:[{itemCode,qty}]}],
// creditDays, paymentDetail }
async function createOrder(actor, data) {
const settings = await masters.getSettings();
const direct = isEmp(actor);
if (direct && !can(actor, 'create_direct', 'add')) fail('You are not assigned "add" on Direct Order creation', 403);
const soType = direct ? 2 : (parseInt(data.soType) === 2 ? 2 : 1);
const divisionId = parseInt(data.divisionId);
const division = await masters.getDivision(divisionId);
if (!division) fail('Please select a product category');
if (!division.active) fail(`${division.name} is currently disabled — new orders cannot be placed in this product category`);
if (direct) { const prof = await masters.getProfile(actor.user); if (!prof.divisions.includes(divisionId)) fail('This product category is not in your divisions', 403); }
const cardCode = direct ? String(data.cardCode || '').trim() : actor.cardCode;
if (!cardCode) fail('Please select a customer');
// Customers: blank or the untouched suggestion → numbered inside the insert
// transaction (so two simultaneous orders can't take the same number).
const autoRef = !direct && (!String(data.custOrderNo || '').trim() || !!data.custOrderNoAuto);
if (!autoRef && !String(data.custOrderNo || '').trim()) fail('Customer reference order no. is required');
if (!data.deliveryDate) fail('Committed Dispatch Date is required');
const { customer, ship, bill } = await resolveAddresses(settings.company, cardCode, data.shipToCode, data.billToCode);
const { items, needsReview } = await priceLines(data.lines, divisionId, { allowCommission: direct });
const tax = taxFor(settings, ship.state);
const t = totals(items, tax);
const consignees = direct && data.isMultiConsignee ? validateConsignees(data.consignees, customer, items) : [];
const status = direct ? S.LOGISTICS : (needsReview ? S.CREATED : S.LOGISTICS);
const orderType = direct ? 'DIRECT' : (soType === 2 ? 'IN-DIRECT' : null);
const remarks = appendRemark(null, actor.name, data.remarks);
const id = await tx(async (r) => {
const custOrderNo = autoRef ? await nextCustOrderNo(cardCode, r) : String(data.custOrderNo).trim();
if (!direct) await assertCustOrderNoFree(cardCode, custOrderNo, 0, r);
const row = await one(`INSERT INTO dbo.ZSO_ORDERS (COMPANY,SO_TYPE,ORDER_TYPE,CARD_CODE,CARD_NAME,DIVISION_ID,CUST_ORDER_NO,ORDER_DATE,DELIVERY_DATE,CONTACT_PERSON,
SALES_EMPLOYEE,SHIP_TO_CODE,SHIP_TO_TEXT,SHIP_STATE,BILL_TO_CODE,BILL_TO_TEXT,TAX_CODE,IGST_RATE,CGST_RATE,SGST_RATE,TCS_RATE,IGST_VAL,CGST_VAL,SGST_VAL,TCS_VAL,
SUB_TOTAL,GRAND_TOTAL,CREDIT_DAYS,PAYMENT_DETAIL,CUSTOMER_REMARKS,STATUS,IS_MULTI_CONSIGNEE,NEEDS_REVIEW,CREATED_BY_TYPE,CREATED_BY,CREATED_BY_NAME)
OUTPUT INSERTED.ID, INSERTED.CREATED_AT VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[settings.company, soType, orderType, cardCode, customer.cardName, divisionId, custOrderNo, data.orderDate || null, data.deliveryDate,
data.contactPerson || customer.contactPerson || '', data.salesEmployee || customer.slpName || '', ship.code, ship.text, ship.state, bill.code, bill.text,
tax.taxCode, tax.igst, tax.cgst, tax.sgst, tax.tcs, t.igst, t.cgst, t.sgst, t.tcs, t.sub, t.grand,
direct ? (parseInt(data.creditDays) || null) : null, direct ? (data.paymentDetail || null) : null, remarks, status, consignees.length > 0, needsReview,
actor.type, String(actor.type === 'customer' ? actor.cardCode : actor.user.id), actor.name], r);
const newId = row.ID;
await query(`UPDATE dbo.ZSO_ORDERS SET REF_NO=? WHERE ID=?`, [refNo(newId, row.CREATED_AT), newId], r);
await insertItems(newId, items, r);
for (const c of consignees) {
await query(`INSERT INTO dbo.ZSO_ORDER_CONSIGNEES (ORDER_ID,SHIP_TO_CODE,SHIP_TO_TEXT,SE_NAME,SE_EMAIL,ITEMS_JSON) VALUES (?,?,?,?,?,?)`,
[newId, c.shipToCode, c.shipToText, c.seName, c.seEmail, JSON.stringify(c.items)], r);
}
await log('order', newId, 'created', null, status, data.remarks, actor, r);
return newId;
});
const o = await getOrderRaw(id);
notify.orderEvent('created', o);
return o;
}
async function insertItems(orderId, items, r) {
for (const [i, it] of items.entries()) {
await query(`INSERT INTO dbo.ZSO_ORDER_ITEMS (ORDER_ID,LINE_NO,PRODUCT_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,QTY,NOP,PRICE,SPECIAL_PRICE,COMMISSION,HSN,LINE_TOTAL)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, [orderId, i + 1, it.productId || null, it.itemCode, it.displayCode, it.description, it.qty, it.nop, it.price, it.specialPrice,
it.commission, it.hsn, it.lineTotal], r);
}
}
// Multi-consignee (Direct orders): each consignee is one of the customer's
// ship-to addresses with an allocation of the order's items; allocations
// must add up exactly to the ordered qty per item.
function validateConsignees(list, customer, items) {
if (!Array.isArray(list) || !list.length) fail('Add at least one consignee or untick multi-consignee');
const alloc = {};
const out = list.map((c, i) => {
const ship = customer.shipTo.find(a => a.code === c.shipToCode);
if (!ship) fail(`Consignee ${i + 1}: select a valid ship-to address`);
const its = (c.items || []).filter(x => Number(x.qty) > 0).map(x => ({ itemCode: x.itemCode, qty: Number(x.qty) }));
its.forEach(x => { alloc[x.itemCode] = (alloc[x.itemCode] || 0) + x.qty; });
return { shipToCode: ship.code, shipToText: ship.text, seName: c.seName || '', seEmail: c.seEmail || '', items: its };
});
for (const it of items) {
if (Math.abs((alloc[it.itemCode] || 0) - it.qty) > 0.0001) fail(`Consignee allocation for ${it.displayCode} (${alloc[it.itemCode] || 0}) must equal the ordered qty (${it.qty})`);
}
return out;
}
// ── Modify & resubmit (status 5) ────────────────────────────────────────────
// Customer for its own orders; for Direct orders, a user with create_direct:edit.
async function resubmit(actor, id, data) {
const settings = await masters.getSettings();
const o = await getOrderRaw(id);
if (!o || !(await canSee(actor, o))) fail('Order not found', 404);
if (o.status !== S.MODIFY) fail('Only orders sent back for modification can be resubmitted');
const direct = o.orderType === 'DIRECT';
if (direct ? !can(actor, 'create_direct', 'edit') : actor.type !== 'customer') fail('You cannot modify this order', 403);
const shipTo = data.shipToCode || o.shipToCode, billTo = data.billToCode || o.billToCode;
const { customer, ship, bill } = await resolveAddresses(settings.company, o.cardCode, shipTo, billTo);
const { items, needsReview } = await priceLines(data.lines, o.divisionId, { allowCommission: direct });
const tax = taxFor(settings, ship.state);
const t = totals(items, tax);
const consignees = direct && o.isMultiConsignee ? validateConsignees(data.consignees, customer, items) : null;
const status = direct ? S.LOGISTICS : (needsReview ? S.CREATED : S.LOGISTICS);
if (!direct && String(data.custOrderNo || '').trim() && String(data.custOrderNo).trim() !== o.custOrderNo) await assertCustOrderNoFree(o.cardCode, String(data.custOrderNo).trim(), o.id);
await tx(async (r) => {
await query(`UPDATE dbo.ZSO_ORDERS SET SHIP_TO_CODE=?, SHIP_TO_TEXT=?, SHIP_STATE=?, BILL_TO_CODE=?, BILL_TO_TEXT=?, TAX_CODE=?, IGST_RATE=?, CGST_RATE=?, SGST_RATE=?, TCS_RATE=?,
IGST_VAL=?, CGST_VAL=?, SGST_VAL=?, TCS_VAL=?, SUB_TOTAL=?, GRAND_TOTAL=?, DELIVERY_DATE=COALESCE(?,DELIVERY_DATE), CUST_ORDER_NO=COALESCE(?,CUST_ORDER_NO),
CUSTOMER_REMARKS=?, STATUS=?, NEEDS_REVIEW=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? WHERE ID=? AND STATUS=5`,
[ship.code, ship.text, ship.state, bill.code, bill.text, tax.taxCode, tax.igst, tax.cgst, tax.sgst, tax.tcs, t.igst, t.cgst, t.sgst, t.tcs, t.sub, t.grand,
data.deliveryDate || null, data.custOrderNo || null, appendRemark(o.customerRemarks, actor.name, data.remarks), status, needsReview, actor.name, o.id], r);
await query(`UPDATE dbo.ZSO_ORDER_ITEMS SET ACTIVE=0 WHERE ORDER_ID=?`, [o.id], r);
await insertItems(o.id, items, r);
if (consignees) {
await query(`UPDATE dbo.ZSO_ORDER_CONSIGNEES SET ACTIVE=0 WHERE ORDER_ID=?`, [o.id], r);
for (const c of consignees) await query(`INSERT INTO dbo.ZSO_ORDER_CONSIGNEES (ORDER_ID,SHIP_TO_CODE,SHIP_TO_TEXT,SE_NAME,SE_EMAIL,ITEMS_JSON) VALUES (?,?,?,?,?,?)`,
[o.id, c.shipToCode, c.shipToText, c.seName, c.seEmail, JSON.stringify(c.items)], r);
}
await log('order', o.id, 'resubmitted', S.MODIFY, status, data.remarks, actor, r);
});
const fresh = await getOrderRaw(o.id);
notify.orderEvent(status === S.LOGISTICS ? 'to_logistics' : 'created', fresh);
return fresh;
}
// ── Employee workflow actions ───────────────────────────────────────────────
// Each: which status it applies to, which step/perm it needs, where it goes,
// and which remark fields are mandatory (msale validation rules).
const ACTIONS = {
send_to_logistics: { from: [S.CREATED], step: 'review', perm: 'approve', to: () => S.LOGISTICS, label: 'Send to Logistics for confirmation', need: [] },
review_modify: { from: [S.CREATED], step: 'review', perm: 'approve', to: () => S.MODIFY, label: 'Send back to customer for modification', need: ['employeeRemarks'] },
logistics_confirm: { from: [S.LOGISTICS], step: 'logistics_confirm', perm: 'approve', to: o => (o.orderType === 'DIRECT' ? S.QTY_CONFIRMED : S.APPROVED), label: 'Confirm quantity & dispatch time', need: ['internalRemarks'] },
logistics_modify: { from: [S.LOGISTICS], step: 'logistics_confirm', perm: 'approve', to: () => S.MODIFY, label: 'Send back for modification', need: ['employeeRemarks'] },
final_approve: { from: [S.QTY_CONFIRMED], step: 'final_approve', perm: 'approve', to: o => (o.orderType === 'DIRECT' ? S.READY_FOR_SAP : S.APPROVED), label: 'Approve (order to be placed)', need: [] },
payment_approve: { from: [S.PAYMENT_SUBMITTED], step: 'payment_verify', perm: 'approve', to: () => S.READY_FOR_SAP, label: 'Payment received', need: [] },
payment_reject: { from: [S.PAYMENT_SUBMITTED], step: 'payment_verify', perm: 'approve', to: () => S.APPROVED, label: 'Reject payment (back to customer)', need: ['employeeRemarks'] },
close: { from: [S.IN_SAP], step: 'sap_post', perm: 'approve', to: () => S.CLOSED, label: 'Close order', need: ['internalRemarks'] },
cancel: { from: [S.CREATED, S.LOGISTICS, S.QTY_CONFIRMED, S.APPROVED, S.MODIFY, S.PAYMENT_SUBMITTED, S.READY_FOR_SAP], step: 'cancel', perm: 'delete', to: () => S.CANCELLED, label: 'Cancel order', need: ['employeeRemarks', 'internalRemarks'] },
};
async function availableActions(actor, o) {
if (!isEmp(actor)) return [];
const out = Object.entries(ACTIONS).filter(([, a]) => a.from.includes(o.status) && can(actor, a.step, a.perm))
.map(([k, a]) => ({ key: k, label: a.label, need: a.need, to: a.to(o) }));
if (o.status === S.READY_FOR_SAP && can(actor, 'sap_post', 'approve')) out.unshift({ key: 'sap_post', label: 'Post to SAP', need: [], to: S.IN_SAP });
if (o.status === S.MODIFY && o.orderType === 'DIRECT' && can(actor, 'create_direct', 'edit')) out.unshift({ key: 'resubmit', label: 'Modify & resubmit', need: [], to: S.LOGISTICS });
return out;
}
async function act(actor, id, action, data = {}) {
if (action === 'sap_post') return postToSap(actor, id);
const a = ACTIONS[action];
if (!a) fail('Unknown action');
const o = await getOrderRaw(id);
if (!o || !(await canSee(actor, o))) fail('Order not found', 404);
if (!can(actor, a.step, a.perm)) fail(`You are not assigned "${a.perm}" on sales_order:${a.step}`, 403);
if (!a.from.includes(o.status)) fail(`This action is not allowed while the order is "${statusLabel(o.status, o.orderType)}"`);
for (const f of a.need) if (!String(data[f] || '').trim()) fail(`${f === 'employeeRemarks' ? 'Remarks (visible to customer)' : 'Internal remarks'} are required`);
const to = a.to(o);
await tx(async (r) => {
const sets = ['STATUS=?', 'EMPLOYEE_REMARKS=?', 'INTERNAL_REMARKS=?', 'UPDATED_AT=SYSDATETIME()', 'UPDATED_BY_NAME=?'];
const vals = [to, appendRemark(o.employeeRemarks, actor.name, data.employeeRemarks), appendRemark(o.internalRemarks, actor.name, data.internalRemarks), actor.name];
if (action === 'final_approve' && o.orderType === 'DIRECT' && data.creditDays != null) { sets.push('CREDIT_DAYS=?', 'PAYMENT_DETAIL=?'); vals.push(parseInt(data.creditDays) || null, data.paymentDetail || null); }
const upd = await query(`UPDATE dbo.ZSO_ORDERS SET ${sets.join(', ')} OUTPUT INSERTED.ID WHERE ID=? AND STATUS=?`, [...vals, o.id, o.status], r);
if (!upd.length) fail('The order was changed by someone else — please reload', 409);
if (action === 'payment_approve') await settlePayment(o, 'approve', actor, r);
if (action === 'payment_reject') await settlePayment(o, 'reject', actor, r);
if (action === 'cancel') await reverseOrderDebit(o, actor, r);
await log('order', o.id, action, o.status, to, [data.employeeRemarks, data.internalRemarks].filter(Boolean).join(' | '), actor, r);
});
const fresh = await getOrderRaw(o.id);
const email = await customerEmail(fresh);
const ev = { send_to_logistics: 'to_logistics', review_modify: 'modify', logistics_modify: 'modify', payment_approve: 'payment_received', payment_reject: 'payment_rejected', cancel: 'cancelled' }[action]
|| (to === S.APPROVED ? 'approved' : to === S.QTY_CONFIRMED ? 'qty_confirmed' : to === S.READY_FOR_SAP ? 'ready_for_sap' : null);
if (ev) notify.orderEvent(ev, fresh, { customerEmail: email, remarks: data.employeeRemarks });
if (to === S.READY_FOR_SAP) maybeAutoPost(fresh.id);
return fresh;
}
async function customerEmail(o) {
const acc = await masters.getCustomerAccount(o.cardCode);
if (acc && acc.email) return acc.email;
try { const c = await sap.getCustomer(o.company, o.cardCode); return (c && c.email) || ''; } catch (_e) { return ''; }
}
// Customer-side cancel (allowed until payment is submitted, as in msale).
async function customerCancel(actor, id, remarks) {
const o = await getOrderRaw(id);
if (!o || o.cardCode !== actor.cardCode) fail('Order not found', 404);
if (![S.CREATED, S.LOGISTICS, S.QTY_CONFIRMED, S.APPROVED, S.MODIFY].includes(o.status)) fail('This order can no longer be cancelled online — please contact MIPL');
if (!String(remarks || '').trim()) fail('Please enter a reason for cancelling');
await tx(async (r) => {
const upd = await query(`UPDATE dbo.ZSO_ORDERS SET STATUS=10, CUSTOMER_REMARKS=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? OUTPUT INSERTED.ID WHERE ID=? AND STATUS=?`,
[appendRemark(o.customerRemarks, actor.name, remarks), actor.name, o.id, o.status], r);
if (!upd.length) fail('The order was changed — please reload', 409);
await reverseOrderDebit(o, actor, r);
await log('order', o.id, 'cancel', o.status, S.CANCELLED, remarks, actor, r);
});
const fresh = await getOrderRaw(o.id);
notify.orderEvent('cancelled', fresh, { customerEmail: await customerEmail(fresh), remarks });
return fresh;
}
// ── Wallet ──────────────────────────────────────────────────────────────────
// The wallet is a pure ledger: balance = approved credits − approved debits.
// No separate balance column that can drift (msale's customer_wallet did).
// An order's debit is (grand total − TDS deducted by the customer).
function txnFromRow(r) {
return { id: r.ID, cardCode: r.CARD_CODE, type: r.TXN_TYPE, amount: Number(r.AMOUNT), tds: Number(r.TDS) || 0, status: r.STATUS, refType: r.REF_TYPE, orderId: r.ORDER_ID,
paymentMode: r.PAYMENT_MODE || '', paymentRef: r.PAYMENT_REF || '', paymentDate: r.PAYMENT_DATE, paymentDetail: r.PAYMENT_DETAIL || '', remarks: r.REMARKS || '',
bankApiId: r.BANK_API_ID || '', docId: r.DOC_ID, createdAt: r.CREATED_AT, createdByName: r.CREATED_BY_NAME || '', decidedAt: r.DECIDED_AT, decidedByName: r.DECIDED_BY_NAME || '',
refNo: r.REF_NO || null };
}
async function walletBalance(cardCode, runner) {
const r = await one(`SELECT COALESCE(SUM(CASE WHEN TXN_TYPE='credit' THEN AMOUNT ELSE -AMOUNT END),0) AS BAL FROM dbo.ZSO_WALLET_TXN WITH (UPDLOCK, HOLDLOCK)
WHERE CARD_CODE=? AND STATUS='approved'`, [cardCode], runner);
return round2(r ? r.BAL : 0);
}
async function walletSummary(cardCode) {
const bal = await walletBalance(cardCode);
const pend = await one(`SELECT COALESCE(SUM(CASE WHEN TXN_TYPE='credit' THEN AMOUNT ELSE 0 END),0) AS PC, COALESCE(SUM(CASE WHEN TXN_TYPE='debit' THEN AMOUNT ELSE 0 END),0) AS PD
FROM dbo.ZSO_WALLET_TXN WHERE CARD_CODE=? AND STATUS='pending'`, [cardCode]);
const acc = await masters.getCustomerAccount(cardCode);
return { balance: bal, pendingCredits: round2(pend.PC), pendingDebits: round2(pend.PD), creditLimit: acc ? acc.creditLimit : 0 };
}
async function walletTxnsForOrder(orderId) {
return (await query(`SELECT * FROM dbo.ZSO_WALLET_TXN WHERE ORDER_ID=? ORDER BY ID`, [parseInt(orderId)])).map(txnFromRow);
}
async function ledger(cardCode, { from, to } = {}) {
const w = ['t.CARD_CODE=?']; const p = [cardCode];
if (from) { w.push('t.CREATED_AT>=?'); p.push(from); }
if (to) { w.push('t.CREATED_AT<DATEADD(day,1,CAST(? AS date))'); p.push(to); }
const rows = await query(`SELECT t.*, o.REF_NO FROM dbo.ZSO_WALLET_TXN t LEFT JOIN dbo.ZSO_ORDERS o ON o.ID=t.ORDER_ID WHERE ${w.join(' AND ')} ORDER BY t.ID`, p);
let run = 0;
if (from) {
const ob = await one(`SELECT COALESCE(SUM(CASE WHEN TXN_TYPE='credit' THEN AMOUNT ELSE -AMOUNT END),0) AS B FROM dbo.ZSO_WALLET_TXN WHERE CARD_CODE=? AND STATUS='approved' AND CREATED_AT<?`, [cardCode, from]);
run = round2(ob.B);
}
const opening = run;
const lines = rows.map(txnFromRow).map(t => {
if (t.status === 'approved') run = round2(run + (t.type === 'credit' ? t.amount : -t.amount));
return { ...t, runningBalance: run };
});
return { opening, closing: run, lines };
}
async function duplicatePaymentRef(paymentRef, cardCode, runner) {
const r = await one(`SELECT TOP 1 ID FROM dbo.ZSO_WALLET_TXN WHERE TXN_TYPE='credit' AND STATUS IN ('pending','approved') AND PAYMENT_REF=? AND CARD_CODE=?`,
[String(paymentRef).trim(), cardCode], runner);
return !!r;
}
// Optional bank auto-match (msale called sapapi/payment_details/check_payment).
async function bankMatch(settings, p) {
if (!settings.bankApiUrl) return null;
try {
const body = new URLSearchParams({ cust_code: p.cardCode, payment_ref_no: p.paymentRef, payment_mode: p.paymentMode, payment_date: p.paymentDate, paid_amount: String(p.amount) });
const r = await fetch(settings.bankApiUrl, { method: 'POST', headers: { 'MS-API-KEY': settings.bankApiKey || '', 'Content-Type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000) });
if (r.status !== 200) return null;
const d = await r.json().catch(() => ({}));
return d && d.id ? String(d.id) : 'matched';
} catch (e) { console.warn('[SALES] bank match failed:', e.message); return null; }
}
// Customer pays an approved order (status 4).
// data: { paidAmount, tds, paymentMode, paymentRef, paymentDate, paymentDetail, remarks, docId }
async function submitPayment(actor, id, data) {
const settings = await masters.getSettings();
const o = await getOrderRaw(id);
if (!o || o.cardCode !== actor.cardCode) fail('Order not found', 404);
if (o.status !== S.APPROVED) fail('Payment can only be made for an approved order');
const tds = Math.max(0, round2(data.tds));
if (tds >= o.grandTotal) fail('TDS cannot exceed the order value');
const paid = Math.max(0, round2(data.paidAmount));
if (paid > 0) {
if (!data.paymentMode || !String(data.paymentRef || '').trim() || !data.paymentDate) fail('Payment mode, reference no. and payment date are required');
if (new Date(data.paymentDate) > new Date()) fail('Payment date cannot be in the future');
}
let result;
await tx(async (r) => {
const bal = await walletBalance(o.cardCode, r);
const need = round2(o.grandTotal - tds);
const payable = round2(Math.max(0, need - bal));
const shortfall = round2(payable - paid);
if (shortfall > Number(settings.roundingTolerance || 0)) fail(`Payable amount is ₹ ${payable.toFixed(2)} — please pay at least that (wallet balance ₹ ${bal.toFixed(2)} already adjusted)`);
if (paid > 0 && await duplicatePaymentRef(data.paymentRef, o.cardCode, r)) fail('This payment reference has already been used. If the amount is not credited, please contact MIPL.');
const rebate = shortfall > 0 ? shortfall : 0; // msale "Recede" rounding write-off
const debit = round2(need - rebate);
let creditId = null;
if (paid > 0) {
const c = await one(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,STATUS,REF_TYPE,ORDER_ID,PAYMENT_MODE,PAYMENT_REF,PAYMENT_DATE,PAYMENT_DETAIL,DOC_ID,CREATED_BY_NAME)
OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [o.cardCode, 'credit', paid, 'pending', 'topup', o.id, data.paymentMode, String(data.paymentRef).trim(), data.paymentDate,
data.paymentDetail || '', parseInt(data.docId) || null, actor.name], r);
creditId = c.ID;
}
const covered = paid === 0; // wallet alone covers it
await query(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,TDS,STATUS,REF_TYPE,ORDER_ID,CREATED_BY_NAME,DECIDED_AT,DECIDED_BY_NAME)
VALUES (?,?,?,?,?,?,?,?,?,?)`, [o.cardCode, 'debit', debit, tds, covered ? 'approved' : 'pending', 'order', o.id, actor.name, covered ? new Date() : null, covered ? 'Auto (wallet)' : null], r);
const status = covered ? S.READY_FOR_SAP : S.PAYMENT_SUBMITTED;
const paidStatus = covered ? 'Already Paid' : (paid > payable + 0.01 ? 'Exceed' : rebate > 0 ? 'Recede' : (bal > 0 ? 'Adjust Payment' : 'Full Paid'));
await query(`UPDATE dbo.ZSO_ORDERS SET STATUS=?, PAID_AMOUNT=?, TDS_VAL=?, WALLET_USED=?, REBATE=?, PAID_STATUS=?, PAYMENT_MODE=?, PAYMENT_REF=?, PAYMENT_DATE=?, PAYMENT_DETAIL=?,
CUSTOMER_REMARKS=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? WHERE ID=?`,
[status, paid, tds, round2(Math.min(bal, need)), rebate || null, paidStatus, paid > 0 ? data.paymentMode : null, paid > 0 ? String(data.paymentRef).trim() : null,
paid > 0 ? data.paymentDate : null, data.paymentDetail || null, appendRemark(o.customerRemarks, actor.name, data.remarks), actor.name, o.id], r);
await log('order', o.id, 'payment', S.APPROVED, status, `Paid ₹ ${paid.toFixed(2)}${tds ? `, TDS ₹ ${tds.toFixed(2)}` : ''}${data.paymentRef ? ` (ref ${data.paymentRef})` : ''}`, actor, r);
result = { status, creditId };
});
// Bank auto-match: approve straight away when the bank confirms the credit.
if (result.creditId) {
const match = await bankMatch(settings, { cardCode: o.cardCode, paymentRef: data.paymentRef, paymentMode: data.paymentMode, paymentDate: data.paymentDate, amount: round2(data.paidAmount) });
if (match) {
await tx(async (r) => {
await query(`UPDATE dbo.ZSO_WALLET_TXN SET BANK_API_ID=? WHERE ID=?`, [match, result.creditId], r);
await query(`UPDATE dbo.ZSO_ORDERS SET STATUS=7, UPDATED_AT=SYSDATETIME() WHERE ID=? AND STATUS=6`, [o.id], r);
await settlePayment(o, 'approve', { type: 'user', name: 'Bank auto-match', user: { id: 0 } }, r);
await log('order', o.id, 'payment_auto_matched', S.PAYMENT_SUBMITTED, S.READY_FOR_SAP, `Bank ref ${match}`, { type: 'user', name: 'Bank auto-match', user: { id: 0 } }, r);
});
}
}
const fresh = await getOrderRaw(o.id);
notify.orderEvent(fresh.status === S.READY_FOR_SAP ? 'payment_received' : 'payment_submitted', fresh, { customerEmail: await customerEmail(fresh) });
if (fresh.status === S.READY_FOR_SAP) { notify.orderEvent('ready_for_sap', fresh); maybeAutoPost(fresh.id); }
return fresh;
}
// Approve/reject every pending wallet txn of an order (Accounts decision).
async function settlePayment(o, decision, actor, r) {
const pend = await query(`SELECT * FROM dbo.ZSO_WALLET_TXN WHERE ORDER_ID=? AND STATUS='pending'`, [o.id], r);
for (const t of pend) {
const st = decision === 'approve' ? 'approved' : (t.TXN_TYPE === 'credit' ? 'rejected' : 'cancelled');
await query(`UPDATE dbo.ZSO_WALLET_TXN SET STATUS=?, DECIDED_AT=SYSDATETIME(), DECIDED_BY_NAME=? WHERE ID=?`, [st, actor.name, t.ID], r);
}
if (decision === 'reject') {
await query(`UPDATE dbo.ZSO_ORDERS SET PAID_AMOUNT=NULL, WALLET_USED=NULL, REBATE=NULL, PAID_STATUS='Payment Rejected' WHERE ID=?`, [o.id], r);
}
}
// On cancel: an approved order debit is reversed (money returns to the
// wallet); pending ones are cancelled; a still-pending top-up stays pending
// so Accounts can still verify the money that was actually sent.
async function reverseOrderDebit(o, actor, r) {
await query(`UPDATE dbo.ZSO_WALLET_TXN SET STATUS='cancelled', DECIDED_AT=SYSDATETIME(), DECIDED_BY_NAME=?, REMARKS=CONCAT(COALESCE(REMARKS,''),' Order cancelled')
WHERE ORDER_ID=? AND TXN_TYPE='debit' AND STATUS='pending'`, [actor.name, o.id], r);
const appr = await query(`SELECT ID, AMOUNT FROM dbo.ZSO_WALLET_TXN WHERE ORDER_ID=? AND TXN_TYPE='debit' AND STATUS='approved'`, [o.id], r);
for (const d of appr) {
await query(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,STATUS,REF_TYPE,ORDER_ID,REMARKS,CREATED_BY_NAME,DECIDED_AT,DECIDED_BY_NAME)
VALUES (?,?,?,?,?,?,?,?,SYSDATETIME(),?)`, [o.cardCode, 'credit', Number(d.AMOUNT), 'approved', 'refund', o.id, `Refund — order ${o.refNo} cancelled`, actor.name, actor.name], r);
}
}
// Pending top-ups awaiting Accounts (both order payments and on-account).
async function pendingPayments(actor) {
if (!can(actor, 'payment_verify', 'view') && !can(actor, 'payment_entry', 'view')) fail('Not allowed', 403);
const rows = await query(`SELECT t.*, o.REF_NO, o.CARD_NAME, o.GRAND_TOTAL, o.STATUS AS ORDER_STATUS, o.DIVISION_ID FROM dbo.ZSO_WALLET_TXN t
LEFT JOIN dbo.ZSO_ORDERS o ON o.ID=t.ORDER_ID WHERE t.STATUS='pending' AND t.TXN_TYPE='credit' ORDER BY t.ID`);
const prof = (await scopeFor(actor)).prof;
return rows.filter(r => !r.DIVISION_ID || prof.divisions.includes(Number(r.DIVISION_ID)))
.map(r => ({ ...txnFromRow(r), cardName: r.CARD_NAME || '', orderRef: r.REF_NO || '', orderTotal: r.GRAND_TOTAL == null ? null : Number(r.GRAND_TOTAL), orderStatus: r.ORDER_STATUS }));
}
// On-account payment / adjustment entered by Accounts (msale payment_entry).
async function addOnAccountPayment(actor, data) {
if (!can(actor, 'payment_entry', 'add')) fail('You are not assigned "add" on sales_order:payment_entry', 403);
const amt = round2(data.amount);
if (!data.cardCode) fail('Customer is required');
if (!(amt > 0)) fail('Amount must be greater than 0');
if (!data.paymentMode || !String(data.paymentRef || '').trim() || !data.paymentDate) fail('Payment mode, reference and date are required');
if (!String(data.paymentDetail || '').trim()) fail('Payment remarks are required');
const type = data.type === 'debit' ? 'debit' : 'credit';
return tx(async (r) => {
if (type === 'credit' && await duplicatePaymentRef(data.paymentRef, data.cardCode, r)) fail('This payment reference has already been used for this customer');
const row = await one(`INSERT INTO dbo.ZSO_WALLET_TXN (CARD_CODE,TXN_TYPE,AMOUNT,STATUS,REF_TYPE,PAYMENT_MODE,PAYMENT_REF,PAYMENT_DATE,PAYMENT_DETAIL,REMARKS,CREATED_BY_NAME,DECIDED_AT,DECIDED_BY_NAME)
OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?,?,?,?,?,SYSDATETIME(),?)`,
[data.cardCode, type, amt, 'approved', type === 'credit' ? 'topup' : 'adjustment', data.paymentMode, String(data.paymentRef).trim(), data.paymentDate,
data.paymentDetail, data.remarks || '', actor.name, actor.name], r);
return row.ID;
});
}
// Accounts decision on a standalone pending top-up (not tied to an order).
async function decideTopup(actor, txnId, decision, remarks) {
if (!can(actor, 'payment_verify', 'approve')) fail('You are not assigned "approve" on sales_order:payment_verify', 403);
const t = await one(`SELECT * FROM dbo.ZSO_WALLET_TXN WHERE ID=?`, [parseInt(txnId)]);
if (!t || t.STATUS !== 'pending' || t.TXN_TYPE !== 'credit') fail('Payment not found or already processed');
if (t.ORDER_ID) {
const o = await getOrderRaw(t.ORDER_ID);
if (o && o.status === S.PAYMENT_SUBMITTED) return act(actor, o.id, decision === 'approve' ? 'payment_approve' : 'payment_reject', { employeeRemarks: remarks || (decision === 'approve' ? '' : 'Payment not verified') });
}
await query(`UPDATE dbo.ZSO_WALLET_TXN SET STATUS=?, DECIDED_AT=SYSDATETIME(), DECIDED_BY_NAME=?, REMARKS=? WHERE ID=? AND STATUS='pending'`,
[decision === 'approve' ? 'approved' : 'rejected', actor.name, remarks || t.REMARKS || '', t.ID]);
return { ok: true };
}
// ── SAP posting ─────────────────────────────────────────────────────────────
// The user's own SAP Service-Layer login for `company` (Profile → My SAP Account).
function sapCtxFor(actor, company) {
const entry = actor && actor.user && actor.user.sapLogins ? actor.user.sapLogins[company] : null;
if (!entry || !entry.user || !entry.pwdEnc) {
fail(`You have no SAP login saved for company "${company}". Open Profile → My SAP Account, add your SAP User ID & Password for this company, then log out and back in (or refresh) and try again.`, 400);
}
return { sapUser: entry.user, sapPassword: require('../cryptoUtil').decrypt(entry.pwdEnc) };
}
async function postToSap(actor, id, { system = false } = {}) {
const settings = await masters.getSettings();
const o = await getOrderRaw(id);
if (!o) fail('Order not found', 404);
if (!system) {
if (!(await canSee(actor, o))) fail('Order not found', 404);
if (!can(actor, 'sap_post', 'approve')) fail('You are not assigned "approve" on sales_order:sap_post', 403);
}
if (o.status !== S.READY_FOR_SAP) fail('Only orders that are ready for SAP can be posted');
// Guard against double posting: link an existing SAP order instead.
const existing = (await sap.sapOrdersByWebNo(o.company, [o.id])).filter(x => !x.cancelled);
let results;
if (existing.length) {
results = existing.map(x => ({ docEntry: x.docEntry, docNum: x.docNum, docDate: x.docDate, linked: true }));
} else {
const items = await getItems(o.id);
const consignees = await getConsignees(o.id);
const division = await masters.getDivision(o.divisionId);
const payloads = sap.buildSapPayloads(o, items, consignees, settings, division);
try {
// Act as the posting user in SAP, using THEIR login for the ORDER's
// company (from the JWT's per-company sapLogins map). Decided here from
// the order itself — not from a `company` field the browser may or may
// not send (the global SAP-context middleware needs that, and silently
// degrades to "no SAP login" without it). System auto-post keeps the
// shared .env account (runWithSapUser(null) in maybeAutoPost).
results = system ? await sap.postOrderToSap(o.company, payloads)
: await runWithSapUser(sapCtxFor(actor, o.company), () => sap.postOrderToSap(o.company, payloads));
} catch (e) {
await query(`UPDATE dbo.ZSO_ORDERS SET SAP_ERROR=?, UPDATED_AT=SYSDATETIME() WHERE ID=?`, [e.message, o.id]);
await log('order', o.id, 'sap_post_failed', o.status, o.status, e.message, actor);
throw e;
}
}
const first = results[0];
await tx(async (r) => {
await query(`UPDATE dbo.ZSO_ORDERS SET STATUS=8, SAP_DOC_ENTRY=?, SAP_DOC_NUM=?, SAP_ORDER_DATE=?, SAP_POSTED_AT=SYSDATETIME(), SAP_POSTED_BY=?, SAP_ERROR=NULL,
UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? WHERE ID=? AND STATUS=7`, [first.docEntry, first.docNum, first.docDate || null, actor.name, actor.name, o.id], r);
for (const x of results) if (x.consigneeId) await query(`UPDATE dbo.ZSO_ORDER_CONSIGNEES SET SAP_DOC_ENTRY=?, SAP_DOC_NUM=? WHERE ID=?`, [x.docEntry, x.docNum, x.consigneeId], r);
await log('order', o.id, existing.length ? 'sap_linked' : 'sap_posted', S.READY_FOR_SAP, S.IN_SAP, `SAP SO ${results.map(x => x.docNum).join(', ')}`, actor, r);
});
const fresh = await getOrderRaw(o.id);
notify.orderEvent('in_sap', fresh, { customerEmail: await customerEmail(fresh) });
return fresh;
}
// Optional (Settings → autoPostToSap): post with the shared .env SAP account
// as soon as an order becomes ready. Failures are left on the order
// (SAP_ERROR) for a person to retry from the screen.
function maybeAutoPost(id) {
masters.getSettings().then(s => {
if (!s.autoPostToSap) return;
const sys = { type: 'user', name: 'Auto-post (system)', user: { id: 0, role: 'admin' } };
runWithSapUser(null, () => postToSap(sys, id, { system: true }).catch(e => console.warn(`[SALES] auto-post of order ${id} failed:`, e.message)));
}).catch(() => {});
}
// ── SAP status sync (every 30 min + on demand) ──────────────────────────────
// • status 7 orders already present in SAP (posted manually / by the old
// job) are linked → 8, so they're never posted twice
// • status 8 orders whose SAP orders are all closed (fully delivered /
// invoiced) → 9, with the invoice list cached on the order
let _syncing = false;
async function syncWithSap() {
if (_syncing) return { skipped: true };
_syncing = true;
const stats = { linked: 0, closed: 0, checked: 0 };
try {
const rows = await query(`SELECT ID, COMPANY, STATUS FROM dbo.ZSO_ORDERS WHERE STATUS IN (7,8)`);
const byCo = {};
rows.forEach(r => { (byCo[r.COMPANY || ''] = byCo[r.COMPANY || ''] || []).push(r); });
const sysActor = { type: 'user', name: 'SAP sync', user: { id: 0 } };
for (const [co, list] of Object.entries(byCo)) {
const company = co || (await masters.getSettings()).company;
const sapRows = await sap.sapOrdersByWebNo(company, list.map(r => r.ID));
const byId = {};
sapRows.filter(x => !x.cancelled).forEach(x => { (byId[x.webNo] = byId[x.webNo] || []).push(x); });
for (const r of list) {
stats.checked++;
const so = byId[r.ID] || [];
if (!so.length) continue;
if (r.STATUS === 7) {
const upd = await query(`UPDATE dbo.ZSO_ORDERS SET STATUS=8, SAP_DOC_ENTRY=?, SAP_DOC_NUM=?, SAP_ORDER_DATE=?, SAP_POSTED_AT=SYSDATETIME(), SAP_POSTED_BY='SAP sync',
LAST_SAP_SYNC=SYSDATETIME() OUTPUT INSERTED.ID WHERE ID=? AND STATUS=7`, [so[0].docEntry, so[0].docNum, so[0].docDate || null, r.ID]);
if (upd.length) { stats.linked++; await log('order', r.ID, 'sap_linked', 7, 8, `SAP SO ${so.map(x => x.docNum).join(', ')} found in SAP`, sysActor); }
} else if (so.every(x => !x.open)) {
let inv = null;
try { inv = await sap.invoicesForOrder(company, r.ID); } catch (_e) {}
const upd = await query(`UPDATE dbo.ZSO_ORDERS SET STATUS=9, INVOICE_JSON=?, LAST_SAP_SYNC=SYSDATETIME() OUTPUT INSERTED.ID WHERE ID=? AND STATUS=8`,
[inv ? JSON.stringify(inv.map(i => ({ invoiceNo: i.invoiceNo, invoiceDate: i.invoiceDate, total: i.total }))) : null, r.ID]);
if (upd.length) { stats.closed++; await log('order', r.ID, 'closed_by_sap', 8, 9, 'All SAP sales orders closed', sysActor); }
} else {
await query(`UPDATE dbo.ZSO_ORDERS SET LAST_SAP_SYNC=SYSDATETIME() WHERE ID=?`, [r.ID]);
}
}
}
try { stats.invoiceEmails = await notifyNewInvoices(); } catch (e) { console.warn('[SALES] invoice email check failed:', e.message); }
return stats;
} finally { _syncing = false; }
}
// New SAP invoices on orders in SAP (status 8, or closed in the last 3
// days) → one "invoice raised" email each, recorded in ZSO_INVOICE_NOTICES
// so it's never sent twice. Invoices dated before Settings.invoiceEmailSince
// (the day this feature was deployed) are recorded silently — no backlog flood.
async function notifyNewInvoices() {
const s = await masters.getSettings();
const rows = await query(`SELECT ID, COMPANY FROM dbo.ZSO_ORDERS WHERE STATUS=8 OR (STATUS=9 AND LAST_SAP_SYNC>=DATEADD(day,-3,SYSDATETIME()))`);
if (!rows.length) return 0;
const since = s.invoiceEmailSince ? new Date(s.invoiceEmailSince) : null;
const byCo = {};
rows.forEach(r => { (byCo[r.COMPANY || s.company] = byCo[r.COMPANY || s.company] || []).push(r.ID); });
let sent = 0;
for (const [company, ids] of Object.entries(byCo)) {
const invs = await sap.invoicesForWebNos(company, ids);
if (!invs.length) continue;
const known = new Set((await query(`SELECT ORDER_ID, INVOICE_DOC_ENTRY FROM dbo.ZSO_INVOICE_NOTICES WHERE ORDER_ID IN (${ids.map(Number).join(',')})`))
.map(k => `${k.ORDER_ID}:${k.INVOICE_DOC_ENTRY}`));
for (const inv of invs) {
if (known.has(`${inv.webNo}:${inv.docEntry}`)) continue;
const fresh = s.invoiceEmail !== false && (!since || new Date(inv.invoiceDate) >= since);
// Record first (unique key) — if two sync runs ever race, only one sends.
const ins = await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_INVOICE_NOTICES WHERE ORDER_ID=? AND INVOICE_DOC_ENTRY=?)
INSERT INTO dbo.ZSO_INVOICE_NOTICES (ORDER_ID,INVOICE_DOC_ENTRY,INVOICE_NO,INVOICE_DATE,EMAILED) OUTPUT INSERTED.ID VALUES (?,?,?,?,?)`,
[inv.webNo, inv.docEntry, inv.webNo, inv.docEntry, inv.invoiceNo, inv.invoiceDate, fresh]).catch(() => []);
if (!ins.length) continue;
await log('order', inv.webNo, 'invoice_raised', null, null, `SAP invoice ${inv.invoiceNo} (${inv.total})${fresh ? ' — email sent' : ''}`,
{ type: 'user', name: 'SAP sync', user: { id: 0 } });
if (!fresh) continue;
const o = await getOrderRaw(inv.webNo);
if (!o) continue;
const lines = await sap.invoiceLines(company, inv.docEntry).catch(() => []);
await notify.invoiceEvent(o, inv, lines, { customerEmail: await customerEmail(o) });
sent++;
}
}
return sent;
}
// ── Documents ───────────────────────────────────────────────────────────────
async function addDoc(entity, entityId, docType, title, fileName, origName, byName, runner) {
if (entity === 'order' && ['payment', 'po_copy'].includes(docType)) {
// msale kept only the latest file per type — older ones stay but inactive
await query(`UPDATE dbo.ZSO_DOCS SET ACTIVE=0 WHERE ENTITY=? AND ENTITY_ID=? AND DOC_TYPE=?`, [entity, parseInt(entityId), docType], runner);
}
const r = await one(`INSERT INTO dbo.ZSO_DOCS (ENTITY,ENTITY_ID,DOC_TYPE,TITLE,FILE_NAME,ORIG_NAME,CREATED_BY_NAME) OUTPUT INSERTED.ID VALUES (?,?,?,?,?,?,?)`,
[entity, parseInt(entityId), docType, title || '', fileName, origName || '', byName], runner);
return r.ID;
}
async function getDoc(docId) { return one(`SELECT * FROM dbo.ZSO_DOCS WHERE ID=? AND ACTIVE=1`, [parseInt(docId)]); }
module.exports = {
createOrder, resubmit, act, nextCustOrderNo, customerCancel, submitPayment, postToSap, syncWithSap,
getOrder, getOrderRaw, getItems, listOrders, canSee, availableActions, scopeFor,
walletSummary, walletBalance, ledger, pendingPayments, addOnAccountPayment, decideTopup,
addDoc, getDoc, getDocs, log, appendRemark, refNo, taxFor, totals, ACTIONS,
};
+115
View File
@@ -0,0 +1,115 @@
// services/sales/reports.js
// msale's reports, rebuilt on the app DB (+ live SAP open quantities):
// dashboard — KPIs, status pipeline, monthly trend, top customers, days-to-close
// orderWise — one row per order
// itemWise — qty/value per item
// pending — open (un-delivered) qty per posted order line, from SAP RDR1.OpenQty
// customerPending — customer × item matrix of pending qty
// All are scoped exactly like the order list (division / mapped / own).
'use strict';
const { query, round2 } = require('./db');
const { statusLabel } = require('./constants');
const orders = require('./orders');
const masters = require('./masters');
const sap = require('./sap');
async function scopeWhere(actor, f, alias = 'o') {
const { prof, cards } = await orders.scopeFor(actor);
const w = [`${alias}.STATUS<>11`]; const p = [];
if (!prof.divisions.length) return { empty: true };
w.push(`${alias}.DIVISION_ID IN (${prof.divisions.map(Number).join(',')})`);
if (prof.scope === 'mapped') { if (!cards.length) return { empty: true }; w.push(`${alias}.CARD_CODE IN (${cards.map(() => '?').join(',')})`); p.push(...cards); }
if (prof.scope === 'own') { w.push(`${alias}.CREATED_BY_TYPE='user' AND ${alias}.CREATED_BY=?`); p.push(String(actor.user.id)); }
if (f.divisionId) { w.push(`${alias}.DIVISION_ID=?`); p.push(parseInt(f.divisionId)); }
if (f.soType) { w.push(`${alias}.SO_TYPE=?`); p.push(parseInt(f.soType)); }
if (f.cardCode) { w.push(`${alias}.CARD_CODE=?`); p.push(f.cardCode); }
if (f.statuses) { const st = String(f.statuses).split(',').map(Number).filter(Boolean); if (st.length) w.push(`${alias}.STATUS IN (${st.join(',')})`); }
if (f.from) { w.push(`${alias}.CREATED_AT>=?`); p.push(f.from); }
if (f.to) { w.push(`${alias}.CREATED_AT<DATEADD(day,1,CAST(? AS date))`); p.push(f.to); }
return { w, p };
}
async function dashboard(actor, f = {}) {
const sc = await scopeWhere(actor, f);
if (sc.empty) return { kpis: {}, byStatus: [], monthly: [], topCustomers: [], byDivision: [] };
const W = sc.w.join(' AND ');
const byStatus = await query(`SELECT o.STATUS, o.ORDER_TYPE, COUNT(*) N, SUM(o.GRAND_TOTAL) V FROM dbo.ZSO_ORDERS o WHERE ${W} GROUP BY o.STATUS, o.ORDER_TYPE`, sc.p);
const monthly = await query(`SELECT FORMAT(o.CREATED_AT,'yyyy-MM') M, COUNT(*) N, SUM(o.GRAND_TOTAL) V FROM dbo.ZSO_ORDERS o WHERE ${W} AND o.STATUS<>10
AND o.CREATED_AT>=DATEADD(month,-11,DATEFROMPARTS(YEAR(GETDATE()),MONTH(GETDATE()),1)) GROUP BY FORMAT(o.CREATED_AT,'yyyy-MM') ORDER BY M`, sc.p);
const top = await query(`SELECT TOP 10 o.CARD_CODE, MAX(o.CARD_NAME) CARD_NAME, COUNT(*) N, SUM(o.GRAND_TOTAL) V FROM dbo.ZSO_ORDERS o WHERE ${W} AND o.STATUS<>10
GROUP BY o.CARD_CODE ORDER BY V DESC`, sc.p);
const byDiv = await query(`SELECT o.DIVISION_ID, COUNT(*) N, SUM(o.GRAND_TOTAL) V FROM dbo.ZSO_ORDERS o WHERE ${W} AND o.STATUS<>10 GROUP BY o.DIVISION_ID`, sc.p);
// Days from order creation to SAP posting (turnaround), posted orders only.
const tat = await query(`SELECT AVG(CAST(DATEDIFF(hour,o.CREATED_AT,o.SAP_POSTED_AT) AS float))/24.0 AVGD, COUNT(*) N FROM dbo.ZSO_ORDERS o WHERE ${W} AND o.SAP_POSTED_AT IS NOT NULL`, sc.p);
const divs = Object.fromEntries((await masters.listDivisions(true)).map(d => [d.id, d.name]));
const sumBy = pred => byStatus.filter(pred).reduce((a, r) => ({ n: a.n + r.N, v: a.v + Number(r.V || 0) }), { n: 0, v: 0 });
return {
kpis: {
total: sumBy(r => r.STATUS !== 10), cancelled: sumBy(r => r.STATUS === 10),
awaitingAction: sumBy(r => [1, 2, 3, 5].includes(r.STATUS)), awaitingPayment: sumBy(r => [4, 6].includes(r.STATUS)),
readyForSap: sumBy(r => r.STATUS === 7), inSap: sumBy(r => r.STATUS === 8), closed: sumBy(r => r.STATUS === 9),
avgDaysToSap: tat[0] && tat[0].AVGD != null ? round2(tat[0].AVGD) : null,
},
byStatus: byStatus.map(r => ({ status: r.STATUS, label: statusLabel(r.STATUS, r.ORDER_TYPE), orderType: r.ORDER_TYPE, count: r.N, value: round2(r.V) })),
monthly: monthly.map(r => ({ month: r.M, count: r.N, value: round2(r.V) })),
topCustomers: top.map(r => ({ cardCode: r.CARD_CODE, cardName: r.CARD_NAME, count: r.N, value: round2(r.V) })),
byDivision: byDiv.map(r => ({ divisionId: r.DIVISION_ID, division: divs[r.DIVISION_ID] || r.DIVISION_ID, count: r.N, value: round2(r.V) })),
};
}
async function orderWise(actor, f = {}) {
const sc = await scopeWhere(actor, f);
if (sc.empty) return [];
const rows = await query(`SELECT o.ID, o.REF_NO, o.CREATED_AT, o.SO_TYPE, o.ORDER_TYPE, o.CARD_CODE, o.CARD_NAME, o.DIVISION_ID, o.CUST_ORDER_NO, o.DELIVERY_DATE,
o.SUB_TOTAL, o.GRAND_TOTAL, o.STATUS, o.SAP_DOC_NUM, o.SAP_POSTED_AT, o.PAID_AMOUNT, o.PAYMENT_REF,
(SELECT SUM(QTY) FROM dbo.ZSO_ORDER_ITEMS i WHERE i.ORDER_ID=o.ID AND i.ACTIVE=1) QTY
FROM dbo.ZSO_ORDERS o WHERE ${sc.w.join(' AND ')} ORDER BY o.ID DESC`, sc.p);
return rows.map(r => ({ id: r.ID, refNo: r.REF_NO, date: r.CREATED_AT, soType: r.SO_TYPE, orderType: r.ORDER_TYPE || (r.SO_TYPE === 1 ? 'TRADE' : 'IN-DIRECT'),
cardCode: r.CARD_CODE, cardName: r.CARD_NAME, divisionId: r.DIVISION_ID, custOrderNo: r.CUST_ORDER_NO || '', deliveryDate: r.DELIVERY_DATE, qty: Number(r.QTY || 0),
subTotal: Number(r.SUB_TOTAL), grandTotal: Number(r.GRAND_TOTAL), status: r.STATUS, statusLabel: statusLabel(r.STATUS, r.ORDER_TYPE), sapDocNum: r.SAP_DOC_NUM,
sapPostedAt: r.SAP_POSTED_AT, paidAmount: r.PAID_AMOUNT == null ? null : Number(r.PAID_AMOUNT), paymentRef: r.PAYMENT_REF || '' }));
}
async function itemWise(actor, f = {}) {
const sc = await scopeWhere(actor, f);
if (sc.empty) return [];
const rows = await query(`SELECT i.ITEM_CODE, MAX(i.DISPLAY_CODE) DISPLAY_CODE, MAX(i.DESCRIPTION) DESCRIPTION, COUNT(DISTINCT o.ID) ORDERS, SUM(i.QTY) QTY, SUM(i.LINE_TOTAL) VAL,
SUM(CASE WHEN i.SPECIAL_PRICE>0 THEN 1 ELSE 0 END) SPECIAL_LINES
FROM dbo.ZSO_ORDERS o JOIN dbo.ZSO_ORDER_ITEMS i ON i.ORDER_ID=o.ID AND i.ACTIVE=1 WHERE ${sc.w.join(' AND ')} AND o.STATUS<>10
GROUP BY i.ITEM_CODE ORDER BY VAL DESC`, sc.p);
return rows.map(r => ({ itemCode: r.ITEM_CODE, displayCode: r.DISPLAY_CODE, description: r.DESCRIPTION, orders: r.ORDERS, qty: Number(r.QTY), value: round2(r.VAL),
avgPrice: Number(r.QTY) ? round2(Number(r.VAL) / Number(r.QTY)) : 0, specialLines: r.SPECIAL_LINES }));
}
// Pending = qty still open on the SAP order lines of orders in SAP (status 8).
async function pending(actor, f = {}) {
const sc = await scopeWhere(actor, { ...f, statuses: '8' });
if (sc.empty) return [];
const ords = await query(`SELECT o.ID, o.REF_NO, o.CARD_CODE, o.CARD_NAME, o.DIVISION_ID, o.COMPANY, o.CREATED_AT, o.DELIVERY_DATE FROM dbo.ZSO_ORDERS o WHERE ${sc.w.join(' AND ')}`, sc.p);
if (!ords.length) return [];
const company = ords[0].COMPANY || (await masters.getSettings()).company;
const open = await sap.openQtyByWebNo(company, ords.map(o => o.ID));
const byId = Object.fromEntries(ords.map(o => [o.ID, o]));
return open.filter(l => l.openQty > 0 && (!f.itemCode || l.itemCode === f.itemCode)).map(l => {
const o = byId[l.webNo] || {};
return { orderId: l.webNo, refNo: o.REF_NO, cardCode: o.CARD_CODE, cardName: o.CARD_NAME, divisionId: o.DIVISION_ID, orderDate: o.CREATED_AT, deliveryDate: o.DELIVERY_DATE,
sapDocNum: l.sapDocNum, itemCode: l.itemCode, description: l.description, qty: l.qty, openQty: l.openQty, deliveredQty: round2(l.qty - l.openQty), openValue: round2(l.openQty * l.price),
ageDays: o.CREATED_AT ? Math.floor((Date.now() - new Date(o.CREATED_AT)) / 86400000) : null };
}).sort((a, b) => (b.ageDays || 0) - (a.ageDays || 0));
}
async function customerPending(actor, f = {}) {
const lines = await pending(actor, f);
const items = {}; const cust = {};
lines.forEach(l => {
items[l.itemCode] = items[l.itemCode] || { itemCode: l.itemCode, description: l.description, total: 0 };
items[l.itemCode].total = round2(items[l.itemCode].total + l.openQty);
cust[l.cardCode] = cust[l.cardCode] || { cardCode: l.cardCode, cardName: l.cardName, qty: {}, total: 0, value: 0 };
cust[l.cardCode].qty[l.itemCode] = round2((cust[l.cardCode].qty[l.itemCode] || 0) + l.openQty);
cust[l.cardCode].total = round2(cust[l.cardCode].total + l.openQty);
cust[l.cardCode].value = round2(cust[l.cardCode].value + l.openValue);
});
return { items: Object.values(items).sort((a, b) => b.total - a.total), customers: Object.values(cust).sort((a, b) => b.value - a.value) };
}
module.exports = { dashboard, orderWise, itemWise, pending, customerPending };
+260
View File
@@ -0,0 +1,260 @@
// services/sales/samples.js
// Sample Requests — msale's two sample workflows:
//
// Domestic (sales_sample): raised by Sales (SM/BUH) for a customer
// 1 Submitted ──buh_review──▶ 2 Approved | 3 Modification | 4 Rejected
// 3 ──creator edits──▶ 5 Resubmitted ──buh_review──▶ 2/3/4
// 2/5→ Logistics: stock available? yes ▶ 6 QA pending (batch nos, due +2d) / no ▶ 7 In production
// 7 ──logistics──▶ 6 ; 6 ──logistics (QA verified + challan/courier)──▶ 8 Shipped
// 8 ──feedback (Sales)──▶ 9 Completed
// Export (sales_export_sample): raised by Export team
// 1 Submitted ──logistics──▶ 2 In production | 3 QA pending ; 2 ──▶ 3 ; 3 ──QA verified + dispatch docs──▶ 4 Dispatched
'use strict';
const { query, one, tx } = require('./db');
const { SAMPLE, EXPORT_SAMPLE, sampleStatusLabel, exportSampleStatusLabel } = require('./constants');
const masters = require('./masters');
const notify = require('./notify');
const { hasStepPerm } = require('../../middleware/auth');
const { appendRemark, refNo, log, getDocs } = require('./orders');
function fail(msg, code = 400) { const e = new Error(msg); e.status = code; throw e; }
const canS = (actor, key, perm) => hasStepPerm(actor.user, `sales_sample:${key}`, perm);
const canE = (actor, key, perm) => hasStepPerm(actor.user, `sales_export_sample:${key}`, perm);
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
async function itemsFor(id, kind) {
return (await query(`SELECT * FROM dbo.ZSO_SAMPLE_ITEMS WHERE SAMPLE_ID=? AND KIND=? AND ACTIVE=1 ORDER BY ID`, [parseInt(id), kind]))
.map(r => ({ id: r.ID, productId: r.PRODUCT_ID, itemCode: r.ITEM_CODE, displayCode: r.DISPLAY_CODE || r.ITEM_CODE, description: r.DESCRIPTION || '',
qty: Number(r.QTY), nop: r.NOP, batchNo: r.BATCH_NO || '' }));
}
async function priceSampleLines(lines, divisionId, catalog) {
if (!Array.isArray(lines) || !lines.length) fail('Please add at least one product');
const out = [];
for (const [i, l] of lines.entries()) {
const qty = Number(l.qty);
if (!(qty > 0)) fail(`Line ${i + 1}: quantity must be greater than 0`);
const p = await masters.resolveProduct(l.key, divisionId, catalog);
if (!p) fail(`Line ${i + 1}: product not found`);
out.push({ productId: p.id, itemCode: p.itemCode, displayCode: p.displayCode, description: p.description, qty, nop: Math.max(1, Math.ceil(qty / (p.packSize || 1))) });
}
return out;
}
async function writeItems(id, kind, items, r) {
await query(`UPDATE dbo.ZSO_SAMPLE_ITEMS SET ACTIVE=0 WHERE SAMPLE_ID=? AND KIND=?`, [id, kind], r);
for (const it of items) {
await query(`INSERT INTO dbo.ZSO_SAMPLE_ITEMS (SAMPLE_ID,KIND,PRODUCT_ID,ITEM_CODE,DISPLAY_CODE,DESCRIPTION,QTY,NOP) VALUES (?,?,?,?,?,?,?,?)`,
[id, kind, it.productId || null, it.itemCode, it.displayCode, it.description, it.qty, it.nop], r);
}
}
// ── Domestic ────────────────────────────────────────────────────────────────
function sFromRow(r) {
return { id: r.ID, refNo: r.REF_NO, cardCode: r.CARD_CODE, cardName: r.CARD_NAME, address: r.ADDRESS || '', bbLicenceNo: r.BB_LICENCE_NO || '', bbName: r.BB_NAME || '',
divisionId: r.DIVISION_ID, reqDate: r.REQ_DATE, deliveryDays: r.DELIVERY_DAYS, status: r.STATUS, statusLabel: sampleStatusLabel(r.STATUS),
internalRemarks: r.INTERNAL_REMARKS || '', modificationRemarks: r.MODIFICATION_REMARKS || '', rejectionRemarks: r.REJECTION_REMARKS || '',
stockAvailable: r.STOCK_AVAILABLE == null ? null : !!r.STOCK_AVAILABLE, qaDueDate: r.QA_DUE_DATE, qaVerified: r.QA_VERIFIED == null ? null : !!r.QA_VERIFIED,
challanNo: r.CHALLAN_NO || '', challanDate: r.CHALLAN_DATE, courierNo: r.COURIER_NO || '', dispatchInfo: r.DISPATCH_INFO || '', feedback: r.FEEDBACK || '',
createdBy: r.CREATED_BY, createdByName: r.CREATED_BY_NAME || '', createdAt: r.CREATED_AT, updatedAt: r.UPDATED_AT, updatedByName: r.UPDATED_BY_NAME || '', legacy: !!r.LEGACY };
}
async function sampleScope(actor) {
const prof = await masters.getProfile(actor.user);
return prof;
}
async function listSamples(actor, f = {}) {
if (!canS(actor, 'view', 'view')) fail('You are not assigned "view" on Sample Requests', 403);
const prof = await sampleScope(actor);
if (!prof.divisions.length) return [];
const w = [`DIVISION_ID IN (${prof.divisions.map(Number).join(',')})`]; const p = [];
// A Sales Manager sees only the requests they raised (msale behaviour).
if (prof.scope !== 'all') { w.push('CREATED_BY=?'); p.push(parseInt(actor.user.id)); }
if (f.statuses) { const st = String(f.statuses).split(',').map(Number).filter(Boolean); if (st.length) w.push(`STATUS IN (${st.join(',')})`); }
if (f.search) { w.push('(REF_NO LIKE ? OR CARD_NAME LIKE ? OR CARD_CODE LIKE ?)'); const s = `%${f.search}%`; p.push(s, s, s); }
return (await query(`SELECT TOP 2000 * FROM dbo.ZSO_SAMPLES WHERE ${w.join(' AND ')} ORDER BY ID DESC`, p)).map(sFromRow);
}
async function getSample(actor, id) {
const r = await one(`SELECT * FROM dbo.ZSO_SAMPLES WHERE ID=?`, [parseInt(id)]);
if (!r || !canS(actor, 'view', 'view')) fail('Sample request not found', 404);
const prof = await sampleScope(actor);
if (!prof.divisions.includes(Number(r.DIVISION_ID)) || (prof.scope !== 'all' && r.CREATED_BY !== parseInt(actor.user.id))) fail('Sample request not found', 404);
const s = sFromRow(r);
s.items = await itemsFor(s.id, 'domestic');
s.log = (await query(`SELECT * FROM dbo.ZSO_LOG WHERE ENTITY='sample' AND ENTITY_ID=? ORDER BY ID`, [s.id])).map(l => ({ action: l.ACTION, fromStatus: l.FROM_STATUS, toStatus: l.TO_STATUS, remarks: l.REMARKS || '', actorName: l.ACTOR_NAME || '', at: l.AT }));
s.actions = sampleActions(actor, s);
return s;
}
function sampleActions(actor, s) {
const a = [];
if ([SAMPLE.SUBMITTED, SAMPLE.RESUBMITTED].includes(s.status) && canS(actor, 'buh_review', 'approve')) a.push('buh_approve', 'buh_modify', 'buh_reject');
if (s.status === SAMPLE.MODIFICATION && canS(actor, 'create', 'edit') && s.createdBy === parseInt(actor.user.id)) a.push('resubmit');
if (s.status === SAMPLE.APPROVED && canS(actor, 'logistics', 'approve')) a.push('stock');
if (s.status === SAMPLE.IN_PRODUCTION && canS(actor, 'logistics', 'approve')) a.push('stock');
if (s.status === SAMPLE.QA_PENDING && canS(actor, 'logistics', 'approve')) a.push('dispatch', 'qa_extend');
if (s.status === SAMPLE.SHIPPED && canS(actor, 'feedback', 'approve')) a.push('feedback');
return a;
}
async function createSample(actor, d) {
if (!canS(actor, 'create', 'add')) fail('You are not assigned "add" on sales_sample:create', 403);
const prof = await masters.getProfile(actor.user);
const divisionId = parseInt(d.divisionId);
if (!prof.divisions.includes(divisionId)) fail('This product category is not in your divisions', 403);
const div = await masters.getDivision(divisionId);
if (!div || !div.active) fail('This product category is disabled');
if (!d.cardCode) fail('Customer is required');
const items = await priceSampleLines(d.lines, divisionId, 'domestic');
const id = await tx(async (r) => {
const row = await one(`INSERT INTO dbo.ZSO_SAMPLES (CARD_CODE,CARD_NAME,ADDRESS,BB_LICENCE_NO,BB_NAME,DIVISION_ID,REQ_DATE,DELIVERY_DAYS,STATUS,CREATED_BY,CREATED_BY_NAME)
OUTPUT INSERTED.ID, INSERTED.CREATED_AT VALUES (?,?,?,?,?,?,?,?,1,?,?)`,
[d.cardCode, d.cardName || '', d.address || '', d.bbLicenceNo || '', d.bbName || '', divisionId, d.reqDate || new Date(), parseInt(d.deliveryDays) || null,
parseInt(actor.user.id), actor.name], r);
await query(`UPDATE dbo.ZSO_SAMPLES SET REF_NO=? WHERE ID=?`, [refNo(row.ID, row.CREATED_AT), row.ID], r);
await writeItems(row.ID, 'domestic', items, r);
await log('sample', row.ID, 'created', null, 1, d.remarks, actor, r);
return row.ID;
});
notify.sampleEvent('domestic', `Sample request submitted`, [`${actor.name} raised a sample request for ${d.cardName || d.cardCode}.`], 'sales_sample:buh_review', divisionId);
return id;
}
// data per action:
// buh_approve {internalRemarks} · buh_modify {modificationRemarks} · buh_reject {rejectionRemarks}
// resubmit {lines, ...header} · stock {stockAvailable, batchNos:{itemId:batch}, outOfStockDate}
// dispatch {challanNo, challanDate, courierNo, dispatchInfo} · qa_extend {} · feedback {feedback}
async function sampleAct(actor, id, action, d = {}) {
const s = await getSample(actor, id);
if (!s.actions.includes(action)) fail('This action is not available for you at the current status', 403);
let to = s.status; const sets = []; const vals = []; let notifyArgs = null;
switch (action) {
case 'buh_approve': to = SAMPLE.APPROVED; sets.push('INTERNAL_REMARKS=?'); vals.push(appendRemark(s.internalRemarks, actor.name, d.internalRemarks));
notifyArgs = ['Sample request approved', [`Sample ${s.refNo} for ${s.cardName} approved by ${actor.name}.`], 'sales_sample:logistics']; break;
case 'buh_modify': if (!String(d.modificationRemarks || '').trim()) fail('Modification remarks are required');
to = SAMPLE.MODIFICATION; sets.push('MODIFICATION_REMARKS=?'); vals.push(d.modificationRemarks); break;
case 'buh_reject': if (!String(d.rejectionRemarks || '').trim()) fail('Rejection remarks are required');
to = SAMPLE.REJECTED; sets.push('REJECTION_REMARKS=?'); vals.push(d.rejectionRemarks); break;
case 'resubmit': {
const items = await priceSampleLines(d.lines, s.divisionId, 'domestic');
to = SAMPLE.RESUBMITTED;
sets.push('BB_LICENCE_NO=?', 'BB_NAME=?', 'DELIVERY_DAYS=?', 'ADDRESS=?'); vals.push(d.bbLicenceNo ?? s.bbLicenceNo, d.bbName ?? s.bbName, parseInt(d.deliveryDays) || s.deliveryDays, d.address ?? s.address);
await tx(r => writeItems(s.id, 'domestic', items, r));
notifyArgs = ['Sample request resubmitted', [`Sample ${s.refNo} was modified and resubmitted.`], 'sales_sample:buh_review']; break;
}
case 'stock': {
if (d.stockAvailable === undefined || d.stockAvailable === null || d.stockAvailable === '') fail('Select whether stock is available');
const yes = d.stockAvailable === true || d.stockAvailable === 'true' || d.stockAvailable === 1 || d.stockAvailable === '1';
sets.push('STOCK_AVAILABLE=?'); vals.push(yes);
if (yes) {
const batches = d.batchNos || {};
if (s.items.some(it => !String(batches[it.id] || '').trim())) fail('Enter the batch no. for every item');
to = SAMPLE.QA_PENDING; sets.push('QA_DUE_DATE=?'); vals.push(addDays(new Date(), 2));
await tx(async (r) => { for (const it of s.items) await query(`UPDATE dbo.ZSO_SAMPLE_ITEMS SET BATCH_NO=? WHERE ID=?`, [String(batches[it.id]).trim(), it.id], r); });
notifyArgs = ['Sample — QA verification pending', [`Sample ${s.refNo}: stock available, QA verification due in 2 days.`], null];
} else { to = SAMPLE.IN_PRODUCTION; sets.push('OUT_OF_STOCK_DATE=?'); vals.push(d.outOfStockDate || new Date()); }
break;
}
case 'qa_extend': sets.push('QA_DUE_DATE=?'); vals.push(addDays(new Date(), 2)); break;
case 'dispatch':
if (!String(d.challanNo || '').trim() || !d.challanDate || !String(d.courierNo || '').trim()) fail('Challan no., challan date and courier no. are required');
to = SAMPLE.SHIPPED; sets.push('QA_VERIFIED=1', 'CHALLAN_NO=?', 'CHALLAN_DATE=?', 'COURIER_NO=?', 'DISPATCH_INFO=?'); vals.push(d.challanNo, d.challanDate, d.courierNo, d.dispatchInfo || '');
notifyArgs = ['Sample shipped — feedback awaited', [`Sample ${s.refNo} for ${s.cardName} shipped. Courier: ${d.courierNo}.`], null]; break;
case 'feedback': if (!String(d.feedback || '').trim()) fail('Feedback is required');
to = SAMPLE.COMPLETED; sets.push('FEEDBACK=?'); vals.push(d.feedback); break;
default: fail('Unknown action');
}
await tx(async (r) => {
await query(`UPDATE dbo.ZSO_SAMPLES SET STATUS=?, ${sets.length ? sets.join(', ') + ',' : ''} UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? WHERE ID=?`, [to, ...vals, actor.name, s.id], r);
await log('sample', s.id, action, s.status, to, d.internalRemarks || d.modificationRemarks || d.rejectionRemarks || d.feedback || d.dispatchInfo || '', actor, r);
});
const creator = await one(`SELECT EMAIL FROM dbo.ZCUST_USERS WHERE ID=?`, [s.createdBy]).catch(() => null);
if (notifyArgs) notify.sampleEvent('domestic', notifyArgs[0], notifyArgs[1], notifyArgs[2], s.divisionId, creator && creator.EMAIL ? [creator.EMAIL] : []);
else if (creator && creator.EMAIL) notify.sampleEvent('domestic', `Sample ${s.refNo}: ${sampleStatusLabel(to)}`, [`Sample request ${s.refNo} is now "${sampleStatusLabel(to)}".`], null, s.divisionId, [creator.EMAIL]);
return getSample(actor, s.id);
}
// ── Export ──────────────────────────────────────────────────────────────────
function eFromRow(r) {
return { id: r.ID, refNo: r.REF_NO, region: r.REGION || '', sampleAgainst: r.SAMPLE_AGAINST || '', custName: r.CUST_NAME, country: r.COUNTRY || '',
shippingAddress: r.SHIPPING_ADDRESS || '', contactPerson: r.CONTACT_PERSON || '', contactEmail: r.CONTACT_EMAIL || '', contactNo: r.CONTACT_NO || '',
accountDetails: r.ACCOUNT_DETAILS || '', courierByCustomer: !!r.COURIER_BY_CUSTOMER, divisionId: r.DIVISION_ID, reqDate: r.REQ_DATE, deliveryDate: r.DELIVERY_DATE,
deliveryDays: r.DELIVERY_DAYS, remarksQa: r.REMARKS_QA || '', remarksLogistics: r.REMARKS_LOGISTICS || '', status: r.STATUS, statusLabel: exportSampleStatusLabel(r.STATUS),
stockAvailable: r.STOCK_AVAILABLE == null ? null : !!r.STOCK_AVAILABLE, qaDueDate: r.QA_DUE_DATE, qaVerified: r.QA_VERIFIED == null ? null : !!r.QA_VERIFIED,
challanNo: r.CHALLAN_NO || '', challanDate: r.CHALLAN_DATE, courierNo: r.COURIER_NO || '', dispatchDetails: r.DISPATCH_DETAILS || '',
createdBy: r.CREATED_BY, createdByName: r.CREATED_BY_NAME || '', createdAt: r.CREATED_AT, updatedAt: r.UPDATED_AT, legacy: !!r.LEGACY };
}
async function listExport(actor, f = {}) {
if (!canE(actor, 'view', 'view')) fail('You are not assigned "view" on Export Sample Requests', 403);
const w = ['1=1']; const p = [];
if (f.statuses) { const st = String(f.statuses).split(',').map(Number).filter(Boolean); if (st.length) w.push(`STATUS IN (${st.join(',')})`); }
if (f.search) { w.push('(REF_NO LIKE ? OR CUST_NAME LIKE ? OR COUNTRY LIKE ?)'); const s = `%${f.search}%`; p.push(s, s, s); }
return (await query(`SELECT TOP 2000 * FROM dbo.ZSO_EXPORT_SAMPLES WHERE ${w.join(' AND ')} ORDER BY ID DESC`, p)).map(eFromRow);
}
async function getExport(actor, id) {
if (!canE(actor, 'view', 'view')) fail('Not found', 404);
const r = await one(`SELECT * FROM dbo.ZSO_EXPORT_SAMPLES WHERE ID=?`, [parseInt(id)]);
if (!r) fail('Export sample request not found', 404);
const e = eFromRow(r);
e.items = await itemsFor(e.id, 'export');
e.docs = await getDocs('export_sample', e.id);
e.log = (await query(`SELECT * FROM dbo.ZSO_LOG WHERE ENTITY='export_sample' AND ENTITY_ID=? ORDER BY ID`, [e.id])).map(l => ({ action: l.ACTION, fromStatus: l.FROM_STATUS, toStatus: l.TO_STATUS, remarks: l.REMARKS || '', actorName: l.ACTOR_NAME || '', at: l.AT }));
e.actions = [];
if (canE(actor, 'logistics', 'approve') && [EXPORT_SAMPLE.SUBMITTED, EXPORT_SAMPLE.IN_PRODUCTION].includes(e.status)) e.actions.push('stock');
if (canE(actor, 'logistics', 'approve') && e.status === EXPORT_SAMPLE.QA_PENDING) e.actions.push('dispatch');
if (canE(actor, 'create', 'edit') && e.status === EXPORT_SAMPLE.SUBMITTED) e.actions.push('edit');
return e;
}
const EXPORT_FIELDS = ['region', 'sampleAgainst', 'custName', 'country', 'shippingAddress', 'contactPerson', 'contactEmail', 'contactNo', 'accountDetails'];
async function saveExport(actor, d) {
const editing = !!d.id;
if (!canE(actor, 'create', editing ? 'edit' : 'add')) fail(`You are not assigned "${editing ? 'edit' : 'add'}" on sales_export_sample:create`, 403);
for (const f of ['custName', 'country', 'shippingAddress', 'contactPerson', 'contactEmail', 'contactNo']) if (!String(d[f] || '').trim()) fail(`${f} is required`);
const items = await priceSampleLines(d.lines, null, 'export');
const vals = [...EXPORT_FIELDS.map(f => d[f] || ''), !!d.courierByCustomer, d.reqDate || new Date(), parseInt(d.deliveryDays) || null, d.remarksQa || '', d.remarksLogistics || ''];
return tx(async (r) => {
let id;
if (editing) {
const cur = await one(`SELECT STATUS FROM dbo.ZSO_EXPORT_SAMPLES WHERE ID=?`, [parseInt(d.id)], r);
if (!cur || cur.STATUS !== EXPORT_SAMPLE.SUBMITTED) fail('Only submitted requests can be edited');
await query(`UPDATE dbo.ZSO_EXPORT_SAMPLES SET REGION=?, SAMPLE_AGAINST=?, CUST_NAME=?, COUNTRY=?, SHIPPING_ADDRESS=?, CONTACT_PERSON=?, CONTACT_EMAIL=?, CONTACT_NO=?,
ACCOUNT_DETAILS=?, COURIER_BY_CUSTOMER=?, REQ_DATE=?, DELIVERY_DAYS=?, REMARKS_QA=?, REMARKS_LOGISTICS=?, UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? WHERE ID=?`,
[...vals, actor.name, parseInt(d.id)], r);
id = parseInt(d.id);
await log('export_sample', id, 'edited', 1, 1, '', actor, r);
} else {
const row = await one(`INSERT INTO dbo.ZSO_EXPORT_SAMPLES (REGION,SAMPLE_AGAINST,CUST_NAME,COUNTRY,SHIPPING_ADDRESS,CONTACT_PERSON,CONTACT_EMAIL,CONTACT_NO,ACCOUNT_DETAILS,
COURIER_BY_CUSTOMER,REQ_DATE,DELIVERY_DAYS,REMARKS_QA,REMARKS_LOGISTICS,STATUS,CREATED_BY,CREATED_BY_NAME) OUTPUT INSERTED.ID, INSERTED.CREATED_AT
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, [...vals, parseInt(actor.user.id), actor.name], r);
id = row.ID;
await query(`UPDATE dbo.ZSO_EXPORT_SAMPLES SET REF_NO=? WHERE ID=?`, [refNo(id, row.CREATED_AT), id], r);
await log('export_sample', id, 'created', null, 1, '', actor, r);
}
await writeItems(id, 'export', items, r);
if (!editing) notify.sampleEvent('export', 'Export sample request submitted', [`${actor.name} raised an export sample request for ${d.custName} (${d.country}).`], 'sales_export_sample:logistics', null);
return id;
});
}
// stock {stockAvailable, batchNos, deliveryDate} · dispatch {challanNo, challanDate, courierNo, dispatchDetails}
async function exportAct(actor, id, action, d = {}) {
const e = await getExport(actor, id);
if (!e.actions.includes(action)) fail('This action is not available for you at the current status', 403);
let to = e.status; const sets = []; const vals = [];
if (action === 'stock') {
const yes = d.stockAvailable === true || d.stockAvailable === 'true' || d.stockAvailable === 1 || d.stockAvailable === '1';
sets.push('STOCK_AVAILABLE=?', 'DELIVERY_DATE=?'); vals.push(yes, d.deliveryDate || null);
if (yes) {
const b = d.batchNos || {};
if (e.items.some(it => !String(b[it.id] || '').trim())) fail('Enter the batch no. for every item');
to = EXPORT_SAMPLE.QA_PENDING; sets.push('QA_DUE_DATE=?'); vals.push(addDays(new Date(), 2));
await tx(async (r) => { for (const it of e.items) await query(`UPDATE dbo.ZSO_SAMPLE_ITEMS SET BATCH_NO=? WHERE ID=?`, [String(b[it.id]).trim(), it.id], r); });
} else to = EXPORT_SAMPLE.IN_PRODUCTION;
} else if (action === 'dispatch') {
if (!String(d.courierNo || '').trim()) fail('Courier / AWB no. is required');
to = EXPORT_SAMPLE.DISPATCHED; sets.push('QA_VERIFIED=1', 'CHALLAN_NO=?', 'CHALLAN_DATE=?', 'COURIER_NO=?', 'DISPATCH_DETAILS=?');
vals.push(d.challanNo || '', d.challanDate || null, d.courierNo, d.dispatchDetails || '');
} else fail('Unknown action');
await tx(async (r) => {
await query(`UPDATE dbo.ZSO_EXPORT_SAMPLES SET STATUS=?, ${sets.join(', ')}, UPDATED_AT=SYSDATETIME(), UPDATED_BY_NAME=? WHERE ID=?`, [to, ...vals, actor.name, e.id], r);
await log('export_sample', e.id, action, e.status, to, d.dispatchDetails || '', actor, r);
});
const creator = await one(`SELECT EMAIL FROM dbo.ZCUST_USERS WHERE ID=?`, [e.createdBy]).catch(() => null);
if (creator && creator.EMAIL) notify.sampleEvent('export', `Export sample ${e.refNo}: ${exportSampleStatusLabel(to)}`, [`Export sample ${e.refNo} for ${e.custName} is now "${exportSampleStatusLabel(to)}".`], null, null, [creator.EMAIL]);
return getExport(actor, e.id);
}
module.exports = { listSamples, getSample, createSample, sampleAct, listExport, getExport, saveExport, exportAct };
+268
View File
@@ -0,0 +1,268 @@
// services/sales/sap.js
// Everything the Sales Order module reads from / writes to SAP B1:
// • customers + addresses come LIVE from OCRD/CRD1 (msale kept a stale copy
// in dealer_master/dealer_address — no longer needed)
// • SAP-mode product list (OITM + price list)
// • posting the approved order as a SAP Sales Order (ORDR) via Service Layer,
// stamped with U_WEB_SO_NO = our order ID exactly like the old external
// sync job did, so existing SAP reports/PPC keep working
// • invoices, dispatch (LR/GR/AWB) and COA certificates for an order —
// read straight from OINV / batch links / ATC1 instead of being pushed
// into MySQL by external upload jobs
'use strict';
const fs = require('fs');
const path = require('path');
const { getPool } = require('../sqlPool');
const { sapRequest } = require('../sapServiceLayer');
const { sapSalesType } = require('./constants');
async function sq(company, text, inputs = {}) {
const pool = await getPool(company);
const r = pool.request();
Object.entries(inputs).forEach(([k, v]) => r.input(k, v));
return (await r.query(text)).recordset || [];
}
// ── Customers ───────────────────────────────────────────────────────────────
async function searchCustomers(company, term, { limit = 50, cardCodes = null } = {}) {
const t = `%${String(term || '').trim()}%`;
let filter = '';
if (Array.isArray(cardCodes)) {
if (!cardCodes.length) return [];
filter = ` AND c.CardCode IN (${cardCodes.map((_, i) => `@cc${i}`).join(',')})`;
}
const inputs = { t, lim: limit };
(cardCodes || []).forEach((c, i) => { inputs[`cc${i}`] = c; });
const rows = await sq(company, `SELECT TOP (@lim) c.CardCode, c.CardName, c.E_Mail, c.Cellular, c.CntctPrsn, c.SlpCode, s.SlpName, c.City, c.validFor, c.frozenFor
FROM OCRD c LEFT JOIN OSLP s ON s.SlpCode=c.SlpCode
WHERE c.CardType='C' AND (c.CardCode LIKE @t OR c.CardName LIKE @t)${filter} ORDER BY c.CardName`, inputs);
return rows.map(r => ({ cardCode: r.CardCode, cardName: r.CardName, email: r.E_Mail || '', phone: r.Cellular || '', contactPerson: r.CntctPrsn || '',
slpCode: r.SlpCode, slpName: r.SlpName || '', city: r.City || '', frozen: r.frozenFor === 'Y' }));
}
function formatAddress(a) {
return [a.street, a.block, [a.city, a.zipCode].filter(Boolean).join(' - '), a.stateName || a.state, a.countryName || a.country]
.map(x => String(x || '').trim()).filter(Boolean).join('\n');
}
async function getCustomer(company, cardCode) {
const rows = await sq(company, `SELECT c.CardCode, c.CardName, c.E_Mail, c.Cellular, c.CntctPrsn, c.SlpCode, s.SlpName, c.GroupNum, c.Territory,
t.descript AS TerritoryName, c.U_CustomerType, c.U_CustomerCategory, c.frozenFor, c.LicTradNum
FROM OCRD c LEFT JOIN OSLP s ON s.SlpCode=c.SlpCode LEFT JOIN OTER t ON t.territryID=c.Territory
WHERE c.CardCode=@cc AND c.CardType='C'`, { cc: cardCode });
if (!rows.length) return null;
const c = rows[0];
const addr = await sq(company, `SELECT a.Address, a.AdresType, a.Street, a.Block, a.City, a.ZipCode, a.State, st.Name AS StateName, a.Country, co.Name AS CountryName, a.GSTRegnNo
FROM CRD1 a LEFT JOIN OCST st ON st.Code=a.State AND st.Country=a.Country LEFT JOIN OCRY co ON co.Code=a.Country
WHERE a.CardCode=@cc ORDER BY a.AdresType, a.Address`, { cc: cardCode });
const addresses = addr.map(a => {
const o = { code: a.Address, type: a.AdresType === 'B' ? 'bill' : 'ship', street: a.Street || '', block: a.Block || '', city: a.City || '',
zipCode: a.ZipCode || '', state: a.State || '', stateName: a.StateName || '', country: a.Country || '', countryName: a.CountryName || '', gstin: a.GSTRegnNo || '' };
o.text = formatAddress(o);
return o;
});
return { cardCode: c.CardCode, cardName: c.CardName, email: c.E_Mail || '', phone: c.Cellular || '', contactPerson: c.CntctPrsn || '',
slpCode: c.SlpCode, slpName: c.SlpName || '', groupNum: c.GroupNum, territory: c.TerritoryName || '', customerType: c.U_CustomerType || '',
customerCategory: c.U_CustomerCategory || '', frozen: c.frozenFor === 'Y', gstin: c.LicTradNum || '',
billTo: addresses.filter(a => a.type === 'bill'), shipTo: addresses.filter(a => a.type === 'ship') };
}
async function customerNames(company, cardCodes) {
const codes = [...new Set(cardCodes.filter(Boolean))];
if (!codes.length) return {};
const out = {};
for (let i = 0; i < codes.length; i += 500) {
const chunk = codes.slice(i, i + 500); const inputs = {};
chunk.forEach((c, j) => { inputs[`c${j}`] = c; });
(await sq(company, `SELECT CardCode, CardName, E_Mail FROM OCRD WHERE CardCode IN (${chunk.map((_, j) => `@c${j}`).join(',')})`, inputs))
.forEach(r => { out[r.CardCode] = { name: r.CardName, email: r.E_Mail || '' }; });
}
return out;
}
// ── SAP-mode product list ───────────────────────────────────────────────────
async function listSapProducts(company, itemGroups, priceList, divisionId) {
const groups = (itemGroups || []).map(Number).filter(Boolean);
if (!groups.length) return [];
const inputs = { pl: parseInt(priceList) || 1 };
groups.forEach((g, i) => { inputs[`g${i}`] = g; });
const rows = await sq(company, `SELECT i.ItemCode, i.ItemName, i.FrgnName, i.SalPackUn, i.NumInSale, p.Price, h.ChapterID
FROM OITM i LEFT JOIN ITM1 p ON p.ItemCode=i.ItemCode AND p.PriceList=@pl LEFT JOIN OCHP h ON h.AbsEntry=i.ChapterID
WHERE i.SellItem='Y' AND i.frozenFor='N' AND i.ItmsGrpCod IN (${groups.map((_, i) => `@g${i}`).join(',')}) ORDER BY i.ItemCode`, inputs);
return rows.map(r => ({ key: `S:${r.ItemCode}`, id: null, catalog: 'domestic', divisionId: Number(divisionId), itemCode: r.ItemCode, displayCode: r.ItemCode,
description: r.ItemName || '', shortDesc: r.FrgnName || '', packSize: Number(r.SalPackUn) || 1, hsn: r.ChapterID || '', price: Number(r.Price) || 0,
isInstrument: false, active: true }));
}
// ── Posting the order to SAP ────────────────────────────────────────────────
// One SAP Sales Order per order — or, for a multi-consignee Direct order, one
// per consignee (each with its own ShipToCode and item allocation). Every SAP
// order carries U_WEB_SO_NO = order.id and NumAtCard = our reference number.
function ymd(d) { if (!d) return null; const x = new Date(d); return isNaN(x) ? null : x.toISOString().slice(0, 10); }
function buildSapPayloads(order, items, consignees, settings, division) {
const effPrice = it => (Number(it.specialPrice) > 0 ? Number(it.specialPrice) : Number(it.price));
const base = {
CardCode: order.cardCode,
DocDate: ymd(new Date()),
DocDueDate: ymd(order.deliveryDate) || ymd(new Date()),
NumAtCard: order.refNo,
PayToCode: order.billToCode || undefined,
Comments: `Web SO ${order.refNo}${order.custOrderNo ? ' / Cust PO ' + order.custOrderNo : ''}`.slice(0, 254),
U_WEB_SO_NO: order.id,
U_WebCreated: 'Y',
U_SalesType: sapSalesType(order.soType, order.orderType),
U_Saletype: '1.',
U_CustomerType: 'DOMESTIC',
};
if (settings.sapSeries) base.Series = parseInt(settings.sapSeries);
const line = (it, qty) => ({
ItemCode: it.itemCode, Quantity: Number(qty), UnitPrice: effPrice(it), TaxCode: order.taxCode || undefined,
WarehouseCode: (division && division.warehouse) || undefined,
});
const active = consignees.filter(c => c.active !== false && Array.isArray(c.items) && c.items.length);
if (order.isMultiConsignee && active.length) {
return active.map(c => {
const lines = c.items.map(ci => {
const it = items.find(i => i.itemCode === ci.itemCode);
return it && Number(ci.qty) > 0 ? line(it, ci.qty) : null;
}).filter(Boolean);
return { consigneeId: c.id, payload: { ...base, ShipToCode: c.shipToCode || order.shipToCode || undefined, DocumentLines: lines } };
}).filter(p => p.payload.DocumentLines.length);
}
return [{ consigneeId: null, payload: { ...base, ShipToCode: order.shipToCode || undefined, DocumentLines: items.map(it => line(it, it.qty)) } }];
}
async function postOrderToSap(company, payloads) {
const results = [];
for (const p of payloads) {
const r = await sapRequest('POST', 'Orders', p.payload, company);
results.push({ consigneeId: p.consigneeId, docEntry: r.DocEntry, docNum: r.DocNum, docDate: r.DocDate });
}
return results;
}
// SAP orders already carrying U_WEB_SO_NO for these IDs — used to avoid ever
// double-posting (e.g. someone keyed it in SAP manually, or a retry after a
// timeout that actually succeeded) and for the status sync.
async function sapOrdersByWebNo(company, ids) {
const list = [...new Set(ids.map(Number).filter(Boolean))];
if (!list.length) return [];
const out = [];
for (let i = 0; i < list.length; i += 500) {
const chunk = list.slice(i, i + 500); const inputs = {};
chunk.forEach((v, j) => { inputs[`i${j}`] = v; });
out.push(...await sq(company, `SELECT DocEntry, DocNum, DocDate, DocStatus, CANCELED, U_WEB_SO_NO, DocTotal FROM ORDR
WHERE U_WEB_SO_NO IN (${chunk.map((_, j) => `@i${j}`).join(',')})`, inputs));
}
return out.map(r => ({ docEntry: r.DocEntry, docNum: r.DocNum, docDate: r.DocDate, open: r.DocStatus === 'O', cancelled: r.CANCELED === 'Y', webNo: r.U_WEB_SO_NO, total: Number(r.DocTotal) }));
}
// Open (un-delivered) qty per item for posted orders — pending-order reports.
async function openQtyByWebNo(company, ids) {
const list = [...new Set(ids.map(Number).filter(Boolean))];
if (!list.length) return [];
const out = [];
for (let i = 0; i < list.length; i += 500) {
const chunk = list.slice(i, i + 500); const inputs = {};
chunk.forEach((v, j) => { inputs[`i${j}`] = v; });
out.push(...await sq(company, `SELECT o.U_WEB_SO_NO, o.DocNum, l.ItemCode, l.Dscription, l.Quantity, l.OpenQty, l.Price
FROM ORDR o JOIN RDR1 l ON l.DocEntry=o.DocEntry WHERE o.CANCELED='N' AND o.U_WEB_SO_NO IN (${chunk.map((_, j) => `@i${j}`).join(',')})`, inputs));
}
return out.map(r => ({ webNo: r.U_WEB_SO_NO, sapDocNum: r.DocNum, itemCode: r.ItemCode, description: r.Dscription, qty: Number(r.Quantity), openQty: Number(r.OpenQty), price: Number(r.Price) }));
}
// ── Invoices / dispatch / COA ───────────────────────────────────────────────
// Invoices raised against the order (OINV carries U_WEB_SO_NO, set by SAP
// copy-from; also matched through base-document links for safety).
async function invoicesForOrder(company, orderId) {
const rows = await sq(company, `SELECT v.DocEntry, v.DocNum, v.DocDate, v.DocTotal, v.CANCELED, v.U_RrGrAwb, v.U_LR_Date, v.U_TrnspDetails,
v.U_VehRegNo, v.U_EWAYNO, v.AtcEntry
FROM OINV v WHERE v.CANCELED='N' AND (v.U_WEB_SO_NO=@id OR v.DocEntry IN (
SELECT i.DocEntry FROM INV1 i JOIN DLN1 d ON d.DocEntry=i.BaseEntry AND d.LineNum=i.BaseLine AND i.BaseType=15
JOIN ORDR o ON o.DocEntry=d.BaseEntry AND d.BaseType=17 WHERE o.U_WEB_SO_NO=@id
UNION SELECT i.DocEntry FROM INV1 i JOIN ORDR o ON o.DocEntry=i.BaseEntry AND i.BaseType=17 WHERE o.U_WEB_SO_NO=@id))
ORDER BY v.DocDate, v.DocNum`, { id: parseInt(orderId) });
if (!rows.length) return [];
const lines = await sq(company, `SELECT i.DocEntry, i.ItemCode, i.Dscription, i.Quantity, i.Price, i.LineTotal FROM INV1 i
WHERE i.DocEntry IN (${rows.map(r => r.DocEntry).join(',')})`);
return rows.map(r => ({
docEntry: r.DocEntry, invoiceNo: r.DocNum, invoiceDate: r.DocDate, total: Number(r.DocTotal),
dispatch: { lrNo: r.U_RrGrAwb || '', lrDate: r.U_LR_Date || null, transporter: r.U_TrnspDetails || '', vehicle: r.U_VehRegNo || '', ewayBill: r.U_EWAYNO || '' },
hasAttachments: !!r.AtcEntry, atcEntry: r.AtcEntry || null,
lines: lines.filter(l => l.DocEntry === r.DocEntry).map(l => ({ itemCode: l.ItemCode, description: l.Dscription, qty: Number(l.Quantity), price: Number(l.Price), total: Number(l.LineTotal) })),
}));
}
// All (non-cancelled) A/R invoices for many web orders at once — invoice
// email detection. Matches OINV.U_WEB_SO_NO and, for safety, invoices copied
// from the order directly or via a delivery. Header fields only.
async function invoicesForWebNos(company, ids) {
const list = [...new Set(ids.map(Number).filter(Boolean))];
if (!list.length) return [];
const out = [];
for (let i = 0; i < list.length; i += 300) {
const chunk = list.slice(i, i + 300); const inputs = {};
chunk.forEach((v, j) => { inputs[`i${j}`] = v; });
const IN = chunk.map((_, j) => `@i${j}`).join(',');
out.push(...await sq(company, `SELECT x.WebNo, v.DocEntry, v.DocNum, v.DocDate, v.DocTotal, v.U_RrGrAwb, v.U_LR_Date, CAST(v.U_TrnspDetails AS nvarchar(400)) AS Transporter, v.U_VehRegNo, v.U_EWAYNO
FROM (SELECT DISTINCT WebNo, DocEntry FROM (
SELECT v1.U_WEB_SO_NO AS WebNo, v1.DocEntry FROM OINV v1 WHERE v1.U_WEB_SO_NO IN (${IN})
UNION SELECT o.U_WEB_SO_NO, i1.DocEntry FROM INV1 i1 JOIN ORDR o ON i1.BaseType=17 AND o.DocEntry=i1.BaseEntry WHERE o.U_WEB_SO_NO IN (${IN})
UNION SELECT o.U_WEB_SO_NO, i1.DocEntry FROM INV1 i1 JOIN DLN1 d ON i1.BaseType=15 AND d.DocEntry=i1.BaseEntry AND d.LineNum=i1.BaseLine
JOIN ORDR o ON d.BaseType=17 AND o.DocEntry=d.BaseEntry WHERE o.U_WEB_SO_NO IN (${IN})) u) x
JOIN OINV v ON v.DocEntry=x.DocEntry WHERE v.CANCELED='N'`, inputs));
}
return out.map(r => ({ webNo: r.WebNo, docEntry: r.DocEntry, invoiceNo: r.DocNum, invoiceDate: r.DocDate, total: Number(r.DocTotal),
dispatch: { lrNo: r.U_RrGrAwb || '', lrDate: r.U_LR_Date || null, transporter: r.Transporter || '', vehicle: r.U_VehRegNo || '', ewayBill: r.U_EWAYNO || '' } }));
}
async function invoiceLines(company, docEntry) {
return (await sq(company, `SELECT ItemCode, Dscription, Quantity, LineTotal FROM INV1 WHERE DocEntry=@d ORDER BY LineNum`, { d: parseInt(docEntry) }))
.map(l => ({ itemCode: l.ItemCode, description: l.Dscription, qty: Number(l.Quantity), total: Number(l.LineTotal) }));
}
// Batches shipped on an invoice (direct batch rows on the invoice, or on the
// delivery it was copied from), each with its COA file if one was attached in
// SAP — the COA attachment's FileName is the batch number (same rule the
// coa-upload-job uses: ATC1.FileName = OIGN.U_BTCHNO = batch).
async function batchesWithCoa(company, invoiceDocEntries) {
const ids = invoiceDocEntries.map(Number).filter(Boolean);
if (!ids.length) return [];
const rows = await sq(company, `SELECT DISTINCT x.InvEntry, b.ItemCode, b.DistNumber, b.MnfDate, b.ExpDate, a.AbsEntry AS AtcAbs, a.Line AS AtcLine,
a.trgtPath, a.FileName, a.FileExt
FROM (
SELECT L.DocEntry AS InvEntry, L.LogEntry FROM OITL L WHERE L.DocType=13 AND L.DocEntry IN (${ids.join(',')})
UNION SELECT i.DocEntry, L.LogEntry FROM INV1 i JOIN OITL L ON L.DocType=15 AND L.DocEntry=i.BaseEntry AND i.BaseType=15 WHERE i.DocEntry IN (${ids.join(',')})
) x JOIN ITL1 t ON t.LogEntry=x.LogEntry JOIN OBTN b ON b.ItemCode=t.ItemCode AND b.SysNumber=t.SysNumber
OUTER APPLY (SELECT TOP 1 a1.AbsEntry, a1.Line, CAST(a1.trgtPath AS nvarchar(400)) AS trgtPath, CAST(a1.FileName AS nvarchar(254)) AS FileName,
CAST(a1.FileExt AS nvarchar(30)) AS FileExt FROM ATC1 a1 WHERE CAST(a1.FileName AS nvarchar(254))=b.DistNumber ORDER BY a1.AbsEntry DESC, a1.Line DESC) a`);
return rows.map(r => ({ invoiceDocEntry: r.InvEntry, itemCode: r.ItemCode, batch: r.DistNumber, mfgDate: r.MnfDate, expDate: r.ExpDate,
coa: r.FileName ? { absEntry: r.AtcAbs, line: r.AtcLine, fileName: r.FileName, ext: r.FileExt || 'pdf', path: r.trgtPath || '' } : null }));
}
async function attachmentLines(company, absEntry) {
return (await sq(company, `SELECT AbsEntry, Line, trgtPath, FileName, FileExt FROM ATC1 WHERE AbsEntry=@a ORDER BY Line`, { a: parseInt(absEntry) }))
.map(r => ({ absEntry: r.AbsEntry, line: r.Line, path: r.trgtPath || '', fileName: r.FileName, ext: r.FileExt || '' }));
}
async function attachmentLine(company, absEntry, line) {
const r = await sq(company, `SELECT AbsEntry, Line, trgtPath, FileName, FileExt FROM ATC1 WHERE AbsEntry=@a AND Line=@l`, { a: parseInt(absEntry), l: parseInt(line) });
return r[0] ? { absEntry: r[0].AbsEntry, line: r[0].Line, path: r[0].trgtPath || '', fileName: r[0].FileName, ext: r[0].FileExt || '' } : null;
}
// Locate an attachment's bytes. ATC1.trgtPath is the SAP server's view
// (often a local "D:\COAUpload"), so also try the UNC share(s) from .env.
function readAttachment(att) {
const name = att.ext ? `${att.fileName}.${att.ext}` : att.fileName;
const bases = [att.path, process.env.SAP_ATTACHMENT_SERVER_PATH, process.env.SAP_ATTACHMENT_PATH,
...String(process.env.SALES_COA_PATHS || '').split(',')].map(s => String(s || '').trim()).filter(Boolean);
for (const b of [...new Set(bases)]) {
const full = path.join(b, name);
try { if (fs.existsSync(full)) return { buffer: fs.readFileSync(full), name, full }; } catch (_e) {}
}
throw new Error(`Attachment file not found: ${name}`);
}
module.exports = {
searchCustomers, getCustomer, customerNames, formatAddress, listSapProducts,
buildSapPayloads, postOrderToSap, sapOrdersByWebNo, openQtyByWebNo,
invoicesForOrder, invoicesForWebNos, invoiceLines, batchesWithCoa, attachmentLines, attachmentLine, readAttachment,
};
+124
View File
@@ -0,0 +1,124 @@
// services/sales/schema.js
// Idempotent DDL + seed for every Sales Order module table (ZSO_*), run once
// at server start (server.js bootstrap). New ERP-created orders/samples start
// at ID 50001 so they can never collide with migrated msale IDs (msale was at
// ~11.7k in 2026) — the ID is what goes into SAP ORDR.U_WEB_SO_NO.
'use strict';
const { query } = require('./db');
const { USER_TYPES, DEFAULT_DIVISIONS, DEFAULT_SETTINGS } = require('./constants');
const T = (name, body) => `IF OBJECT_ID('dbo.${name}','U') IS NULL CREATE TABLE dbo.${name} (${body})`;
const DDL = [
T('ZSO_SETTINGS', `SKEY NVARCHAR(100) NOT NULL PRIMARY KEY, SVALUE NVARCHAR(MAX) NULL, UPDATED_AT DATETIME2 DEFAULT SYSDATETIME(), UPDATED_BY NVARCHAR(100) NULL`),
T('ZSO_DIVISIONS', `ID INT NOT NULL PRIMARY KEY, NAME NVARCHAR(100) NOT NULL, CODE NVARCHAR(10) NULL, ITEM_GROUPS NVARCHAR(200) NULL,
WAREHOUSE NVARCHAR(16) NULL, SORT INT DEFAULT 0, ACTIVE BIT DEFAULT 1`),
T('ZSO_PRODUCTS', `ID INT IDENTITY(1,1) PRIMARY KEY, CATALOG NVARCHAR(10) NOT NULL DEFAULT 'domestic', DIVISION_ID INT NOT NULL,
ITEM_CODE NVARCHAR(100) NOT NULL, DISPLAY_CODE NVARCHAR(100) NULL, DESCRIPTION NVARCHAR(400) NULL, SHORT_DESC NVARCHAR(400) NULL,
PACK_SIZE INT DEFAULT 1, HSN NVARCHAR(50) NULL, UNIT_PRICE DECIMAL(19,4) DEFAULT 0, IS_INSTRUMENT BIT DEFAULT 0, ACTIVE BIT DEFAULT 1,
LEGACY_ID INT NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), UPDATED_AT DATETIME2 NULL, UPDATED_BY NVARCHAR(100) NULL`),
T('ZSO_USER_TYPES', `ID INT NOT NULL PRIMARY KEY, NAME NVARCHAR(100) NOT NULL, SCOPE NVARCHAR(10) NOT NULL DEFAULT 'all',
LIST_STATUSES NVARCHAR(100) NULL, DEFAULT_STEPS NVARCHAR(MAX) NULL, DEFAULT_MODULES NVARCHAR(MAX) NULL, ACTIVE BIT DEFAULT 1`),
T('ZSO_USER_PROFILES', `USER_ID INT NOT NULL PRIMARY KEY, USER_TYPE_ID INT NULL, DIVISIONS NVARCHAR(200) NULL, EMP_CODE NVARCHAR(30) NULL,
TERRITORY NVARCHAR(150) NULL, CC_EMAILS NVARCHAR(500) NULL, LEGACY_EMP_ID INT NULL, UPDATED_AT DATETIME2 NULL, UPDATED_BY NVARCHAR(100) NULL`),
T('ZSO_SALES_PERSONS', `ID INT IDENTITY(1,1) PRIMARY KEY, NAME NVARCHAR(200) NOT NULL, EMAIL NVARCHAR(150) NULL, DESIGNATION NVARCHAR(100) NULL,
DIVISIONS NVARCHAR(100) NULL, USER_ID INT NULL, ACTIVE BIT DEFAULT 1, LEGACY_ID INT NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME()`),
T('ZSO_SP_CUSTOMER_MAP', `ID INT IDENTITY(1,1) PRIMARY KEY, SALES_PERSON_ID INT NOT NULL, CARD_CODE NVARCHAR(30) NOT NULL, ACTIVE BIT DEFAULT 1,
CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), CREATED_BY NVARCHAR(100) NULL`),
T('ZSO_CUSTOMERS', `CARD_CODE NVARCHAR(30) NOT NULL PRIMARY KEY, CARD_NAME NVARCHAR(200) NULL, EMAIL NVARCHAR(200) NULL, PASSWORD_HASH NVARCHAR(200) NULL,
ACTIVE BIT DEFAULT 1, LOCKED BIT DEFAULT 0, LOGIN_ATTEMPTS INT DEFAULT 0, MUST_CHANGE_PWD BIT DEFAULT 0, LAST_LOGIN DATETIME2 NULL,
CREDIT_LIMIT DECIMAL(19,2) DEFAULT 0, LEGACY_ID INT NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), UPDATED_AT DATETIME2 NULL, UPDATED_BY NVARCHAR(100) NULL`),
T('ZSO_ORDERS', `ID INT IDENTITY(50001,1) PRIMARY KEY, REF_NO NVARCHAR(30) NULL, COMPANY NVARCHAR(100) NULL,
SO_TYPE TINYINT NOT NULL, ORDER_TYPE NVARCHAR(12) NULL, CARD_CODE NVARCHAR(30) NOT NULL, CARD_NAME NVARCHAR(200) NULL, DIVISION_ID INT NOT NULL,
CUST_ORDER_NO NVARCHAR(100) NULL, ORDER_DATE DATE NULL, DELIVERY_DATE DATE NULL, CONTACT_PERSON NVARCHAR(200) NULL, SALES_EMPLOYEE NVARCHAR(200) NULL,
SHIP_TO_CODE NVARCHAR(100) NULL, SHIP_TO_TEXT NVARCHAR(1000) NULL, SHIP_STATE NVARCHAR(10) NULL, BILL_TO_CODE NVARCHAR(100) NULL, BILL_TO_TEXT NVARCHAR(1000) NULL,
TAX_CODE NVARCHAR(30) NULL, IGST_RATE DECIMAL(9,3) DEFAULT 0, CGST_RATE DECIMAL(9,3) DEFAULT 0, SGST_RATE DECIMAL(9,3) DEFAULT 0, TCS_RATE DECIMAL(9,3) DEFAULT 0,
IGST_VAL DECIMAL(19,2) DEFAULT 0, CGST_VAL DECIMAL(19,2) DEFAULT 0, SGST_VAL DECIMAL(19,2) DEFAULT 0, TCS_VAL DECIMAL(19,2) DEFAULT 0,
SUB_TOTAL DECIMAL(19,2) DEFAULT 0, GRAND_TOTAL DECIMAL(19,2) DEFAULT 0, CREDIT_DAYS INT NULL,
PAYMENT_MODE NVARCHAR(30) NULL, PAYMENT_REF NVARCHAR(150) NULL, PAYMENT_DATE DATE NULL, PAYMENT_DETAIL NVARCHAR(500) NULL,
PAID_AMOUNT DECIMAL(19,2) NULL, TDS_VAL DECIMAL(19,2) DEFAULT 0, WALLET_USED DECIMAL(19,2) NULL, REBATE DECIMAL(19,2) NULL, PAID_STATUS NVARCHAR(30) NULL,
CUSTOMER_REMARKS NVARCHAR(MAX) NULL, EMPLOYEE_REMARKS NVARCHAR(MAX) NULL, INTERNAL_REMARKS NVARCHAR(MAX) NULL,
STATUS INT NOT NULL DEFAULT 1, IS_MULTI_CONSIGNEE BIT DEFAULT 0, NEEDS_REVIEW BIT DEFAULT 0,
SAP_DOC_ENTRY INT NULL, SAP_DOC_NUM INT NULL, SAP_ORDER_DATE DATE NULL, SAP_POSTED_AT DATETIME2 NULL, SAP_POSTED_BY NVARCHAR(100) NULL, SAP_ERROR NVARCHAR(MAX) NULL,
INVOICE_JSON NVARCHAR(MAX) NULL, LAST_SAP_SYNC DATETIME2 NULL,
CREATED_BY_TYPE NVARCHAR(10) NULL, CREATED_BY NVARCHAR(100) NULL, CREATED_BY_NAME NVARCHAR(200) NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(),
UPDATED_AT DATETIME2 NULL, UPDATED_BY_NAME NVARCHAR(200) NULL, LEGACY BIT DEFAULT 0`),
T('ZSO_ORDER_ITEMS', `ID INT IDENTITY(1,1) PRIMARY KEY, ORDER_ID INT NOT NULL, LINE_NO INT NOT NULL, PRODUCT_ID INT NULL, ITEM_CODE NVARCHAR(100) NOT NULL,
DISPLAY_CODE NVARCHAR(100) NULL, DESCRIPTION NVARCHAR(400) NULL, QTY DECIMAL(19,3) NOT NULL, NOP INT NULL, PRICE DECIMAL(19,4) DEFAULT 0,
SPECIAL_PRICE DECIMAL(19,4) DEFAULT 0, COMMISSION DECIMAL(9,3) DEFAULT 0, HSN NVARCHAR(50) NULL, LINE_TOTAL DECIMAL(19,2) DEFAULT 0, ACTIVE BIT DEFAULT 1`),
T('ZSO_ORDER_CONSIGNEES', `ID INT IDENTITY(1,1) PRIMARY KEY, ORDER_ID INT NOT NULL, SHIP_TO_CODE NVARCHAR(100) NULL, SHIP_TO_TEXT NVARCHAR(1000) NULL,
SE_NAME NVARCHAR(200) NULL, SE_EMAIL NVARCHAR(200) NULL, ITEMS_JSON NVARCHAR(MAX) NULL, SAP_DOC_ENTRY INT NULL, SAP_DOC_NUM INT NULL, ACTIVE BIT DEFAULT 1`),
T('ZSO_DOCS', `ID INT IDENTITY(1,1) PRIMARY KEY, ENTITY NVARCHAR(20) NOT NULL, ENTITY_ID INT NOT NULL, DOC_TYPE NVARCHAR(30) NOT NULL,
TITLE NVARCHAR(300) NULL, FILE_NAME NVARCHAR(300) NOT NULL, ORIG_NAME NVARCHAR(300) NULL, ACTIVE BIT DEFAULT 1,
CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), CREATED_BY_NAME NVARCHAR(200) NULL`),
T('ZSO_LOG', `ID INT IDENTITY(1,1) PRIMARY KEY, ENTITY NVARCHAR(20) NOT NULL, ENTITY_ID INT NOT NULL, ACTION NVARCHAR(50) NOT NULL,
FROM_STATUS INT NULL, TO_STATUS INT NULL, REMARKS NVARCHAR(MAX) NULL, ACTOR_TYPE NVARCHAR(10) NULL, ACTOR NVARCHAR(100) NULL,
ACTOR_NAME NVARCHAR(200) NULL, AT DATETIME2 DEFAULT SYSDATETIME()`),
T('ZSO_WALLET_TXN', `ID INT IDENTITY(1,1) PRIMARY KEY, CARD_CODE NVARCHAR(30) NOT NULL, TXN_TYPE NVARCHAR(10) NOT NULL, AMOUNT DECIMAL(19,2) NOT NULL,
TDS DECIMAL(19,2) DEFAULT 0, STATUS NVARCHAR(12) NOT NULL, REF_TYPE NVARCHAR(15) NOT NULL, ORDER_ID INT NULL,
PAYMENT_MODE NVARCHAR(30) NULL, PAYMENT_REF NVARCHAR(150) NULL, PAYMENT_DATE DATE NULL, PAYMENT_DETAIL NVARCHAR(500) NULL, REMARKS NVARCHAR(1000) NULL,
BANK_API_ID NVARCHAR(50) NULL, DOC_ID INT NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), CREATED_BY_NAME NVARCHAR(200) NULL,
DECIDED_AT DATETIME2 NULL, DECIDED_BY_NAME NVARCHAR(200) NULL`),
T('ZSO_SAMPLES', `ID INT IDENTITY(50001,1) PRIMARY KEY, REF_NO NVARCHAR(30) NULL, CARD_CODE NVARCHAR(30) NOT NULL, CARD_NAME NVARCHAR(200) NULL,
ADDRESS NVARCHAR(1000) NULL, BB_LICENCE_NO NVARCHAR(200) NULL, BB_NAME NVARCHAR(200) NULL, DIVISION_ID INT NOT NULL, REQ_DATE DATE NULL,
DELIVERY_DAYS INT NULL, STATUS INT NOT NULL DEFAULT 1, INTERNAL_REMARKS NVARCHAR(MAX) NULL, MODIFICATION_REMARKS NVARCHAR(1000) NULL,
REJECTION_REMARKS NVARCHAR(1000) NULL, STOCK_AVAILABLE BIT NULL, QA_DUE_DATE DATE NULL, QA_VERIFIED BIT NULL, OUT_OF_STOCK_DATE DATE NULL,
CHALLAN_NO NVARCHAR(100) NULL, CHALLAN_DATE DATE NULL, COURIER_NO NVARCHAR(100) NULL, DISPATCH_INFO NVARCHAR(500) NULL, FEEDBACK NVARCHAR(1000) NULL,
CREATED_BY INT NULL, CREATED_BY_NAME NVARCHAR(200) NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), UPDATED_AT DATETIME2 NULL,
UPDATED_BY_NAME NVARCHAR(200) NULL, LEGACY BIT DEFAULT 0`),
T('ZSO_SAMPLE_ITEMS', `ID INT IDENTITY(1,1) PRIMARY KEY, SAMPLE_ID INT NOT NULL, KIND NVARCHAR(10) NOT NULL DEFAULT 'domestic', PRODUCT_ID INT NULL,
ITEM_CODE NVARCHAR(100) NOT NULL, DISPLAY_CODE NVARCHAR(100) NULL, DESCRIPTION NVARCHAR(400) NULL, QTY DECIMAL(19,3) NOT NULL, NOP INT NULL,
BATCH_NO NVARCHAR(200) NULL, ACTIVE BIT DEFAULT 1`),
T('ZSO_EXPORT_SAMPLES', `ID INT IDENTITY(50001,1) PRIMARY KEY, REF_NO NVARCHAR(30) NULL, REGION NVARCHAR(500) NULL, SAMPLE_AGAINST NVARCHAR(500) NULL,
CUST_NAME NVARCHAR(200) NOT NULL, COUNTRY NVARCHAR(200) NULL, SHIPPING_ADDRESS NVARCHAR(1000) NULL, CONTACT_PERSON NVARCHAR(200) NULL,
CONTACT_EMAIL NVARCHAR(200) NULL, CONTACT_NO NVARCHAR(100) NULL, ACCOUNT_DETAILS NVARCHAR(300) NULL, COURIER_BY_CUSTOMER BIT DEFAULT 0,
DIVISION_ID INT NULL, REQ_DATE DATE NULL, DELIVERY_DATE DATE NULL, DELIVERY_DAYS INT NULL, REMARKS_QA NVARCHAR(1000) NULL,
REMARKS_LOGISTICS NVARCHAR(500) NULL, STATUS INT NOT NULL DEFAULT 1, STOCK_AVAILABLE BIT NULL, QA_DUE_DATE DATE NULL, QA_VERIFIED BIT NULL,
CHALLAN_NO NVARCHAR(100) NULL, CHALLAN_DATE DATE NULL, COURIER_NO NVARCHAR(100) NULL, DISPATCH_DETAILS NVARCHAR(500) NULL,
CREATED_BY INT NULL, CREATED_BY_NAME NVARCHAR(200) NULL, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), UPDATED_AT DATETIME2 NULL,
UPDATED_BY_NAME NVARCHAR(200) NULL, LEGACY BIT DEFAULT 0`),
// One row per SAP invoice already announced by email (never mail twice).
T('ZSO_INVOICE_NOTICES', `ID INT IDENTITY(1,1) PRIMARY KEY, ORDER_ID INT NOT NULL, INVOICE_DOC_ENTRY INT NOT NULL, INVOICE_NO INT NULL,
INVOICE_DATE DATE NULL, EMAILED BIT DEFAULT 0, CREATED_AT DATETIME2 DEFAULT SYSDATETIME(), CONSTRAINT UQ_ZSO_INV_NOTICE UNIQUE (ORDER_ID, INVOICE_DOC_ENTRY)`),
T('ZSO_INVOICE_FILES', `ID INT IDENTITY(1,1) PRIMARY KEY, INVOICE_NO NVARCHAR(50) NOT NULL, FILE_NAME NVARCHAR(300) NOT NULL,
UPLOADED_AT DATETIME2 DEFAULT SYSDATETIME()`),
];
const INDEXES = [
['IX_ZSO_ORDERS_CARD', 'ZSO_ORDERS(CARD_CODE, STATUS)'],
['IX_ZSO_ORDERS_STATUS', 'ZSO_ORDERS(STATUS, DIVISION_ID)'],
['IX_ZSO_ORDER_ITEMS_ORD', 'ZSO_ORDER_ITEMS(ORDER_ID)'],
['IX_ZSO_WALLET_CARD', 'ZSO_WALLET_TXN(CARD_CODE, STATUS)'],
['IX_ZSO_LOG_ENT', 'ZSO_LOG(ENTITY, ENTITY_ID)'],
['IX_ZSO_DOCS_ENT', 'ZSO_DOCS(ENTITY, ENTITY_ID)'],
['IX_ZSO_SPMAP_SP', 'ZSO_SP_CUSTOMER_MAP(SALES_PERSON_ID, CARD_CODE)'],
['IX_ZSO_PRODUCTS_DIV', 'ZSO_PRODUCTS(CATALOG, DIVISION_ID)'],
['IX_ZSO_SAMPLE_ITEMS_S', 'ZSO_SAMPLE_ITEMS(SAMPLE_ID, KIND)'],
];
async function bootstrap() {
for (const ddl of DDL) await query(ddl);
for (const [name, on] of INDEXES) {
const table = on.split('(')[0];
await query(`IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name='${name}' AND object_id=OBJECT_ID('dbo.${table}')) CREATE INDEX ${name} ON dbo.${on}`);
}
// Seeds — only insert what's missing, never overwrite admin edits.
for (const d of DEFAULT_DIVISIONS) {
await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_DIVISIONS WHERE ID=?) INSERT INTO dbo.ZSO_DIVISIONS (ID,NAME,CODE,ITEM_GROUPS,WAREHOUSE,SORT,ACTIVE) VALUES (?,?,?,?,?,?,1)`,
[d.id, d.id, d.name, d.code, d.itemGroups, d.warehouse, d.sort]);
}
for (const u of USER_TYPES) {
await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_USER_TYPES WHERE ID=?) INSERT INTO dbo.ZSO_USER_TYPES (ID,NAME,SCOPE,LIST_STATUSES,DEFAULT_STEPS,DEFAULT_MODULES,ACTIVE) VALUES (?,?,?,?,?,?,1)`,
[u.id, u.id, u.name, u.scope, u.listStatuses, JSON.stringify(u.steps), JSON.stringify(u.modules)]);
}
// Invoice emails start from the day the feature is first deployed — older
// invoices are recorded silently so go-live doesn't send a backlog flood.
await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_SETTINGS WHERE SKEY='invoiceEmailSince') INSERT INTO dbo.ZSO_SETTINGS (SKEY,SVALUE) VALUES ('invoiceEmailSince',?)`,
[JSON.stringify(new Date().toISOString().slice(0, 10))]);
for (const [k, v] of Object.entries(DEFAULT_SETTINGS)) {
await query(`IF NOT EXISTS (SELECT 1 FROM dbo.ZSO_SETTINGS WHERE SKEY=?) INSERT INTO dbo.ZSO_SETTINGS (SKEY,SVALUE) VALUES (?,?)`, [k, k, JSON.stringify(v)]);
}
console.log('[SALES] ✅ Sales Order module tables ready');
}
module.exports = { bootstrap };
+1
View File
@@ -893,6 +893,7 @@ module.exports = {
sapRequest,
sapRequestAs,
runWithSapUser,
currentSapCtx,
testSapLogin,
getNextCardCode,
getNextVendorCardCode,
+178
View File
@@ -0,0 +1,178 @@
// services/shortStockAlertStore.js
// Independent, fully automated feature (NOT tied to the Inventory Status
// Report screen at all): admin defines a watch-list of item codes/item
// groups in System Settings, each with its own concerned-user recipients.
// A background job (started from server.js, see checkShortStock() below)
// periodically sums each watched item's on-hand quantity (OITW.OnHand,
// across every warehouse) and emails the concerned users the FIRST time it
// hits zero — not on every check while it stays at zero, and not gated by
// the stage-change notification system (services/notifyStore.js) at all,
// since this isn't a workflow-stage event.
'use strict';
const sql = require('mssql');
const appSettings = require('./appSettingsStore');
const hanaUsers = require('./hanaUsers');
const mailer = require('./mailer');
const { getPool: getSapPool } = require('./sqlPool');
const TABLE = `[dbo].[ZSHORT_STOCK_STATE]`;
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('there is already an object');
}
async function bootstrap() {
console.log('[SHORT-STOCK-ALERT] Checking table', TABLE, '...');
await exec(`
CREATE TABLE ${TABLE} (
ITEM_CODE NVARCHAR(60) NOT NULL,
COMPANY NVARCHAR(60) NOT NULL,
WAS_ZERO BIT DEFAULT 0,
LAST_ALERT_AT DATETIME2,
CONSTRAINT PK_SHORT_STOCK_STATE PRIMARY KEY (ITEM_CODE, COMPANY)
)
`).catch(e => { if (!isAlreadyExists(e)) throw e; });
console.log('[SHORT-STOCK-ALERT] ✅ Ready');
}
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// Resolves every configured rule into a flat map of itemCode → Set of
// recipient usernames (a item matched by more than one rule — e.g. both a
// direct ITEM rule and a GROUP rule it happens to belong to — gets every
// rule's recipients merged, mailed once).
async function resolveWatchedItems(company) {
const rules = appSettings.shortStockAlertRules();
const map = new Map(); // itemCode -> Set(usernames)
if (!rules.length) return map;
const itemRules = rules.filter(r => r.type === 'ITEM' && r.value);
const groupRules = rules.filter(r => r.type === 'GROUP' && r.value);
itemRules.forEach(r => {
const set = map.get(r.value) || new Set();
(r.recipients || []).forEach(u => set.add(u));
map.set(r.value, set);
});
if (groupRules.length) {
const pool = await getSapPool(company);
for (const r of groupRules) {
const grp = parseInt(r.value);
if (isNaN(grp)) continue;
try {
const rows = await pool.request().query(`SELECT "ItemCode" FROM [dbo].[OITM] WHERE "ItmsGrpCod"=${grp}`);
rows.recordset.forEach(row => {
const set = map.get(row.ItemCode) || new Set();
(r.recipients || []).forEach(u => set.add(u));
map.set(row.ItemCode, set);
});
} catch (e) { console.warn('[SHORT-STOCK-ALERT] group resolve failed for', r.value, e.message); }
}
}
return map;
}
async function getState(itemCode, company) {
const rows = await exec(`SELECT * FROM ${TABLE} WHERE ITEM_CODE=? AND COMPANY=?`, [itemCode, company]);
return rows[0] || null;
}
async function upsertState(itemCode, company, wasZero, alertedNow) {
const existing = await getState(itemCode, company);
const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23);
if (existing) {
await exec(
`UPDATE ${TABLE} SET WAS_ZERO=?${alertedNow ? ', LAST_ALERT_AT=?' : ''} WHERE ITEM_CODE=? AND COMPANY=?`,
alertedNow ? [wasZero ? 1 : 0, now, itemCode, company] : [wasZero ? 1 : 0, itemCode, company]
);
} else {
await exec(
`INSERT INTO ${TABLE} (ITEM_CODE, COMPANY, WAS_ZERO, LAST_ALERT_AT) VALUES (?,?,?,?)`,
[itemCode, company, wasZero ? 1 : 0, alertedNow ? now : null]
);
}
}
async function sendShortStockMail(itemCode, itemName, recipients, company) {
const all = await hanaUsers.listUsers();
const want = new Set([...recipients].map(u => u.toLowerCase()));
const users = all.filter(u => u.active && u.emailNotify !== false && want.has((u.username || '').toLowerCase()) && u.email);
if (!users.length) return false;
const html = `<div style="font-family:Segoe UI,Arial,sans-serif;max-width:520px">
<h2 style="margin:0 0 12px;font-size:16px;color:#1a2b4a">⚠ Out of Stock — ${esc(itemCode)}</h2>
<table>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Item Code</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(itemCode)}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Description</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(itemName)}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">Company</td><td style="padding:3px 0;font-size:13px;font-weight:600">${esc(company)}</td></tr>
<tr><td style="padding:3px 10px 3px 0;color:#667;font-size:13px">In Stock</td><td style="padding:3px 0;font-size:13px;font-weight:600;color:#e53e3e">0</td></tr>
</table>
<p style="margin-top:20px;color:#98a;font-size:11px">Automated alert from the SAP ERP Portal — Short in Stock watch-list (Admin → System Settings).</p>
</div>`;
return mailer.sendMail({ to: users.map(u => u.email), subject: `Out of Stock — ${itemCode}${itemName ? ' (' + itemName + ')' : ''}`, html });
}
// Main job — safe to call on a timer; never throws (a failure here must
// never crash the app), logs and returns instead.
async function checkShortStock() {
try {
if (!appSettings.shortStockAlertEnabled()) return;
const company = process.env.SAP_B1_COMPANY;
if (!company) return;
const watched = await resolveWatchedItems(company);
if (!watched.size) return;
const itemCodes = [...watched.keys()];
const pool = await getSapPool(company);
const list = itemCodes.map(c => `'${String(c).replace(/'/g, "''")}'`).join(',');
const rows = await pool.request().query(`
SELECT T0."ItemCode", T0."ItemName", SUM(ISNULL(T1."OnHand",0)) AS "InStock"
FROM [dbo].[OITM] T0
LEFT JOIN [dbo].[OITW] T1 ON T1."ItemCode"=T0."ItemCode"
WHERE T0."ItemCode" IN (${list})
GROUP BY T0."ItemCode", T0."ItemName"`);
for (const row of rows.recordset) {
const itemCode = row.ItemCode;
const inStock = Number(row.InStock) || 0;
const state = await getState(itemCode, company);
const wasZero = !!(state && state.WAS_ZERO);
const isZero = inStock <= 0;
if (isZero && !wasZero) {
const sent = await sendShortStockMail(itemCode, row.ItemName, watched.get(itemCode), company);
console.log(`[SHORT-STOCK-ALERT] ${itemCode} hit zero — mail ${sent ? 'sent' : 'skipped (SMTP not configured / no eligible recipients)'}`);
await upsertState(itemCode, company, true, true);
} else if (!isZero && wasZero) {
await upsertState(itemCode, company, false, false);
}
}
} catch (e) {
console.error('[SHORT-STOCK-ALERT] check failed:', e.message);
}
}
module.exports = { bootstrap, checkShortStock, resolveWatchedItems };
+6
View File
@@ -272,6 +272,12 @@ async function resubmitAfterReject(id, w, { by, byName }) {
stage, JSON.stringify(log), now,
parseInt(id),
]);
// Keep an already-generated Production Order's batch snapshot in sync —
// see syncBatchFromWorkOrder()'s comment for why this can still be needed
// even after a Production Order exists.
await require('./productionOrderStore').syncBatchFromWorkOrder(id, {
batchNumber: w.batchNumber || '', mfgDate: w.mfgDate || '', expDate: w.expDate || '',
}).catch(e => console.error('[WO-STORE] syncBatchFromWorkOrder failed:', e.message));
return findById(id);
}