import { Pool } from 'pg' import refreshTokenHelper from "../../utils/refreshTokenHelper" /** * BI → Product Performance (admin-only page, direct Postgres like the other pg routes). * * Sold qty = completed customer shipments (m_inout C-, dated by shipping_date, falling * back to movementdate), returns = completed customer returns (C+, movementdate). * Revenue = shipped qty × c_orderline.priceactual (net) of the linked order line. * * Query params * orgId organisation (required; limited roles are forced to their own org) * mode 'window' (default) | 'monthly' * days window length in days: 3 | 7 | 14 | 30 | 90 | 180 | 365 (window mode) * months number of monthly buckets, default 12, max 24 (monthly mode) * productIds comma-separated M_Product_IDs → only these products (manual scope) * limit top-N products by sold qty when productIds is empty (default 50, max 300) */ const CLIENT_ID = 1000000 const ALLOWED_DAYS = [3, 7, 14, 30, 90, 180, 365] const toIso = (d: Date) => { const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${day}` } const addDays = (d: Date, n: number) => { const x = new Date(d); x.setDate(x.getDate() + n); return x } const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1) const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1) const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : 0 } const handleFunc = async (event: any) => { const config = useRuntimeConfig() const dbConfig = { host: config.pgHost, port: parseInt(config.pgPort || '5432'), database: config.pgDatabase, user: config.pgUser, password: config.pgPassword, } if (!dbConfig.host || !dbConfig.database || !dbConfig.user || !dbConfig.password) { throw createError({ statusCode: 500, message: 'PostgreSQL credentials not configured. Please set PG_HOST, PG_DATABASE, PG_USER, PG_PASSWORD in .env' }) } const query = getQuery(event) const mode = String(query?.mode || 'window') === 'monthly' ? 'monthly' : 'window' let days = parseInt(String(query?.days || '30')) if (!ALLOWED_DAYS.includes(days)) days = 30 let months = parseInt(String(query?.months || '12')) if (!Number.isFinite(months) || months < 3) months = 12 if (months > 24) months = 24 let limit = parseInt(String(query?.limit || '50')) if (!Number.isFinite(limit) || limit < 5) limit = 50 if (limit > 300) limit = 300 const productIds = String(query?.productIds || '') .split(',').map((s) => parseInt(s.trim())).filter((n) => Number.isFinite(n) && n > 0) // Limited roles (FrontendMenu == 'c') only ever see their own organisation. const roleCookie = getCookie(event, 'logship_role') let menuType: string = 'Menu' try { const role = roleCookie ? JSON.parse(roleCookie) : null menuType = role?.FrontendMenu?.id || role?.FrontendMenu || 'Menu' } catch {} const ownOrgCookie = getCookie(event, 'logship_organization_id') const ownOrgId = ownOrgCookie ? parseInt(ownOrgCookie) : NaN const requestedOrg = query?.orgId != null && String(query.orgId) !== '' ? parseInt(String(query.orgId)) : NaN let orgId: number | null = Number.isFinite(requestedOrg) ? requestedOrg : (Number.isFinite(ownOrgId) ? ownOrgId : null) if (menuType === 'c') orgId = Number.isFinite(ownOrgId) ? ownOrgId : -1 if (!orgId) throw createError({ statusCode: 400, message: 'orgId is required' }) const pool = new Pool(dbConfig) try { // ---------------------------------------------------------------- shared pieces // Customer shipments / returns of the org, one row per line, with the business date. const movesCte = ` moves AS ( SELECT ino.m_inout_id, ino.movementtype, iol.m_product_id AS pid, ABS(iol.movementqty) AS qty, ABS(iol.movementqty) * COALESCE(ol.priceactual, 0) AS revenue, CASE WHEN ino.movementtype = 'C-' THEN COALESCE(ino.shipping_date, ino.movementdate)::date ELSE ino.movementdate::date END AS d FROM m_inout ino JOIN m_inoutline iol ON iol.m_inout_id = ino.m_inout_id LEFT JOIN c_orderline ol ON ol.c_orderline_id = iol.c_orderline_id WHERE ino.issotrx = 'Y' AND ino.isactive = 'Y' AND ino.docstatus = 'CO' AND ino.movementtype IN ('C-', 'C+') AND ino.ad_client_id = ${CLIENT_ID} AND ino.ad_org_id = $1 AND iol.isactive = 'Y' AND iol.m_product_id IS NOT NULL AND iol.movementqty <> 0 )` const productSelect = ` p.m_product_id AS id, p.name, p.value, p.sku, p.upc, p.isbom, p.isjtlbom, p.isactive, p.imageurl, pc.name AS category` const productJoin = ` JOIN m_product p ON p.m_product_id = t.pid LEFT JOIN m_product_category pc ON pc.m_product_category_id = p.m_product_category_id` if (mode === 'window') { const today = new Date() const from = addDays(today, -(days - 1)) // inclusive window of `days` days ending today const prevFrom = addDays(from, -days) // previous window of equal length, right before const prevTo = addDays(from, -1) const params: any[] = [orgId, toIso(from), toIso(today), toIso(prevFrom)] const scopeSql = productIds.length ? `AND t.pid = ANY($5::int[])` : '' if (productIds.length) params.push(productIds) else params.push(limit) const limitSql = productIds.length ? '' : `LIMIT $5` const sql = ` WITH ${movesCte}, agg AS ( SELECT pid, SUM(qty) FILTER (WHERE movementtype = 'C-' AND d >= $2::date AND d <= $3::date) AS qty_sold, COUNT(DISTINCT m_inout_id) FILTER (WHERE movementtype = 'C-' AND d >= $2::date AND d <= $3::date) AS shipments, SUM(revenue) FILTER (WHERE movementtype = 'C-' AND d >= $2::date AND d <= $3::date) AS revenue, SUM(qty) FILTER (WHERE movementtype = 'C+' AND d >= $2::date AND d <= $3::date) AS qty_returned, SUM(qty) FILTER (WHERE movementtype = 'C-' AND d >= $4::date AND d < $2::date) AS qty_sold_prev, SUM(revenue) FILTER (WHERE movementtype = 'C-' AND d >= $4::date AND d < $2::date) AS revenue_prev, SUM(qty) FILTER (WHERE movementtype = 'C+' AND d >= $4::date AND d < $2::date) AS qty_returned_prev, MAX(d) FILTER (WHERE movementtype = 'C-') AS last_sold FROM moves WHERE d >= $4::date AND d <= $3::date GROUP BY pid ), stock AS ( SELECT s.m_product_id AS pid, SUM(s.qtyonhand) AS qty_on_hand FROM m_storage s JOIN m_locator l ON l.m_locator_id = s.m_locator_id WHERE l.ad_org_id = $1 GROUP BY s.m_product_id ) SELECT ${productSelect}, COALESCE(t.qty_sold, 0) AS qty_sold, COALESCE(t.shipments, 0) AS shipments, COALESCE(t.revenue, 0) AS revenue, COALESCE(t.qty_returned, 0) AS qty_returned, COALESCE(t.qty_sold_prev, 0) AS qty_sold_prev, COALESCE(t.revenue_prev, 0) AS revenue_prev, COALESCE(t.qty_returned_prev, 0) AS qty_returned_prev, t.last_sold, COALESCE(st.qty_on_hand, 0) AS qty_on_hand FROM agg t ${productJoin} LEFT JOIN stock st ON st.pid = t.pid WHERE (t.qty_sold > 0 OR t.qty_returned > 0 OR t.qty_sold_prev > 0 ${productIds.length ? 'OR TRUE' : ''}) ${scopeSql} ORDER BY COALESCE(t.qty_sold, 0) DESC, COALESCE(t.qty_returned, 0) DESC, p.name ASC ${limitSql}` // Org-level daily series (all products, not just the listed ones) for the trend chart const dailySql = ` WITH ${movesCte} SELECT d, SUM(qty) FILTER (WHERE movementtype = 'C-') AS qty_sold, SUM(qty) FILTER (WHERE movementtype = 'C+') AS qty_returned, COUNT(DISTINCT m_inout_id) FILTER (WHERE movementtype = 'C-') AS shipments, SUM(revenue) FILTER (WHERE movementtype = 'C-') AS revenue FROM moves WHERE d >= $2::date AND d <= $3::date GROUP BY d ORDER BY d` // Org totals for the window + previous window (KPI cards) — all products const totalsSql = ` WITH ${movesCte} SELECT SUM(qty) FILTER (WHERE movementtype = 'C-' AND d >= $2::date) AS qty_sold, SUM(revenue) FILTER (WHERE movementtype = 'C-' AND d >= $2::date) AS revenue, SUM(qty) FILTER (WHERE movementtype = 'C+' AND d >= $2::date) AS qty_returned, COUNT(DISTINCT m_inout_id) FILTER (WHERE movementtype = 'C-' AND d >= $2::date) AS shipments, COUNT(DISTINCT pid) FILTER (WHERE movementtype = 'C-' AND d >= $2::date) AS products_sold, SUM(qty) FILTER (WHERE movementtype = 'C-' AND d < $2::date) AS qty_sold_prev, SUM(revenue) FILTER (WHERE movementtype = 'C-' AND d < $2::date) AS revenue_prev, SUM(qty) FILTER (WHERE movementtype = 'C+' AND d < $2::date) AS qty_returned_prev, COUNT(DISTINCT m_inout_id) FILTER (WHERE movementtype = 'C-' AND d < $2::date) AS shipments_prev, COUNT(DISTINCT pid) FILTER (WHERE movementtype = 'C-' AND d < $2::date) AS products_sold_prev FROM moves WHERE d >= $4::date AND d <= $3::date` const baseParams = [orgId, toIso(from), toIso(today), toIso(prevFrom)] const [rowsRes, dailyRes, totalsRes] = await Promise.all([ pool.query(sql, params), pool.query(dailySql, baseParams.slice(0, 3)), pool.query(totalsSql, baseParams) ]) const products = (rowsRes.rows || []).map((r: any) => { const sold = num(r.qty_sold), returned = num(r.qty_returned), onHand = num(r.qty_on_hand) const perDay = sold / days return { id: r.id, name: r.name, value: r.value, sku: r.sku, upc: r.upc, isBom: r.isbom === 'Y' || r.isjtlbom === 'Y', isActive: r.isactive === 'Y', imageUrl: r.imageurl || null, category: r.category || '', qtySold: sold, qtySoldPrev: num(r.qty_sold_prev), shipments: num(r.shipments), revenue: Math.round(num(r.revenue) * 100) / 100, revenuePrev: Math.round(num(r.revenue_prev) * 100) / 100, qtyReturned: returned, qtyReturnedPrev: num(r.qty_returned_prev), returnRate: sold > 0 ? Math.round((returned / sold) * 1000) / 10 : (returned > 0 ? 100 : 0), qtyOnHand: onHand, perDay: Math.round(perDay * 100) / 100, coverageDays: perDay > 0 ? Math.round(onHand / perDay) : null, lastSold: r.last_sold ? toIso(new Date(r.last_sold)) : null } }) const t = totalsRes.rows?.[0] || {} const daily = (dailyRes.rows || []).map((r: any) => ({ day: toIso(new Date(r.d)), qtySold: num(r.qty_sold), qtyReturned: num(r.qty_returned), shipments: num(r.shipments), revenue: Math.round(num(r.revenue) * 100) / 100 })) // fill missing days with zeros so the chart has a continuous axis const byDay: Record = Object.fromEntries(daily.map((x) => [x.day, x])) const dailyFilled: any[] = [] for (let i = 0; i < days; i++) { const day = toIso(addDays(from, i)) dailyFilled.push(byDay[day] || { day, qtySold: 0, qtyReturned: 0, shipments: 0, revenue: 0 }) } return { mode, orgId, days, from: toIso(from), to: toIso(today), prevFrom: toIso(prevFrom), prevTo: toIso(prevTo), scope: productIds.length ? 'manual' : 'top', limit, totals: { qtySold: num(t.qty_sold), qtySoldPrev: num(t.qty_sold_prev), revenue: Math.round(num(t.revenue) * 100) / 100, revenuePrev: Math.round(num(t.revenue_prev) * 100) / 100, qtyReturned: num(t.qty_returned), qtyReturnedPrev: num(t.qty_returned_prev), shipments: num(t.shipments), shipmentsPrev: num(t.shipments_prev), productsSold: num(t.products_sold), productsSoldPrev: num(t.products_sold_prev), returnRate: num(t.qty_sold) > 0 ? Math.round((num(t.qty_returned) / num(t.qty_sold)) * 1000) / 10 : 0, returnRatePrev: num(t.qty_sold_prev) > 0 ? Math.round((num(t.qty_returned_prev) / num(t.qty_sold_prev)) * 1000) / 10 : 0 }, daily: dailyFilled, products } } // ---------------------------------------------------------------- monthly matrix const today = new Date() const firstBucket = addMonths(startOfMonth(today), -(months - 1)) const params: any[] = [orgId, toIso(firstBucket), toIso(addDays(today, 1))] const scopeSql = productIds.length ? `WHERE pid = ANY($4::int[])` : '' if (productIds.length) params.push(productIds) else params.push(limit) const topSql = productIds.length ? `SELECT DISTINCT pid FROM moves ${scopeSql}` : `SELECT pid FROM moves WHERE movementtype = 'C-' GROUP BY pid ORDER BY SUM(qty) DESC LIMIT $4` const sql = ` WITH ${movesCte.replace('AND iol.movementqty <> 0', `AND iol.movementqty <> 0 AND (CASE WHEN ino.movementtype = 'C-' THEN COALESCE(ino.shipping_date, ino.movementdate) ELSE ino.movementdate END) >= $2::date AND (CASE WHEN ino.movementtype = 'C-' THEN COALESCE(ino.shipping_date, ino.movementdate) ELSE ino.movementdate END) < $3::date`)}, scope AS (${topSql}), agg AS ( SELECT m.pid, date_trunc('month', m.d)::date AS month, SUM(m.qty) FILTER (WHERE m.movementtype = 'C-') AS qty_sold, SUM(m.revenue) FILTER (WHERE m.movementtype = 'C-') AS revenue, SUM(m.qty) FILTER (WHERE m.movementtype = 'C+') AS qty_returned FROM moves m JOIN scope s ON s.pid = m.pid GROUP BY m.pid, date_trunc('month', m.d) ), stock AS ( SELECT s.m_product_id AS pid, SUM(s.qtyonhand) AS qty_on_hand FROM m_storage s JOIN m_locator l ON l.m_locator_id = s.m_locator_id WHERE l.ad_org_id = $1 GROUP BY s.m_product_id ) SELECT ${productSelect}, t.month, COALESCE(t.qty_sold, 0) AS qty_sold, COALESCE(t.revenue, 0) AS revenue, COALESCE(t.qty_returned, 0) AS qty_returned, COALESCE(st.qty_on_hand, 0) AS qty_on_hand FROM agg t ${productJoin} LEFT JOIN stock st ON st.pid = t.pid ORDER BY p.name, t.month` const monthlyTotalsSql = ` WITH ${movesCte} SELECT date_trunc('month', d)::date AS month, SUM(qty) FILTER (WHERE movementtype = 'C-') AS qty_sold, SUM(revenue) FILTER (WHERE movementtype = 'C-') AS revenue, SUM(qty) FILTER (WHERE movementtype = 'C+') AS qty_returned, COUNT(DISTINCT m_inout_id) FILTER (WHERE movementtype = 'C-') AS shipments FROM moves WHERE d >= $2::date AND d < $3::date GROUP BY 1 ORDER BY 1` const [rowsRes, totalsRes] = await Promise.all([ pool.query(sql, params), pool.query(monthlyTotalsSql, [orgId, toIso(firstBucket), toIso(addDays(today, 1))]) ]) const monthKeys: string[] = [] for (let i = 0; i < months; i++) monthKeys.push(toIso(addMonths(firstBucket, i)).slice(0, 7)) const byProduct: Record = {} for (const r of (rowsRes.rows || [])) { const key = String(r.id) if (!byProduct[key]) { byProduct[key] = { id: r.id, name: r.name, value: r.value, sku: r.sku, upc: r.upc, isBom: r.isbom === 'Y' || r.isjtlbom === 'Y', isActive: r.isactive === 'Y', imageUrl: r.imageurl || null, category: r.category || '', qtyOnHand: num(r.qty_on_hand), months: Object.fromEntries(monthKeys.map((k) => [k, { qtySold: 0, qtyReturned: 0, revenue: 0 }])), qtySold: 0, qtyReturned: 0, revenue: 0 } } const mk = toIso(new Date(r.month)).slice(0, 7) const cell = byProduct[key].months[mk] if (cell) { cell.qtySold += num(r.qty_sold); cell.qtyReturned += num(r.qty_returned); cell.revenue += num(r.revenue) } byProduct[key].qtySold += num(r.qty_sold) byProduct[key].qtyReturned += num(r.qty_returned) byProduct[key].revenue += num(r.revenue) } const products = Object.values(byProduct).map((p: any) => { const series = monthKeys.map((k) => p.months[k].qtySold) const last3 = series.slice(-3).reduce((a, b) => a + b, 0) const prev3 = series.slice(-6, -3).reduce((a, b) => a + b, 0) return { ...p, revenue: Math.round(p.revenue * 100) / 100, returnRate: p.qtySold > 0 ? Math.round((p.qtyReturned / p.qtySold) * 1000) / 10 : (p.qtyReturned > 0 ? 100 : 0), avgPerMonth: Math.round((p.qtySold / months) * 10) / 10, trend: prev3 > 0 ? Math.round(((last3 - prev3) / prev3) * 1000) / 10 : (last3 > 0 ? 100 : 0), last3, prev3, coverageMonths: p.qtySold > 0 ? Math.round((p.qtyOnHand / (p.qtySold / months)) * 10) / 10 : null } }).sort((a: any, b: any) => b.qtySold - a.qtySold || a.name.localeCompare(b.name)) const totalsByMonth = Object.fromEntries(monthKeys.map((k) => [k, { qtySold: 0, qtyReturned: 0, revenue: 0, shipments: 0 }])) for (const r of (totalsRes.rows || [])) { const mk = toIso(new Date(r.month)).slice(0, 7) if (totalsByMonth[mk]) totalsByMonth[mk] = { qtySold: num(r.qty_sold), qtyReturned: num(r.qty_returned), revenue: Math.round(num(r.revenue) * 100) / 100, shipments: num(r.shipments) } } return { mode, orgId, months, monthKeys, from: toIso(firstBucket), to: toIso(today), scope: productIds.length ? 'manual' : 'top', limit, totalsByMonth, products } } catch (err: any) { console.error('[bi/product-performance] PostgreSQL query error:', err) throw createError({ statusCode: 500, message: `Database error: ${err.message}` }) } finally { await pool.end() } } export default defineEventHandler(async (event) => { try { return await handleFunc(event) } catch (err: any) { if (err?.statusCode === 400 || err?.statusCode === 500) throw err try { await refreshTokenHelper(event) return await handleFunc(event) } catch (error: any) { console.error('[bi/product-performance] fatal:', error) throw createError({ statusCode: 500, message: error?.message || 'Product performance failed' }) } } })