// 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 { if (!isEmp(actor)) delete o.internalRemarks; return o; }); } // ── Customer's own order reference ("Your order ref. no.") ────────────────── // Auto-suggested per customer as //<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 { 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, };