Spaces:
Running
Running
feat: implement business date utility and financial reporting system including expense tracking, platform sales, and profit-loss analysis endpoints
ec62259 Download src/controllers/reportController.js from aliroohan179/face: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/aliroohan179/face/resolve/main/src/controllers/reportController.js
- Command line
-
hf download hf://spaces/aliroohan179/face/src/controllers/reportController.js
-
curl -L -o reportController.js https://huggingface.co/spaces/aliroohan179/face/resolve/main/src/controllers/reportController.js
17.1 kB
| const Order = require('../models/Order'); | |
| const Expense = require('../models/Expense'); | |
| const PlatformSale = require('../models/PlatformSale'); | |
| const Setting = require('../models/Setting'); | |
| const { | |
| getBusinessDate, | |
| getBusinessDayRange, | |
| getBusinessDateRange, | |
| getCurrentBusinessDayRange, | |
| } = require('../utils/businessDate'); | |
| // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const buildOrderFilter = (from, to, extra = {}) => ({ | |
| status: 'completed', | |
| createdAt: { $gte: from, $lte: to }, | |
| ...extra, | |
| }); | |
| // Default financial report settings | |
| const DEFAULT_FINANCIAL_SETTINGS = { | |
| chefSharePercent: 30, | |
| directorsSharePercent: 70, | |
| directorCount: 3, | |
| directorAdjustments: [], | |
| }; | |
| // ββ Settings Endpoints ββββββββββββββββββββββββββββββββββββββββ | |
| // GET /api/reports/settings | |
| exports.getSettings = async (req, res) => { | |
| const doc = await Setting.findOne({ key: 'financial_settings' }); | |
| res.json(doc ? doc.value : DEFAULT_FINANCIAL_SETTINGS); | |
| }; | |
| // PUT /api/reports/settings | |
| exports.updateSettings = async (req, res) => { | |
| const updatedValue = { ...DEFAULT_FINANCIAL_SETTINGS, ...req.body }; | |
| const doc = await Setting.findOneAndUpdate( | |
| { key: 'financial_settings' }, | |
| { value: updatedValue }, | |
| { upsert: true, new: true, setDefaultsOnInsert: true } | |
| ); | |
| res.json(doc.value); | |
| }; | |
| // ββ Daily Sales Summary βββββββββββββββββββββββββββββββββββββββ | |
| // GET /api/reports/daily?date=2026-07-17 | |
| exports.dailySummary = async (req, res) => { | |
| const { date } = req.query; | |
| const { from, to, businessDate } = getBusinessDayRange(date); | |
| const orders = await Order.find(buildOrderFilter(from, to)); | |
| const totalSales = orders.reduce((s, o) => s + o.netTotal, 0); | |
| const orderCount = orders.length; | |
| const avgOrderValue = orderCount ? totalSales / orderCount : 0; | |
| const byType = orders.reduce((acc, o) => { | |
| acc[o.orderType] = (acc[o.orderType] || 0) + o.netTotal; | |
| return acc; | |
| }, {}); | |
| res.json({ date: businessDate, totalSales, orderCount, avgOrderValue, byType }); | |
| }; | |
| // ββ Date-Range Report βββββββββββββββββββββββββββββββββββββββββ | |
| // GET /api/reports/range?from=2026-07-01&to=2026-07-31 | |
| exports.rangeReport = async (req, res) => { | |
| const { from, to } = req.query; | |
| if (!from || !to) return res.status(400).json({ message: 'from and to dates required' }); | |
| const { from: fromDate, to: toDate } = getBusinessDateRange(from, to); | |
| const orders = await Order.find(buildOrderFilter(fromDate, toDate)); | |
| const totalSales = orders.reduce((s, o) => s + o.netTotal, 0); | |
| const orderCount = orders.length; | |
| // Group by business day (orders 00:00-05:00 belong to previous day) | |
| const byDay = {}; | |
| for (const o of orders) { | |
| const day = getBusinessDate(o.createdAt); | |
| if (!byDay[day]) byDay[day] = { sales: 0, count: 0 }; | |
| byDay[day].sales += o.netTotal; | |
| byDay[day].count += 1; | |
| } | |
| res.json({ from, to, totalSales, orderCount, byDay }); | |
| }; | |
| // ββ Top-Selling Items βββββββββββββββββββββββββββββββββββββββββ | |
| // GET /api/reports/top-items?from=...&to=...&limit=10 | |
| exports.topItems = async (req, res) => { | |
| const { from, to, limit = 10 } = req.query; | |
| const matchStage = { status: 'completed' }; | |
| if (from || to) { | |
| const { from: fromDate, to: toDate } = getBusinessDateRange(from, to); | |
| matchStage.createdAt = { $gte: fromDate, $lte: toDate }; | |
| } | |
| const results = await Order.aggregate([ | |
| { $match: matchStage }, | |
| { $unwind: '$items' }, | |
| { | |
| $group: { | |
| _id: { menuItem: '$items.menuItem', variantLabel: '$items.variantLabel' }, | |
| name: { $first: '$items.menuItemName' }, | |
| variantLabel: { $first: '$items.variantLabel' }, | |
| totalQty: { $sum: '$items.qty' }, | |
| totalRevenue: { $sum: { $multiply: ['$items.qty', '$items.priceAtSale'] } }, | |
| }, | |
| }, | |
| { $sort: { totalQty: -1 } }, | |
| { $limit: Number(limit) }, | |
| ]); | |
| res.json(results); | |
| }; | |
| // ββ Profit & Loss βββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /api/reports/profit-loss?from=...&to=... | |
| exports.profitLoss = async (req, res) => { | |
| const { from, to } = req.query; | |
| if (!from || !to) return res.status(400).json({ message: 'from and to dates required' }); | |
| const { from: fromDate, to: toDate } = getBusinessDateRange(from, to); | |
| // Calendar dates for expenses | |
| const [fy, fm, fd] = from.split('-').map(Number); | |
| const expenseFrom = new Date(fy, fm - 1, fd, 0, 0, 0, 0); | |
| const [ty, tm, td] = to.split('-').map(Number); | |
| const expenseTo = new Date(ty, tm - 1, td, 23, 59, 59, 999); | |
| const [orders, expenses, platformSales] = await Promise.all([ | |
| Order.find(buildOrderFilter(fromDate, toDate)), | |
| Expense.find({ date: { $gte: expenseFrom, $lte: expenseTo } }), | |
| PlatformSale.find({ date: { $gte: expenseFrom, $lte: expenseTo } }), | |
| ]); | |
| const posSales = orders.reduce((s, o) => s + o.netTotal, 0); | |
| const platformTotal = platformSales.reduce((s, p) => s + p.amount, 0); | |
| const totalSales = posSales + platformTotal; | |
| const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); | |
| const netProfit = totalSales - totalExpenses; | |
| const expensesByCategory = expenses.reduce((acc, e) => { | |
| acc[e.category] = (acc[e.category] || 0) + e.amount; | |
| return acc; | |
| }, {}); | |
| res.json({ | |
| from, | |
| to, | |
| posSales, | |
| platformSales: platformTotal, | |
| totalSales, | |
| totalExpenses, | |
| netProfit, | |
| expensesByCategory, | |
| }); | |
| }; | |
| // ββ Monthly Accounting Sheet (Exact Spreadsheet Format) ββββββββ | |
| // GET /api/reports/monthly-sheet?month=2026-07 | |
| exports.monthlySheet = async (req, res) => { | |
| const monthParam = req.query.month || getBusinessDate().substring(0, 7); // "YYYY-MM" | |
| const [yearStr, monthStr] = monthParam.split('-'); | |
| const year = Number(yearStr); | |
| const monthNum = Number(monthStr); | |
| if (isNaN(year) || isNaN(monthNum) || monthNum < 1 || monthNum > 12) { | |
| return res.status(400).json({ message: 'Invalid month format, expected YYYY-MM' }); | |
| } | |
| // Days in month | |
| const totalDays = new Date(year, monthNum, 0).getDate(); | |
| // 1. Business date range for the whole month | |
| // Month starts on Day 1 at 05:00 AM | |
| const monthStart = new Date(year, monthNum - 1, 1, 5, 0, 0, 0); | |
| // Month ends on Day (totalDays + 1) at 04:59:59.999 AM | |
| const monthEnd = new Date(year, monthNum - 1, totalDays + 1, 4, 59, 59, 999); | |
| // Calendar date range for expenses and platform sales | |
| const calendarStart = new Date(year, monthNum - 1, 1, 0, 0, 0, 0); | |
| const calendarEnd = new Date(year, monthNum, 0, 23, 59, 59, 999); | |
| // 2. Fetch all data in parallel | |
| const [orders, expenses, platformSales, settingsDoc] = await Promise.all([ | |
| Order.find(buildOrderFilter(monthStart, monthEnd)), | |
| Expense.find({ date: { $gte: calendarStart, $lte: calendarEnd } }).populate('createdBy', 'name'), | |
| PlatformSale.find({ date: { $gte: calendarStart, $lte: calendarEnd } }).populate('createdBy', 'name'), | |
| Setting.findOne({ key: 'financial_settings' }), | |
| ]); | |
| const settings = settingsDoc ? settingsDoc.value : DEFAULT_FINANCIAL_SETTINGS; | |
| // 3. Aggregate daily POS sales by business date | |
| const dailySalesMap = {}; | |
| for (const o of orders) { | |
| const bDate = getBusinessDate(o.createdAt); | |
| dailySalesMap[bDate] = (dailySalesMap[bDate] || 0) + o.netTotal; | |
| } | |
| // 4. Aggregate daily expenses (excluding salaries) and separate salary expenses | |
| const dailyExpensesMap = {}; | |
| const salaryExpenses = []; | |
| for (const e of expenses) { | |
| if (e.category === 'Salary') { | |
| salaryExpenses.push({ | |
| _id: e._id, | |
| title: e.title, | |
| amount: e.amount, | |
| date: e.date, | |
| note: e.note, | |
| addedBy: e.createdBy?.name || '', | |
| }); | |
| } else { | |
| const dayNum = new Date(e.date).getDate(); | |
| const bDate = `${year}-${String(monthNum).padStart(2, '0')}-${String(dayNum).padStart(2, '0')}`; | |
| dailyExpensesMap[bDate] = (dailyExpensesMap[bDate] || 0) + e.amount; | |
| } | |
| } | |
| // 5. Build daily rows with running cumulative totals | |
| let runningSale = 0; | |
| let runningProfit = 0; | |
| const rows = []; | |
| for (let d = 1; d <= totalDays; d++) { | |
| const dayPadded = String(d).padStart(2, '0'); | |
| const monthPadded = String(monthNum).padStart(2, '0'); | |
| const dateKey = `${year}-${monthPadded}-${dayPadded}`; | |
| const displayDate = `${dayPadded}/${monthPadded}`; | |
| const exp = dailyExpensesMap[dateKey] || 0; | |
| const sale = dailySalesMap[dateKey] || 0; | |
| const profitLoss = sale - exp; | |
| runningSale += sale; | |
| runningProfit += profitLoss; | |
| rows.push({ | |
| day: d, | |
| date: displayDate, | |
| fullDate: dateKey, | |
| expenses: exp, | |
| sale, | |
| profitLoss, | |
| totalSale: runningSale, | |
| totalProfit: runningProfit, | |
| }); | |
| } | |
| // 6. Platform Sales (e.g. Foodpanda) | |
| const foodpandaTotal = platformSales.reduce((s, p) => s + p.amount, 0); | |
| // 7. Subtotal (S. Total) | |
| const totalDailyExpenses = rows.reduce((s, r) => s + r.expenses, 0); | |
| const totalDailySales = rows.reduce((s, r) => s + r.sale, 0); | |
| const subtotalSale = totalDailySales + foodpandaTotal; | |
| const subtotalExpenses = totalDailyExpenses; | |
| const subtotalProfit = subtotalSale - subtotalExpenses; | |
| // 8. Salaries Total | |
| const salariesTotal = salaryExpenses.reduce((s, e) => s + e.amount, 0); | |
| // 9. Grand Total (G. Total) | |
| const grandTotalExpenses = subtotalExpenses + salariesTotal; | |
| const grandTotalSale = subtotalSale; | |
| const netProfit = grandTotalSale - grandTotalExpenses; | |
| // 10. Profit Sharing Calculations | |
| const chefSharePercent = Number(settings.chefSharePercent) || 30; | |
| const directorsSharePercent = Number(settings.directorsSharePercent) || 70; | |
| const directorCount = Number(settings.directorCount) || 3; | |
| const chefShare = Math.round((netProfit * chefSharePercent) / 100); | |
| const directorsShare = netProfit - chefShare; | |
| const eachDirectorShare = directorCount > 0 ? Math.round(directorsShare / directorCount) : 0; | |
| res.json({ | |
| month: monthParam, | |
| year, | |
| monthNum, | |
| monthName: new Date(year, monthNum - 1, 1).toLocaleString('en-US', { month: 'long', year: 'numeric' }), | |
| rows, | |
| platformSales: { | |
| platform: 'Foodpanda', | |
| entries: platformSales, | |
| total: foodpandaTotal, | |
| }, | |
| subtotal: { | |
| expenses: subtotalExpenses, | |
| sale: subtotalSale, | |
| profit: subtotalProfit, | |
| }, | |
| salaries: { | |
| entries: salaryExpenses, | |
| total: salariesTotal, | |
| }, | |
| grandTotal: { | |
| expenses: grandTotalExpenses, | |
| sale: grandTotalSale, | |
| netProfit, | |
| }, | |
| profitDistribution: { | |
| chefSharePercent, | |
| directorsSharePercent, | |
| directorCount, | |
| chefShare, | |
| directorsShare, | |
| eachDirectorShare, | |
| directorAdjustments: settings.directorAdjustments || [], | |
| }, | |
| }); | |
| }; | |
| // ββ CSV Export ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // GET /api/reports/export/csv?type=orders|expenses|monthly-sheet&from=...&to=...&month=... | |
| exports.exportCSV = async (req, res) => { | |
| const { type = 'orders', from, to, month } = req.query; | |
| if (type === 'monthly-sheet') { | |
| const monthParam = month || getBusinessDate().substring(0, 7); | |
| const [yStr, mStr] = monthParam.split('-'); | |
| const year = Number(yStr); | |
| const monthNum = Number(mStr); | |
| const totalDays = new Date(year, monthNum, 0).getDate(); | |
| const monthStart = new Date(year, monthNum - 1, 1, 5, 0, 0, 0); | |
| const monthEnd = new Date(year, monthNum - 1, totalDays + 1, 4, 59, 59, 999); | |
| const calendarStart = new Date(year, monthNum - 1, 1, 0, 0, 0, 0); | |
| const calendarEnd = new Date(year, monthNum, 0, 23, 59, 59, 999); | |
| const [orders, expenses, platformSales, settingsDoc] = await Promise.all([ | |
| Order.find(buildOrderFilter(monthStart, monthEnd)), | |
| Expense.find({ date: { $gte: calendarStart, $lte: calendarEnd } }), | |
| PlatformSale.find({ date: { $gte: calendarStart, $lte: calendarEnd } }), | |
| Setting.findOne({ key: 'financial_settings' }), | |
| ]); | |
| const settings = settingsDoc ? settingsDoc.value : DEFAULT_FINANCIAL_SETTINGS; | |
| const dailySalesMap = {}; | |
| for (const o of orders) { | |
| const bDate = getBusinessDate(o.createdAt); | |
| dailySalesMap[bDate] = (dailySalesMap[bDate] || 0) + o.netTotal; | |
| } | |
| const dailyExpensesMap = {}; | |
| let salariesTotal = 0; | |
| for (const e of expenses) { | |
| if (e.category === 'Salary') { | |
| salariesTotal += e.amount; | |
| } else { | |
| const dayNum = new Date(e.date).getDate(); | |
| const bDate = `${year}-${String(monthNum).padStart(2, '0')}-${String(dayNum).padStart(2, '0')}`; | |
| dailyExpensesMap[bDate] = (dailyExpensesMap[bDate] || 0) + e.amount; | |
| } | |
| } | |
| const fpTotal = platformSales.reduce((s, p) => s + p.amount, 0); | |
| const rows = [ | |
| [`Monthly Report - ${monthParam}`], | |
| ['Date', 'Expenses', 'Sale', 'Profit/Loss', 'Total Sale', 'Total Profit'], | |
| ]; | |
| let rSale = 0; | |
| let rProf = 0; | |
| let totExp = 0; | |
| let totSale = 0; | |
| for (let d = 1; d <= totalDays; d++) { | |
| const dayP = String(d).padStart(2, '0'); | |
| const monP = String(monthNum).padStart(2, '0'); | |
| const dKey = `${year}-${monP}-${dayP}`; | |
| const exp = dailyExpensesMap[dKey] || 0; | |
| const sale = dailySalesMap[dKey] || 0; | |
| const pl = sale - exp; | |
| rSale += sale; | |
| rProf += pl; | |
| totExp += exp; | |
| totSale += sale; | |
| rows.push([`${dayP}/${monP}`, exp, sale, pl, rSale, rProf]); | |
| } | |
| // Platform Sales | |
| rows.push(['Foodpanda', '', fpTotal, fpTotal, totSale + fpTotal, rProf + fpTotal]); | |
| // Subtotal | |
| rows.push(['S. Total', totExp, totSale + fpTotal, (totSale + fpTotal) - totExp, '', '']); | |
| // Salaries | |
| rows.push(['Salaries', salariesTotal, '', '', '', '']); | |
| // Grand Total | |
| const gNet = (totSale + fpTotal) - (totExp + salariesTotal); | |
| rows.push(['G. Total', totExp + salariesTotal, totSale + fpTotal, gNet, '', '']); | |
| const chefShare = Math.round((gNet * (settings.chefSharePercent || 30)) / 100); | |
| const dirShare = gNet - chefShare; | |
| const eachDir = Math.round(dirShare / (settings.directorCount || 3)); | |
| rows.push([]); | |
| rows.push(['Share of Chef (30%)', chefShare]); | |
| rows.push(['Directors Share (70%)', dirShare]); | |
| rows.push(['Each Director Share', eachDir]); | |
| res.setHeader('Content-Type', 'text/csv'); | |
| res.setHeader('Content-Disposition', `attachment; filename="monthly_sheet_${monthParam}.csv"`); | |
| return res.send(rows.map((r) => r.join(',')).join('\n')); | |
| } | |
| const { from: fromDate, to: toDate } = getBusinessDateRange(from, to); | |
| if (type === 'orders') { | |
| const orders = await Order.find(buildOrderFilter(fromDate, toDate)).populate('cashier', 'name'); | |
| const rows = [ | |
| ['Business Date', 'Created At', 'Order Type', 'Items', 'Subtotal', 'Discount', 'Delivery Fee', 'Net Total', 'Cash Received', 'Change', 'Status', 'Cashier'], | |
| ...orders.map((o) => [ | |
| getBusinessDate(o.createdAt), | |
| o.createdAt.toISOString(), | |
| o.orderType, | |
| o.items.map((i) => `${i.menuItemName} (${i.variantLabel}) x${i.qty}`).join(' | '), | |
| o.subtotal, | |
| o.discount.amount, | |
| o.deliveryFee || 0, | |
| o.netTotal, | |
| o.cashReceived, | |
| o.change, | |
| o.status, | |
| o.cashier?.name || '', | |
| ]), | |
| ]; | |
| res.setHeader('Content-Type', 'text/csv'); | |
| res.setHeader('Content-Disposition', `attachment; filename="orders_${from}_${to}.csv"`); | |
| return res.send(rows.map((r) => r.join(',')).join('\n')); | |
| } | |
| if (type === 'expenses') { | |
| const [fy, fm, fd] = from ? from.split('-').map(Number) : [1970, 1, 1]; | |
| const [ty, tm, td] = to ? to.split('-').map(Number) : [new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()]; | |
| const expStart = new Date(fy, fm - 1, fd, 0, 0, 0, 0); | |
| const expEnd = new Date(ty, tm - 1, td, 23, 59, 59, 999); | |
| const expenses = await Expense.find({ date: { $gte: expStart, $lte: expEnd } }).populate('createdBy', 'name'); | |
| const rows = [ | |
| ['Date', 'Title', 'Category', 'Amount', 'Note', 'Created By'], | |
| ...expenses.map((e) => [e.date.toISOString().split('T')[0], e.title, e.category, e.amount, e.note, e.createdBy?.name || '']), | |
| ]; | |
| res.setHeader('Content-Type', 'text/csv'); | |
| res.setHeader('Content-Disposition', `attachment; filename="expenses_${from}_${to}.csv"`); | |
| return res.send(rows.map((r) => r.join(',')).join('\n')); | |
| } | |
| res.status(400).json({ message: 'type must be orders, expenses, or monthly-sheet' }); | |
| }; | |