// 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 };