'use strict'; const express = require('express'); const router = express.Router(); const { GoogleGenerativeAI } = require('@google/generative-ai'); const { verifyToken } = require('../middleware/auth'); const { getPool } = require('../services/sqlPool'); const SYSTEM_PROMPT = `You are an intelligent AI assistant embedded in the MITRA ERP Portal for Mitra Industries — a manufacturing company that produces Blood Bags and CAPD products. You help users with: 1. Portal navigation & how-to: Explain how to use any module (BOM, P&L, Cost Sheet, Salary, Purchase, Production, GSTR, Projects, etc.) 2. Live data queries: When asked about sales revenue or BOM costs, call the appropriate tool to fetch real data from SAP B1 and present it clearly. == PORTAL MODULES == - BOM: Multi-level bill of materials with 3-level approval (Manager → Sr. Manager → SAP Adder). - Purchase Request / Quotation / Order: Full procurement workflow. - GRPO: Goods receipt against POs. - Production: Issue, Receipt, Close production orders. - Monthly Accounts P&L: 8-tab trial balance (Revenue, Purchase, Employee, Factory, Admin, SND, Finance, OtherIncome). Snapshot save supported. - P&L Comparison: Side-by-side snapshot comparison across periods. - Cost Sheet: Item-wise cost (Blood Bag & CAPD). RM Consumption = Qty × BOM Price × 115%. - Salary: Department salary periods (Blood Bag, CAPD, QA, QC). - GSTR-1 / GSTR-2: GST return filing. - Project Forms & Approvals. == DATA TOOLS == - get_revenue_data: Fetches revenue from SAP B1. Supports specific dates — if user says "17 May sales" or "sales on 16 May 2026", pass date="2026-05-17" and period_type="daily". For "monthly sales", pass period_type="monthly". For "yearly", pass period_type="yearly". For all periods, pass period_type="all". ALWAYS call this tool for any sales/revenue question — never say data is unavailable without trying. - get_bom_costs: Fetches BOM item codes, names, and standard prices for a given product group. Call when the user asks about BOM prices or item costs. - get_fg_inventory: Fetches FG inventory report via stored procedure INVENTORY_FG_AVG_PRICE. Returns opening balance, received from production, issues, balance, and live stock per item. Call when user asks about FG stock, inventory levels, warehouse stock, or item balances. - get_open_production_orders: Fetches open (Released + Planned) production orders from SAP B1. Returns summary totals, overdue count, segment breakdown, and order details. Call when the user asks about production orders, what is in production, overdue orders, or production status. == RESPONSE STYLE == - Be concise. Use tables or bullet points for data results. - Format currency in Indian Rupees (₹) with commas, 2 decimal places. - If a question is unrelated to Mitra Industries, politely redirect. - Respond in the same language the user writes in.`; // ── Account code constants (from stored procedure) ──────────────────────── const EXCL_DOCS = `'552520145','552520146','552520147','552520465','552522652','552522653','552620186'`; const EXCL_TM = `'4110201001','4110202001','4110201003','4110202003','4110201007','4110201002','4110201005'`; const EXCL_PD = EXCL_TM + `,'4110202005','4110202007','4110201004'`; const EXCL_ED = `'4110201001','4110201003','4110201007','4110201002','4110201005','4110201004'`; const EXCL_EI = `'4110202001','4110202003','4110202005','4110202007'`; // ── Date ranges (mirrors stored procedure logic) ────────────────────────── function getDateRanges() { const t = new Date(); t.setDate(t.getDate() - 1); // yesterday const dd = t.getDate(), mm = t.getMonth() + 1, yyyy = t.getFullYear(); const yearStart = (dd < 16 && mm <= 1) ? `${yyyy - 1}-01-16` : `${yyyy}-01-16`; let monthStart; if (dd < 16) { const pMm = mm === 1 ? 12 : mm - 1; const pYyyy = mm === 1 ? yyyy - 1 : yyyy; monthStart = `${pYyyy}-${String(pMm).padStart(2, '0')}-16`; } else { monthStart = `${yyyy}-${String(mm).padStart(2, '0')}-16`; } return { toDate: t.toISOString().split('T')[0], yearStart, monthStart }; } // ── Revenue subquery builder ─────────────────────────────────────────────── function buildRevenueSubq(dateWhere, isCreditNote) { const tbl = isCreditNote ? 'RIN1' : 'INV1'; const hdr = isCreditNote ? 'ORIN' : 'OINV'; const sign = isCreditNote ? '-' : ''; const extras = isCreditNote ? '' : ` AND i.DocNum NOT IN (${EXCL_DOCS}) AND (t2.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories') OR (i.DocStatus<>'C' OR i.InvntSttus='O'))`; return ` SELECT CASE WHEN (t2.ItmsGrpNam='FG BLOOD BAG' OR (t2.ItmsGrpNam IN ('FG EQUIPMENT','SEMI FINISHED') AND t1.U_BagType='Equipment')) AND t.AcctCode NOT IN (${EXCL_TM}) THEN ${sign}t.LineTotal ELSE 0 END AS TM, CASE WHEN (t2.ItmsGrpNam IN ('FG CAPD','FG CAPD Accessories') OR t1.U_PDGroup='Equipment') AND t.AcctCode NOT IN (${EXCL_PD}) THEN ${sign}t.LineTotal ELSE 0 END AS PD, CASE WHEN t.AcctCode IN (${EXCL_ED}) THEN ${sign}t.LineTotal ELSE 0 END AS ED, CASE WHEN t.AcctCode IN (${EXCL_EI}) THEN ${sign}t.LineTotal ELSE 0 END AS EI FROM ${tbl} t LEFT JOIN OITM t1 ON t.ItemCode = t1.ItemCode LEFT JOIN OITB t2 ON t1.ItmsGrpCod = t2.ItmsGrpCod INNER JOIN ${hdr} i ON t.DocEntry = i.DocEntry WHERE ${dateWhere} ${extras}`; } // ── Fetch revenue data ───────────────────────────────────────────────────── // options.date : 'YYYY-MM-DD' (default = yesterday) // options.periodType : 'daily' | 'monthly' | 'yearly' | 'all' (default = 'all') async function fetchRevenue(pool, options = {}) { // Resolve target date let target; if (options.date) { target = new Date(options.date); if (isNaN(target)) target = new Date(); } else { target = new Date(); target.setDate(target.getDate() - 1); // yesterday by default } const toDate = target.toISOString().split('T')[0]; const dd = target.getDate(), mm = target.getMonth() + 1, yyyy = target.getFullYear(); // Year start (mirrors stored procedure) const yearStart = (dd < 16 && mm <= 1) ? `${yyyy - 1}-01-16` : `${yyyy}-01-16`; // Month start let monthStart; if (dd < 16) { const pMm = mm === 1 ? 12 : mm - 1; const pYyyy = mm === 1 ? yyyy - 1 : yyyy; monthStart = `${pYyyy}-${String(pMm).padStart(2, '0')}-16`; } else { monthStart = `${yyyy}-${String(mm).padStart(2, '0')}-16`; } const pt = (options.periodType || 'all').toLowerCase(); const periods = []; if (pt === 'daily' || pt === 'all') periods.push([`Daily (${toDate})`, `i.DocDate = '${toDate}'`]); if (pt === 'monthly' || pt === 'all') periods.push([`Monthly (${monthStart} to ${toDate})`, `i.DocDate BETWEEN '${monthStart}' AND '${toDate}'`]); if (pt === 'yearly' || pt === 'all') periods.push([`Yearly (${yearStart} to ${toDate})`, `i.DocDate BETWEEN '${yearStart}' AND '${toDate}'`]); const rows = []; for (const [label, dw] of periods) { const sql = ` SELECT '${label}' AS Period, ROUND(SUM(TM), 2) AS TM_BloodBag, ROUND(SUM(PD), 2) AS PD_CAPD, ROUND(SUM(ED), 2) AS ExportDirect, ROUND(SUM(EI), 2) AS ExportIndirect, ROUND(SUM(TM)+SUM(PD)+SUM(ED)+SUM(EI), 2) AS Total FROM ( ${buildRevenueSubq(dw, false)} UNION ALL ${buildRevenueSubq(dw, true)} ) CK`; try { const r = await pool.request().query(sql); if (r.recordset?.[0]) rows.push(r.recordset[0]); } catch (e) { rows.push({ Period: label, error: e.message }); } } return { queriedDate: toDate, note: 'TM = Blood Bag, PD = CAPD. All amounts in INR (₹).', data: rows, }; } // ── Fetch open production orders ────────────────────────────────────────── async function fetchOpenProductionOrders(pool, options = {}) { const to = options.to_date || new Date().toISOString().split('T')[0]; const frDate = new Date(to); frDate.setDate(frDate.getDate() - (options.days ? parseInt(options.days) - 1 : 14)); const from = options.from_date || frDate.toISOString().split('T')[0]; const statusClause = options.status && options.status !== 'all' ? `AND OWOR.Status = '${options.status === 'Released' ? 'R' : 'P'}'` : `AND OWOR.Status IN ('R','P')`; const sql = ` SELECT DISTINCT OWOR.DocNum, k.SeriesName, CASE WHEN OWOR.Type = 'S' THEN 'Standard' ELSE 'Other' END AS Type, CASE WHEN OWOR.Status = 'R' THEN 'Released' WHEN OWOR.Status = 'P' THEN 'Planned' ELSE 'Unknown' END AS Status, OWOR.ItemCode AS ProductCode, CASE WHEN OITM.U_ItemSubGroup LIKE '%SFG Equipment%' OR OITM.U_ItemSubGroup LIKE '%EQUIPMENT%' THEN 'Equipment' WHEN OITM.U_ItemSubGroup LIKE '%Consumable%' OR OITM.U_ItemSubGroup LIKE '%Consumables%' THEN 'Consumable' WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD ACCESSORIES%' THEN 'FG CAPD ACCESSORIES' WHEN OITM.U_ItemSubGroup LIKE '%FG CAPD%' THEN 'FG CAPD' WHEN OITM.U_ItemSubGroup LIKE '%CAPD%' THEN 'CAPD' WHEN OITM.U_ItemSubGroup LIKE '%FG Blood Bag%' THEN 'FG Blood Bag' WHEN OITM.U_ItemSubGroup LIKE '%SFG Blood bag%' THEN 'SFG Blood bag' WHEN OITM.U_ItemSubGroup LIKE '%BLOOD BAG%' THEN 'BLOOD BAG' WHEN OITM.U_ItemSubGroup LIKE '%FG Equipment%' THEN 'FG Equipment' WHEN OITM.U_ItemSubGroup LIKE '%NEEDLE%' THEN 'NEEDLE' WHEN OITM.U_ItemSubGroup LIKE '%MOULDING%' THEN 'MOULDING' WHEN OITM.U_ItemSubGroup LIKE '%PLASTIC%' THEN 'PLASTIC' ELSE 'Other' END AS ProductSegment, OWOR.ProdName AS ProductName, OWOR.PlannedQty AS PlannedQty, OWOR.CmpltQty AS CompletedQty, CONVERT(VARCHAR(10), OWOR.PostDate, 105) AS PostDate, CONVERT(VARCHAR(10), OWOR.DueDate, 105) AS DueDate FROM OWOR INNER JOIN WOR1 ON OWOR.DocEntry = WOR1.DocEntry INNER JOIN OITM ON OWOR.ItemCode = OITM.ItemCode LEFT JOIN NNM1 k ON k.Series = OWOR.Series WHERE OWOR.PostDate BETWEEN '${from}' AND '${to}' ${statusClause} AND OITM.U_ItemSubGroup <> 'FG Scrap' ORDER BY OWOR.DocNum DESC`; const r = await pool.request().query(sql); const rows = r.recordset || []; const today = new Date(); today.setHours(0,0,0,0); const overdue = rows.filter(row => { if (!row.DueDate) return false; const [d, m, y] = row.DueDate.split('-'); return new Date(`${y}-${m}-${d}`) < today; }).length; const bySegment = {}; rows.forEach(row => { const seg = row.ProductSegment || 'Other'; bySegment[seg] = (bySegment[seg] || 0) + 1; }); return { period: `${from} to ${to}`, total: rows.length, released: rows.filter(r => r.Status === 'Released').length, planned: rows.filter(r => r.Status === 'Planned').length, overdue, bySegment, orders: rows.slice(0, 50), // cap at 50 rows for AI context }; } // ── Fetch BOM cost data ──────────────────────────────────────────────────── async function fetchBom(pool, itemGroup) { const grp = (itemGroup || 'FG BLOOD BAG').trim(); const r = await pool.request() .input('grp', grp) .query(` SELECT T1.Code AS ItemCode, T0.ItemName, MIN(P.Price) AS Price FROM OITT T1 INNER JOIN OITM T0 ON T0.ItemCode = T1.Code LEFT JOIN ITM1 P ON P.ItemCode = T1.Code LEFT JOIN OITB T2 ON T2.ItmsGrpCod = T0.ItmsGrpCod WHERE T2.ItmsGrpNam = @grp AND P.Price > 0 GROUP BY T1.Code, T0.ItemName ORDER BY T1.Code`); return { itemGroup: grp, count: r.recordset?.length || 0, items: r.recordset || [], }; } // ── Fetch FG inventory via stored procedure ─────────────────────────────── async function fetchFgInventory(pool, options = {}) { const to = options.to_date || new Date().toISOString().split('T')[0]; const from = options.from_date || (() => { const d = new Date(to); return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; })(); const warehouse = options.warehouse || '01'; const itemGroup = options.item_group || 'FG BLOOD BAG'; const r = await pool.request() .input('FromDate', from) .input('ToDate', to) .input('WhsCode', warehouse) .input('ItmsGrp', itemGroup) .execute('INVENTORY_FG_AVG_PRICE'); const data = r.recordset || []; return { period: `${from} to ${to}`, warehouse, itemGroup, count: data.length, summary: { totalOpening: data.reduce((s, r) => s + (parseFloat(r.OpeningBalanceQTY) || 0), 0).toFixed(2), totalReceivedProd: data.reduce((s, r) => s + (parseFloat(r.ReceivedFromPrd) || 0), 0).toFixed(2), totalIssued: data.reduce((s, r) => s + (parseFloat(r.IssueStock) || 0), 0).toFixed(2), totalBalance: data.reduce((s, r) => s + (parseFloat(r.Balance) || 0), 0).toFixed(2), totalLiveStock: data.reduce((s, r) => s + (parseFloat(r.QtyInWhse) || 0), 0).toFixed(2), }, items: data.slice(0, 40), }; } // ── Gemini tool definitions ──────────────────────────────────────────────── const TOOLS = [{ functionDeclarations: [ { name: 'get_revenue_data', description: 'Fetch sales revenue from SAP B1. Supports any specific date, month, or year. Always call this for questions about sales, revenue, or turnover — including specific dates like "17 May sales" or "16 May 2026". Pass the date in YYYY-MM-DD format and choose the appropriate period_type.', parameters: { type: 'object', properties: { date: { type: 'string', description: 'Target date in YYYY-MM-DD format. For "17 May sales" use "2026-05-17". Defaults to yesterday if omitted.', }, period_type: { type: 'string', enum: ['daily', 'monthly', 'yearly', 'all'], description: 'Which period to fetch: "daily" = just that date, "monthly" = 16th of prev/current month to that date, "yearly" = Jan 16 to that date, "all" = all three. Use "daily" when user asks about a specific date.', }, }, }, }, { name: 'get_bom_costs', description: 'Fetch BOM (Bill of Materials) item codes, names, and standard prices for a given product group. Call this when user asks about BOM prices, item costs, or material rates.', parameters: { type: 'object', properties: { item_group: { type: 'string', description: 'Product group name. Valid values: "FG BLOOD BAG", "FG CAPD", "FG CAPD Accessories"', }, }, required: ['item_group'], }, }, { name: 'get_fg_inventory', description: 'Fetch FG (Finished Goods) inventory report from SAP B1. Returns opening balance, received from production, issues, stock transfers, closing balance and live stock for each item. Call when user asks about inventory, stock levels, FG stock, or warehouse stock.', parameters: { type: 'object', properties: { item_group: { type: 'string', description: 'Item group name. E.g. "FG BLOOD BAG", "FG CAPD", "FG CAPD Accessories".' }, warehouse: { type: 'string', description: 'Warehouse code. Default is "01".' }, from_date: { type: 'string', description: 'Start date YYYY-MM-DD. Defaults to 1st of current month.' }, to_date: { type: 'string', description: 'End date YYYY-MM-DD. Defaults to today.' }, }, }, }, { name: 'get_open_production_orders', description: 'Fetch open production orders from SAP B1. Returns Released and Planned orders with segment breakdown and overdue count. Call when user asks about production orders, open orders, what is in production, overdue orders, or production status.', parameters: { type: 'object', properties: { status: { type: 'string', enum: ['all', 'Released', 'Planned'], description: 'Filter by order status. Default is "all".', }, days: { type: 'string', description: 'Number of days to look back from today (e.g. "15" for last 15 days). Default is 15.', }, from_date: { type: 'string', description: 'Start date in YYYY-MM-DD format. Use with to_date for a specific range.', }, to_date: { type: 'string', description: 'End date in YYYY-MM-DD format.', }, }, }, }, ], }]; // ── Execute a Gemini tool call ───────────────────────────────────────────── async function executeTool(name, args) { const pool = await getPool(); if (name === 'get_revenue_data') return fetchRevenue(pool, { date: args?.date, periodType: args?.period_type }); if (name === 'get_bom_costs') return fetchBom(pool, args?.item_group); if (name === 'get_fg_inventory') return fetchFgInventory(pool, args || {}); if (name === 'get_open_production_orders') return fetchOpenProductionOrders(pool, args || {}); return { error: `Unknown tool: ${name}` }; } // ── POST /api/chat/message ───────────────────────────────────────────────── router.post('/message', verifyToken, async (req, res) => { try { const { message, history = [] } = req.body; if (!message?.trim()) return res.status(400).json({ success: false, message: 'Message is required' }); if (!process.env.GEMINI_API_KEY) { return res.status(503).json({ success: false, message: 'AI service not configured. Add GEMINI_API_KEY to .env' }); } const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: 'gemini-flash-lite-latest', systemInstruction: SYSTEM_PROMPT, tools: TOOLS, }); const geminiHistory = history.slice(-20).map(m => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }], })); const chat = model.startChat({ history: geminiHistory }); let result = await chat.sendMessage(message.trim()); let response = result.response; let reply = ''; // Gemini function-calling loop (up to 3 round-trips) for (let i = 0; i < 3; i++) { const parts = response.candidates?.[0]?.content?.parts || []; const fnPart = parts.find(p => p.functionCall); if (!fnPart) { reply = response.text(); break; } const { name, args } = fnPart.functionCall; let toolResult; try { toolResult = await executeTool(name, args); } catch (e) { toolResult = { error: e.message }; } const followUp = await chat.sendMessage([{ functionResponse: { name, response: { result: toolResult } }, }]); response = followUp.response; } if (!reply) { try { reply = response.text(); } catch { reply = 'Sorry, I could not generate a response. Please try again.'; } } res.json({ success: true, reply }); } catch (err) { res.status(500).json({ success: false, message: err.message }); } }); module.exports = router;