@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
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> Date: <b>${esc(fmt(inv.invoiceDate))}</b> 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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -893,6 +893,7 @@ module.exports = {
|
||||
sapRequest,
|
||||
sapRequestAs,
|
||||
runWithSapUser,
|
||||
currentSapCtx,
|
||||
testSapLogin,
|
||||
getNextCardCode,
|
||||
getNextVendorCardCode,
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
|
||||
// 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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user