2026-05-26 13:37:55 +08:00
|
|
|
const { query } = require('../config/database')
|
|
|
|
|
const { COST_RATIO, PROFIT_RATIO } = require('../config/constants')
|
|
|
|
|
|
|
|
|
|
async function getSalesTrend(ctx) {
|
|
|
|
|
const days = parseInt(ctx.query.days) || 30
|
|
|
|
|
const group = ctx.query.group || 'day'
|
|
|
|
|
|
|
|
|
|
let dateFormat
|
|
|
|
|
if (group === 'week') {
|
|
|
|
|
dateFormat = 'DATE_FORMAT(created_at, \'%x-W%v\')'
|
|
|
|
|
} else if (group === 'month') {
|
|
|
|
|
dateFormat = 'DATE_FORMAT(created_at, \'%Y-%m\')'
|
|
|
|
|
} else {
|
|
|
|
|
dateFormat = 'DATE(created_at)'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = await query(
|
|
|
|
|
`SELECT ${dateFormat} as period,
|
|
|
|
|
COUNT(*) as order_count,
|
|
|
|
|
COALESCE(SUM(total_price), 0) as total_sales
|
|
|
|
|
FROM orders
|
|
|
|
|
WHERE status IN ('paid', 'completed')
|
|
|
|
|
AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
|
|
|
GROUP BY period
|
|
|
|
|
ORDER BY period ASC`,
|
|
|
|
|
[days]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ctx.body = { code: 200, data: rows }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getHotProducts(ctx) {
|
|
|
|
|
const limit = parseInt(ctx.query.limit) || 10
|
|
|
|
|
|
|
|
|
|
const rows = await query(
|
2026-06-04 19:25:02 +08:00
|
|
|
`SELECT goods.id, goods.name, goods.price, goods.sales, goods.stock,
|
2026-05-26 13:37:55 +08:00
|
|
|
COALESCE(s.quantity, 0) as stock_qty
|
|
|
|
|
FROM goods
|
|
|
|
|
LEFT JOIN stock s ON goods.id = s.goods_id
|
2026-06-04 19:25:02 +08:00
|
|
|
ORDER BY goods.sales DESC
|
2026-05-26 13:37:55 +08:00
|
|
|
LIMIT ?`,
|
|
|
|
|
[limit]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
ctx.body = { code: 200, data: rows }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getProfitAnalysis(ctx) {
|
|
|
|
|
const days = parseInt(ctx.query.days) || 30
|
|
|
|
|
|
|
|
|
|
const revenueRows = await query(
|
|
|
|
|
`SELECT DATE(created_at) as date,
|
|
|
|
|
COUNT(*) as order_count,
|
|
|
|
|
COALESCE(SUM(total_price), 0) as revenue
|
|
|
|
|
FROM orders
|
|
|
|
|
WHERE status IN ('paid', 'completed')
|
|
|
|
|
AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
|
|
|
GROUP BY DATE(created_at)
|
|
|
|
|
ORDER BY date ASC`,
|
|
|
|
|
[days]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const avgCost = await query(
|
|
|
|
|
`SELECT COALESCE(AVG(purchase_price), 0) as avg_cost
|
|
|
|
|
FROM purchase_items`
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const costPerUnit = parseFloat(avgCost[0]?.avg_cost || 0)
|
|
|
|
|
|
|
|
|
|
const enriched = revenueRows.map(row => ({
|
|
|
|
|
...row,
|
|
|
|
|
revenue: parseFloat(row.revenue),
|
|
|
|
|
estimated_cost: parseFloat(row.revenue) * COST_RATIO,
|
|
|
|
|
estimated_profit: parseFloat(row.revenue) * PROFIT_RATIO
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const totalRevenue = enriched.reduce((s, r) => s + r.revenue, 0)
|
|
|
|
|
const totalCost = enriched.reduce((s, r) => s + r.estimated_cost, 0)
|
|
|
|
|
const totalProfit = enriched.reduce((s, r) => s + r.estimated_profit, 0)
|
|
|
|
|
|
|
|
|
|
ctx.body = {
|
|
|
|
|
code: 200,
|
|
|
|
|
data: {
|
|
|
|
|
days,
|
|
|
|
|
summary: {
|
|
|
|
|
total_revenue: totalRevenue,
|
|
|
|
|
total_cost: totalCost,
|
|
|
|
|
total_profit: totalProfit,
|
|
|
|
|
profit_margin: totalRevenue > 0 ? ((totalProfit / totalRevenue) * 100).toFixed(1) : '0.0',
|
|
|
|
|
avg_purchase_cost: costPerUnit
|
|
|
|
|
},
|
|
|
|
|
details: enriched
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getInventoryTurnover(ctx) {
|
|
|
|
|
const rows = await query(
|
|
|
|
|
`SELECT g.id, g.name, g.price, g.sales,
|
|
|
|
|
COALESCE(s.quantity, 0) as stock_qty,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN COALESCE(s.quantity, 0) <= 0 THEN g.sales
|
|
|
|
|
ELSE ROUND(g.sales / s.quantity, 2)
|
|
|
|
|
END as turnover_ratio
|
|
|
|
|
FROM goods g
|
|
|
|
|
LEFT JOIN stock s ON g.id = s.goods_id
|
|
|
|
|
ORDER BY turnover_ratio DESC`
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const lowStock = rows.filter(r => r.stock_qty <= 5)
|
|
|
|
|
const outOfStock = rows.filter(r => r.stock_qty <= 0)
|
|
|
|
|
const slowMoving = rows.filter(r => r.turnover_ratio < 0.1 && r.sales > 0)
|
|
|
|
|
|
|
|
|
|
ctx.body = {
|
|
|
|
|
code: 200,
|
|
|
|
|
data: {
|
|
|
|
|
total_items: rows.length,
|
|
|
|
|
low_stock_count: lowStock.length,
|
|
|
|
|
out_of_stock_count: outOfStock.length,
|
|
|
|
|
slow_moving_count: slowMoving.length,
|
|
|
|
|
items: rows
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
getSalesTrend,
|
|
|
|
|
getHotProducts,
|
|
|
|
|
getProfitAnalysis,
|
|
|
|
|
getInventoryTurnover
|
|
|
|
|
}
|