fix: 修复成本/库存/对账多页面数据准确性与展示问题
- 库存周转: turnover_days零消耗时返回NULL并显示"无消耗",排序至末尾; 门店名称三级回退(dim_store→mv_distribution_monthly→硬编码中央厨房) - 成本管理: estimated_inventory_days为NULL时显示"-"而非"0.0天"; 补充成本率口径与指标关系说明 - 成本分析: 修复理论成本率计算(按单位售价而非单价),新增sales_quantity展示; 区分菜品级(Excel)与门店级(倒挤)成本指标,说明差异原因 - 配送对账: 区分中央厨房/门店,分层展示耗用口径 - 告警/任务/目标管理: 新增详情弹窗组件,优化交互体验 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -555,22 +555,40 @@ router.get('/inventory/turnover', async (req: AuthRequest, res) => {
|
||||
|
||||
// 库存周转概览
|
||||
const turnoverResult = await query(`
|
||||
SELECT
|
||||
SELECT
|
||||
fis.store_code,
|
||||
ds.store_name,
|
||||
COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
|
||||
WHEN '2' THEN '中央厨房'
|
||||
WHEN '3' THEN '加工车间'
|
||||
WHEN '6' THEN '子仓库'
|
||||
ELSE '门店' || fis.store_code
|
||||
END) as store_name,
|
||||
round(sum(fis.opening_amount)::numeric, 2) as opening_value,
|
||||
round(sum(fis.purchase_amount)::numeric, 2) as purchase_value,
|
||||
round(sum(fis.consumption_amount)::numeric, 2) as consumption_value,
|
||||
round(sum(fis.ending_amount)::numeric, 2) as ending_value,
|
||||
round(sum(fis.waste_amount)::numeric, 2) as waste_value,
|
||||
round(avg(fis.ending_amount)::numeric, 2) as avg_ending,
|
||||
round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1) as turnover_days
|
||||
CASE WHEN sum(fis.consumption_amount) > 0
|
||||
THEN round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1)
|
||||
ELSE NULL
|
||||
END as turnover_days
|
||||
FROM analytics.fact_inventory_snapshot fis
|
||||
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
|
||||
LEFT JOIN analytics.dim_store ds ON LPAD(fis.store_code,4,'0') = ds.store_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT store_name FROM mv_distribution_monthly
|
||||
WHERE store_code = fis.store_code AND store_name IS NOT NULL AND store_name != ''
|
||||
LIMIT 1
|
||||
) dist ON true
|
||||
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
|
||||
${storeFilter}
|
||||
GROUP BY fis.store_code, ds.store_name
|
||||
ORDER BY turnover_days ASC
|
||||
GROUP BY fis.store_code, COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
|
||||
WHEN '2' THEN '中央厨房'
|
||||
WHEN '3' THEN '加工车间'
|
||||
WHEN '6' THEN '子仓库'
|
||||
ELSE '门店' || fis.store_code
|
||||
END)
|
||||
ORDER BY CASE WHEN sum(fis.consumption_amount) > 0 THEN 0 ELSE 1 END, turnover_days ASC
|
||||
`, params)
|
||||
|
||||
// 损耗TOP
|
||||
@@ -606,32 +624,90 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const storeCode = (req.query.store_code as string) || ''
|
||||
const urgencyLevel = (req.query.urgency_level as string) || ''
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1)
|
||||
const pageSize = Math.min(500, Math.max(10, parseInt(req.query.page_size as string) || 50))
|
||||
|
||||
let storeFilter = ''
|
||||
let urgencyFilter = ''
|
||||
const params: any[] = [month]
|
||||
if (storeCode) {
|
||||
params.push(storeCode)
|
||||
storeFilter = `AND fis.store_code = $${params.length}`
|
||||
}
|
||||
if (urgencyLevel === '紧急') {
|
||||
urgencyFilter = `AND (fis.ending_amount / fis.consumption_amount * 30) <= 3`
|
||||
} else if (urgencyLevel === '预警') {
|
||||
urgencyFilter = `AND (fis.ending_amount / fis.consumption_amount * 30) > 3 AND (fis.ending_amount / fis.consumption_amount * 30) <= 7`
|
||||
} else if (urgencyLevel === '关注') {
|
||||
urgencyFilter = `AND (fis.ending_amount / fis.consumption_amount * 30) > 7 AND (fis.ending_amount / fis.consumption_amount * 30) <= 14`
|
||||
}
|
||||
|
||||
// 临期商品明细(按门店+物料)
|
||||
// 临期汇总(全量统计)
|
||||
const summaryResult = await query(`
|
||||
SELECT
|
||||
count(*) AS total_items,
|
||||
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3) AS urgent,
|
||||
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) > 3) AS warning,
|
||||
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) > 7) AS watch,
|
||||
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3)::numeric, 2) AS urgent_value,
|
||||
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) > 3)::numeric, 2) AS warning_value,
|
||||
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7)::numeric, 2) AS potential_loss
|
||||
FROM analytics.fact_inventory_snapshot fis
|
||||
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
|
||||
AND fis.ending_amount > 0
|
||||
${storeFilter}
|
||||
`, params)
|
||||
|
||||
// 临期商品明细总数(带筛选)
|
||||
const countParams = [...params]
|
||||
const countResult = await query(`
|
||||
SELECT count(*) AS total
|
||||
FROM analytics.fact_inventory_snapshot fis
|
||||
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
|
||||
AND fis.ending_amount > 0
|
||||
AND fis.consumption_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14
|
||||
${storeFilter}
|
||||
${urgencyFilter}
|
||||
`, countParams)
|
||||
|
||||
// 分页查询
|
||||
const pageParams = [...params]
|
||||
pageParams.push(pageSize)
|
||||
pageParams.push((page - 1) * pageSize)
|
||||
const nearExpiryResult = await query(`
|
||||
SELECT
|
||||
SELECT
|
||||
fis.store_code,
|
||||
ds.store_name,
|
||||
COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
|
||||
WHEN '2' THEN '中央厨房'
|
||||
WHEN '3' THEN '加工车间'
|
||||
WHEN '6' THEN '子仓库'
|
||||
ELSE '门店' || fis.store_code
|
||||
END) as store_name,
|
||||
dm.material_name,
|
||||
dm.material_code,
|
||||
dm.category,
|
||||
dm.major_category,
|
||||
dm.minor_category,
|
||||
round(fis.ending_quantity::numeric, 2) as stock_qty,
|
||||
round(fis.ending_amount::numeric, 2) as stock_value,
|
||||
round(fis.consumption_amount::numeric, 2) as monthly_consumption,
|
||||
round(fis.waste_amount::numeric, 2) as waste_value,
|
||||
CASE
|
||||
CASE
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
THEN round((fis.ending_amount / fis.consumption_amount * 30)::numeric, 1)
|
||||
ELSE NULL
|
||||
ELSE NULL
|
||||
END as estimated_days_to_consume,
|
||||
CASE
|
||||
CASE
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN '紧急'
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
@@ -640,7 +716,7 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN '关注'
|
||||
ELSE '正常'
|
||||
END as urgency_level,
|
||||
CASE
|
||||
CASE
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN '立即促销出清或报损'
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
@@ -650,47 +726,51 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
|
||||
ELSE '正常周转'
|
||||
END as suggested_action
|
||||
FROM analytics.fact_inventory_snapshot fis
|
||||
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
|
||||
LEFT JOIN analytics.dim_store ds ON LPAD(fis.store_code,4,'0') = ds.store_code
|
||||
LEFT JOIN analytics.dim_material dm ON fis.material_code = dm.material_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT store_name FROM mv_distribution_monthly
|
||||
WHERE store_code = fis.store_code AND store_name IS NOT NULL AND store_name != ''
|
||||
LIMIT 1
|
||||
) dist ON true
|
||||
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
|
||||
AND fis.ending_amount > 0
|
||||
AND fis.consumption_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14
|
||||
${storeFilter}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN 1
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN 2
|
||||
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN 3
|
||||
ELSE 4
|
||||
${urgencyFilter}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN 1
|
||||
WHEN (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
fis.ending_amount DESC
|
||||
LIMIT 200
|
||||
`, params)
|
||||
LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}
|
||||
`, pageParams)
|
||||
|
||||
// 临期汇总
|
||||
const sr = summaryResult.rows[0] as any
|
||||
const summary = {
|
||||
total_items: nearExpiryResult.rows.length,
|
||||
urgent: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '紧急').length,
|
||||
warning: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '预警').length,
|
||||
watch: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '关注').length,
|
||||
urgent_value: nearExpiryResult.rows
|
||||
.filter((r: any) => r.urgency_level === '紧急')
|
||||
.reduce((s: number, r: any) => s + Number(r.stock_value || 0), 0),
|
||||
warning_value: nearExpiryResult.rows
|
||||
.filter((r: any) => r.urgency_level === '预警')
|
||||
.reduce((s: number, r: any) => s + Number(r.stock_value || 0), 0),
|
||||
potential_loss: nearExpiryResult.rows
|
||||
.filter((r: any) => r.urgency_level === '紧急' || r.urgency_level === '预警')
|
||||
.reduce((s: number, r: any) => s + Number(r.stock_value || 0) * 0.5, 0),
|
||||
total_items: parseInt(sr?.total_items) || 0,
|
||||
urgent: parseInt(sr?.urgent) || 0,
|
||||
warning: parseInt(sr?.warning) || 0,
|
||||
watch: parseInt(sr?.watch) || 0,
|
||||
urgent_value: parseFloat(sr?.urgent_value) || 0,
|
||||
warning_value: parseFloat(sr?.warning_value) || 0,
|
||||
potential_loss: parseFloat(sr?.potential_loss) || 0,
|
||||
}
|
||||
|
||||
// 按门店汇总临期风险
|
||||
const storeRisk = await query(`
|
||||
SELECT
|
||||
SELECT
|
||||
fis.store_code,
|
||||
ds.store_name,
|
||||
COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
|
||||
WHEN '2' THEN '中央厨房'
|
||||
WHEN '3' THEN '加工车间'
|
||||
WHEN '6' THEN '子仓库'
|
||||
ELSE '门店' || fis.store_code
|
||||
END) as store_name,
|
||||
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3) as urgent_items,
|
||||
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
@@ -700,20 +780,38 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7)::numeric, 2) as at_risk_value,
|
||||
round(sum(fis.waste_amount)::numeric, 2) as total_waste
|
||||
FROM analytics.fact_inventory_snapshot fis
|
||||
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
|
||||
LEFT JOIN analytics.dim_store ds ON LPAD(fis.store_code,4,'0') = ds.store_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT store_name FROM mv_distribution_monthly
|
||||
WHERE store_code = fis.store_code AND store_name IS NOT NULL AND store_name != ''
|
||||
LIMIT 1
|
||||
) dist ON true
|
||||
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
|
||||
AND fis.ending_amount > 0
|
||||
${storeFilter}
|
||||
GROUP BY fis.store_code, ds.store_name
|
||||
GROUP BY fis.store_code, COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
|
||||
WHEN '2' THEN '中央厨房'
|
||||
WHEN '3' THEN '加工车间'
|
||||
WHEN '6' THEN '子仓库'
|
||||
ELSE '门店' || fis.store_code
|
||||
END)
|
||||
HAVING count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
|
||||
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7) > 0
|
||||
ORDER BY at_risk_value DESC
|
||||
`, params)
|
||||
|
||||
const total = parseInt(countResult.rows[0]?.total) || 0
|
||||
|
||||
sendSuccess(res, {
|
||||
summary,
|
||||
near_expiry: nearExpiryResult.rows,
|
||||
store_risk: storeRisk.rows,
|
||||
pagination: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total,
|
||||
total_pages: Math.ceil(total / pageSize),
|
||||
},
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
|
||||
@@ -11,6 +11,7 @@ const router = Router()
|
||||
router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
// 菜品级数据(Excel导入,可能不覆盖全部门店)
|
||||
const result = await query(`
|
||||
SELECT
|
||||
count(*) AS total_dishes,
|
||||
@@ -25,7 +26,35 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
FROM public.dish_cost_analysis_summary
|
||||
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows[0])
|
||||
// 总部驾驶舱同源数据(bill_fact物化视图,覆盖全部94家门店)
|
||||
const billResult = await query(`
|
||||
SELECT
|
||||
count(*) AS bill_count,
|
||||
count(DISTINCT store_code) AS total_stores,
|
||||
round(sum(received_total)::numeric, 2) AS total_sales,
|
||||
round(sum(consumption)::numeric, 2) AS total_consumption,
|
||||
round(sum(theoretical_cost)::numeric, 2) AS total_theo_cost,
|
||||
round(sum(theoretical_profit)::numeric, 2) AS total_theo_profit
|
||||
FROM analytics.bill_fact
|
||||
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
|
||||
`, [month])
|
||||
// 门店级成本数据(物化视图,93家门店,倒挤口径)
|
||||
const storeResult = await query(`
|
||||
SELECT
|
||||
count(*) AS store_count,
|
||||
round(sum(sales_received)::numeric, 2) AS store_total_sales,
|
||||
round(sum(theoretical_cost)::numeric, 2) AS store_total_theo_cost,
|
||||
round(sum(actual_food_cost)::numeric, 2) AS store_total_actual_cost,
|
||||
round(sum(actual_total_cost)::numeric, 2) AS store_total_all_cost,
|
||||
round(sum(food_cost_variance)::numeric, 2) AS store_total_variance,
|
||||
round(avg(theoretical_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%')::numeric, 2) AS store_avg_theo_cost_rate,
|
||||
round(avg(actual_food_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%')::numeric, 2) AS store_avg_actual_cost_rate,
|
||||
round((100 - avg(theoretical_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%'))::numeric, 2) AS store_avg_theo_margin,
|
||||
round((100 - avg(actual_food_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%'))::numeric, 2) AS store_avg_actual_margin
|
||||
FROM analytics.mv_store_theoretical_actual_cost_monthly
|
||||
WHERE month_start = $1
|
||||
`, [month])
|
||||
sendSuccess(res, { ...result.rows[0], ...billResult.rows[0], ...storeResult.rows[0] })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
@@ -192,8 +221,10 @@ router.get('/pricing', async (req: AuthRequest, res) => {
|
||||
const result = await query(`
|
||||
SELECT dish_name, dish_code, category_level1,
|
||||
round(price::numeric, 2) AS price,
|
||||
round(theoretical_cost::numeric, 2) AS theo_cost,
|
||||
round((theoretical_cost / nullif(price, 0) * 100)::numeric, 2) AS theo_cost_rate,
|
||||
round(sales_quantity::numeric, 2) AS sales_quantity,
|
||||
round(theoretical_cost::numeric, 2) AS theo_cost_total,
|
||||
round((theoretical_cost / nullif(sales_quantity, 0))::numeric, 2) AS theo_cost_per_unit,
|
||||
round((theoretical_cost / nullif(sales_quantity, 0) / nullif(price, 0) * 100)::numeric, 2) AS theo_cost_rate,
|
||||
round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
|
||||
round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
|
||||
round(sales_amount::numeric, 2) AS sales_amount
|
||||
|
||||
+262
-102
@@ -1964,89 +1964,104 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
|
||||
// 中央厨房/仓库编码(库存系统中不带前导零,配送系统中可能带或不带)
|
||||
const KITCHEN_CODES = "('2','3','6')"
|
||||
|
||||
// 检查配送物化视图是否包含当前月份数据
|
||||
const distMvCheck = await query(`SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1`, [month])
|
||||
if (distMvCheck.rows.length === 0) {
|
||||
console.warn(`[distribution/reconciliation] mv_distribution_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
|
||||
}
|
||||
|
||||
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([
|
||||
// 汇总
|
||||
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation, unmatchedStores, kitchenDetails] = await Promise.all([
|
||||
// 汇总:区分中央厨房/门店,用采购金额做倒挤
|
||||
query(`
|
||||
WITH dist AS (
|
||||
SELECT d.store_code, d.item_code,
|
||||
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
|
||||
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date
|
||||
GROUP BY d.store_code, d.item_code
|
||||
WITH kitchen AS (
|
||||
SELECT
|
||||
round(sum(opening_amount)::numeric,2) AS opening_amt,
|
||||
round(sum(purchase_amount)::numeric,2) AS purchase_amt,
|
||||
round(sum(consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(ending_amount)::numeric,2) AS ending_amt
|
||||
FROM analytics.fact_inventory_snapshot
|
||||
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND store_code IN ${KITCHEN_CODES}
|
||||
),
|
||||
inv AS (
|
||||
SELECT f.store_code, f.material_code,
|
||||
round(sum(f.opening_quantity)::numeric,2) AS opening_qty,
|
||||
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
|
||||
round(sum(f.consumption_quantity)::numeric,2) AS consumption_qty,
|
||||
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(f.ending_quantity)::numeric,2) AS ending_qty,
|
||||
round(sum(f.ending_amount)::numeric,2) AS ending_amt
|
||||
FROM analytics.fact_inventory_snapshot f
|
||||
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
GROUP BY f.store_code, f.material_code
|
||||
stores AS (
|
||||
SELECT
|
||||
round(sum(opening_amount)::numeric,2) AS opening_amt,
|
||||
round(sum(purchase_amount)::numeric,2) AS purchase_amt,
|
||||
round(sum(consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(ending_amount)::numeric,2) AS ending_amt,
|
||||
count(*) AS total_lines,
|
||||
count(*) FILTER(WHERE is_negative) AS neg_inventory_count
|
||||
FROM analytics.fact_inventory_snapshot
|
||||
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND store_code NOT IN ${KITCHEN_CODES}
|
||||
),
|
||||
recon AS (
|
||||
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
|
||||
COALESCE(d.item_code, i.material_code) AS item_code,
|
||||
COALESCE(d.dist_qty,0) AS dist_qty,
|
||||
COALESCE(d.dist_amt,0) AS dist_amt,
|
||||
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
|
||||
COALESCE(i.opening_qty,0) AS opening_qty,
|
||||
COALESCE(i.opening_amt,0) AS opening_amt,
|
||||
COALESCE(i.consumption_qty,0) AS consumption_qty,
|
||||
COALESCE(i.consumption_amt,0) AS consumption_amt,
|
||||
COALESCE(i.ending_qty,0) AS ending_qty,
|
||||
COALESCE(i.ending_amt,0) AS ending_amt
|
||||
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
|
||||
dist AS (
|
||||
SELECT
|
||||
round(sum(outbound_total_amount)::numeric,2) AS dist_amt,
|
||||
round(sum(cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax,
|
||||
round(sum(outbound_total_amount) FILTER (WHERE store_code NOT IN ${KITCHEN_CODES})::numeric,2) AS dist_to_stores_amt,
|
||||
round(sum(outbound_total_amount) FILTER (WHERE store_code IN ('3','6'))::numeric,2) AS dist_to_kitchen_amt
|
||||
FROM mv_distribution_monthly WHERE report_month = $1::date
|
||||
AND total_quantity > 0 AND outbound_total_amount > 0
|
||||
)
|
||||
SELECT
|
||||
count(*) AS total_lines,
|
||||
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty > 0) AS matched_lines,
|
||||
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty = 0) AS dist_only_lines,
|
||||
count(*) FILTER(WHERE dist_qty = 0 AND consumption_qty > 0) AS inv_only_lines,
|
||||
round(sum(dist_amt)::numeric,2) AS total_dist_amt,
|
||||
round(sum(dist_cost_excl_tax)::numeric,2) AS total_dist_cost_excl_tax,
|
||||
round(sum(opening_amt)::numeric,2) AS total_opening_amt,
|
||||
round(sum(consumption_amt)::numeric,2) AS total_consumption_amt,
|
||||
round(sum(ending_amt)::numeric,2) AS total_ending_amt,
|
||||
round((sum(opening_amt) + sum(dist_amt) - sum(ending_amt))::numeric,2) AS reverse_consumption_amt,
|
||||
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt)))::numeric,2) AS variance_amt,
|
||||
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt))) / NULLIF(sum(consumption_amt),0) * 100::numeric,2) AS variance_pct
|
||||
FROM recon
|
||||
k.opening_amt AS kitchen_opening,
|
||||
k.purchase_amt AS kitchen_purchase,
|
||||
k.consumption_amt AS kitchen_consumption,
|
||||
k.ending_amt AS kitchen_ending,
|
||||
s.opening_amt AS store_opening,
|
||||
s.purchase_amt AS store_purchase,
|
||||
s.consumption_amt AS store_consumption,
|
||||
s.ending_amt AS store_ending,
|
||||
s.total_lines AS store_total_lines,
|
||||
s.neg_inventory_count AS store_neg_inventory_count,
|
||||
d.dist_amt AS total_dist_amt,
|
||||
d.dist_cost_excl_tax AS total_dist_cost_excl_tax,
|
||||
d.dist_to_stores_amt AS dist_to_stores_amt,
|
||||
d.dist_to_kitchen_amt AS dist_to_kitchen_amt,
|
||||
-- 门店倒挤:期初 + 采购 - 期末 = 应耗用(库存自洽,必然等于实际耗用)
|
||||
round((s.opening_amt + s.purchase_amt - s.ending_amt)::numeric,2) AS store_reverse_consumption,
|
||||
-- 配送vs采购差异:配送发了多少 vs 门店入账了多少
|
||||
round((d.dist_to_stores_amt - s.purchase_amt)::numeric,2) AS dist_purchase_variance_amt,
|
||||
round((d.dist_to_stores_amt - s.purchase_amt) / NULLIF(d.dist_to_stores_amt,0) * 100::numeric,2) AS dist_purchase_variance_pct,
|
||||
-- 中央厨房库存变动 = 期末 - 期初
|
||||
round((k.ending_amt - k.opening_amt)::numeric,2) AS kitchen_inventory_change,
|
||||
-- 中央厨房净加工损耗 = 加工投入 - 总配送出库(含发往加工车间/分仓)
|
||||
round((k.consumption_amt - d.dist_amt)::numeric,2) AS kitchen_loss,
|
||||
-- 真实总耗用 = 门店最终耗用 + 中央厨房净加工损耗
|
||||
round((s.consumption_amt + (k.consumption_amt - d.dist_amt))::numeric,2) AS real_total_consumption
|
||||
FROM kitchen k, stores s, dist d
|
||||
`, [month]),
|
||||
// 门店维度对账
|
||||
// 门店维度对账(排除中央厨房,用采购金额做倒挤)
|
||||
query(`
|
||||
WITH dist AS (
|
||||
SELECT d.store_code,
|
||||
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
|
||||
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date
|
||||
GROUP BY d.store_code
|
||||
),
|
||||
inv AS (
|
||||
SELECT f.store_code,
|
||||
WITH inv AS (
|
||||
SELECT LPAD(f.store_code, 4, '0') AS store_code,
|
||||
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
|
||||
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
|
||||
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(f.ending_amount)::numeric,2) AS ending_amt,
|
||||
count(*) FILTER(WHERE f.is_negative) AS neg_inventory_count
|
||||
FROM analytics.fact_inventory_snapshot f
|
||||
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
GROUP BY f.store_code
|
||||
AND f.store_code NOT IN ${KITCHEN_CODES}
|
||||
GROUP BY LPAD(f.store_code, 4, '0')
|
||||
),
|
||||
dist AS (
|
||||
SELECT LPAD(d.store_code, 4, '0') AS store_code,
|
||||
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
|
||||
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
GROUP BY LPAD(d.store_code, 4, '0')
|
||||
),
|
||||
store_names AS (
|
||||
SELECT DISTINCT store_code, store_name FROM mv_distribution_monthly
|
||||
WHERE report_month = $1::date
|
||||
SELECT DISTINCT LPAD(store_code, 4, '0') AS store_code, store_name FROM mv_distribution_monthly
|
||||
WHERE report_month = $1::date AND store_code NOT IN ${KITCHEN_CODES}
|
||||
)
|
||||
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
|
||||
sn.store_name,
|
||||
@@ -2054,46 +2069,52 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
COALESCE(d.dist_amt,0) AS dist_amt,
|
||||
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
|
||||
COALESCE(i.opening_amt,0) AS opening_amt,
|
||||
COALESCE(i.purchase_amt,0) AS purchase_amt,
|
||||
COALESCE(i.consumption_amt,0) AS consumption_amt,
|
||||
COALESCE(i.ending_amt,0) AS ending_amt,
|
||||
round((COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct,
|
||||
COALESCE(i.neg_inventory_count,0) AS neg_inventory_count
|
||||
round((COALESCE(i.opening_amt,0) + COALESCE(i.purchase_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
|
||||
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0))::numeric,2) AS variance_amt,
|
||||
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct,
|
||||
COALESCE(i.neg_inventory_count,0) AS neg_inventory_count,
|
||||
CASE WHEN d.store_code IS NOT NULL AND i.store_code IS NULL THEN true ELSE false END AS is_unmatched
|
||||
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code
|
||||
LEFT JOIN store_names sn ON COALESCE(d.store_code, i.store_code) = sn.store_code
|
||||
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) DESC
|
||||
ORDER BY is_unmatched DESC, abs(COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0)) DESC
|
||||
`, [month]),
|
||||
// Top差异品项
|
||||
// Top差异品项(排除中央厨房,用采购金额做倒挤)
|
||||
query(`
|
||||
WITH dist AS (
|
||||
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
|
||||
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date
|
||||
GROUP BY d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
|
||||
),
|
||||
inv AS (
|
||||
SELECT f.store_code, f.material_code,
|
||||
WITH inv AS (
|
||||
SELECT LPAD(f.store_code, 4, '0') AS store_code, f.material_code,
|
||||
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
|
||||
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
|
||||
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(f.ending_amount)::numeric,2) AS ending_amt
|
||||
FROM analytics.fact_inventory_snapshot f
|
||||
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
GROUP BY f.store_code, f.material_code
|
||||
AND f.store_code NOT IN ${KITCHEN_CODES}
|
||||
GROUP BY LPAD(f.store_code, 4, '0'), f.material_code
|
||||
),
|
||||
dist AS (
|
||||
SELECT LPAD(d.store_code, 4, '0') AS store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
|
||||
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
GROUP BY LPAD(d.store_code, 4, '0'), d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
|
||||
)
|
||||
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
|
||||
d.dist_qty, d.dist_amt,
|
||||
COALESCE(i.opening_amt,0) AS opening_amt,
|
||||
COALESCE(i.purchase_amt,0) AS purchase_amt,
|
||||
COALESCE(i.consumption_amt,0) AS consumption_amt,
|
||||
COALESCE(i.ending_amt,0) AS ending_amt,
|
||||
round((COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct
|
||||
round((COALESCE(i.opening_amt,0) + COALESCE(i.purchase_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
|
||||
round((d.dist_amt - COALESCE(i.purchase_amt,0))::numeric,2) AS variance_amt,
|
||||
round((d.dist_amt - COALESCE(i.purchase_amt,0)) / NULLIF(d.dist_amt,0) * 100::numeric,2) AS variance_pct
|
||||
FROM dist d LEFT JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
|
||||
WHERE COALESCE(i.consumption_amt,0) > 0
|
||||
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) DESC
|
||||
WHERE d.dist_amt > 0
|
||||
ORDER BY abs(d.dist_amt - COALESCE(i.purchase_amt,0)) DESC
|
||||
LIMIT 30
|
||||
`, [month]),
|
||||
// 未匹配品项(有配送无库存耗用)
|
||||
@@ -2104,12 +2125,15 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
|
||||
count(DISTINCT d.store_code) AS store_count
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date
|
||||
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
GROUP BY d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
|
||||
),
|
||||
inv_items AS (
|
||||
SELECT DISTINCT material_code FROM analytics.fact_inventory_snapshot
|
||||
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND store_code NOT IN ${KITCHEN_CODES}
|
||||
)
|
||||
SELECT d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
|
||||
d.dist_qty, d.dist_amt, d.store_count
|
||||
@@ -2118,7 +2142,7 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
ORDER BY d.dist_amt DESC
|
||||
LIMIT 20
|
||||
`, [month]),
|
||||
// 品类维度对账
|
||||
// 品类维度对账(排除中央厨房)
|
||||
query(`
|
||||
WITH dist AS (
|
||||
SELECT COALESCE(NULLIF(d.minor_category,''),'未分类') AS minor_category,
|
||||
@@ -2127,22 +2151,25 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax,
|
||||
count(DISTINCT d.item_code) AS item_count
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date
|
||||
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类')
|
||||
),
|
||||
item_cat AS (
|
||||
SELECT DISTINCT ic.item_code, COALESCE(NULLIF(dm.minor_category,''), NULLIF(ic.minor_category,''),'未分类') AS minor_category
|
||||
FROM mv_distribution_monthly ic
|
||||
LEFT JOIN analytics.dim_material dm ON ic.item_code = dm.material_code
|
||||
WHERE ic.report_month = $1::date
|
||||
WHERE ic.report_month = $1::date AND ic.store_code NOT IN ${KITCHEN_CODES}
|
||||
),
|
||||
inv AS (
|
||||
SELECT ic.minor_category,
|
||||
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
|
||||
round(sum(f.ending_amount)::numeric,2) AS ending_amt
|
||||
FROM analytics.fact_inventory_snapshot f
|
||||
JOIN item_cat ic ON f.material_code = ic.item_code
|
||||
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND f.store_code NOT IN ${KITCHEN_CODES}
|
||||
GROUP BY ic.minor_category
|
||||
)
|
||||
SELECT COALESCE(d.minor_category, i.minor_category) AS minor_category,
|
||||
@@ -2151,13 +2178,65 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
COALESCE(d.dist_amt,0) AS dist_amt,
|
||||
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
|
||||
COALESCE(i.consumption_amt,0) AS consumption_amt,
|
||||
COALESCE(i.purchase_amt,0) AS purchase_amt,
|
||||
COALESCE(i.ending_amt,0) AS ending_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0) + COALESCE(i.ending_amt,0))::numeric,2) AS reverse_opening_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0))::numeric,2) AS variance_amt,
|
||||
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct
|
||||
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0))::numeric,2) AS variance_amt,
|
||||
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct
|
||||
FROM dist d FULL OUTER JOIN inv i ON d.minor_category = i.minor_category
|
||||
ORDER BY COALESCE(d.dist_amt,0) DESC
|
||||
`, [month]),
|
||||
// 未入账门店(有配送无库存记录)
|
||||
query(`
|
||||
WITH dist AS (
|
||||
SELECT LPAD(d.store_code, 4, '0') AS store_code, max(d.store_name) AS store_name,
|
||||
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
|
||||
count(DISTINCT d.item_code) AS item_count
|
||||
FROM mv_distribution_monthly d
|
||||
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
GROUP BY LPAD(d.store_code, 4, '0')
|
||||
)
|
||||
SELECT d.store_code, d.store_name, d.dist_amt, d.item_count
|
||||
FROM dist d
|
||||
WHERE d.store_code NOT IN (
|
||||
SELECT DISTINCT LPAD(store_code, 4, '0') FROM analytics.fact_inventory_snapshot
|
||||
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND store_code NOT IN ${KITCHEN_CODES}
|
||||
)
|
||||
ORDER BY d.dist_amt DESC
|
||||
`, [month]),
|
||||
// 中央厨房/加工车间/分仓 库存平衡明细
|
||||
query(`
|
||||
WITH inv AS (
|
||||
SELECT store_code,
|
||||
round(sum(opening_amount)::numeric,2) AS opening_amt,
|
||||
round(sum(purchase_amount)::numeric,2) AS purchase_amt,
|
||||
round(sum(consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(ending_amount)::numeric,2) AS ending_amt
|
||||
FROM analytics.fact_inventory_snapshot
|
||||
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND store_code IN ${KITCHEN_CODES}
|
||||
GROUP BY store_code
|
||||
),
|
||||
dist AS (
|
||||
SELECT store_code,
|
||||
round(sum(outbound_total_amount)::numeric,2) AS dist_received_amt
|
||||
FROM mv_distribution_monthly
|
||||
WHERE report_month = $1::date AND store_code IN ${KITCHEN_CODES}
|
||||
GROUP BY store_code
|
||||
)
|
||||
SELECT i.store_code,
|
||||
CASE i.store_code WHEN '2' THEN '中央厨房' WHEN '3' THEN '加工车间' WHEN '6' THEN '杭州分仓' ELSE i.store_code END AS store_name,
|
||||
i.opening_amt,
|
||||
COALESCE(d.dist_received_amt,0) AS dist_received_amt,
|
||||
i.purchase_amt,
|
||||
i.consumption_amt,
|
||||
i.ending_amt,
|
||||
round((i.opening_amt + COALESCE(d.dist_received_amt,0) - i.consumption_amt - i.ending_amt)::numeric,2) AS balance
|
||||
FROM inv i
|
||||
LEFT JOIN dist d ON i.store_code = d.store_code
|
||||
ORDER BY CASE i.store_code WHEN '2' THEN 1 WHEN '3' THEN 2 WHEN '6' THEN 3 ELSE 9 END
|
||||
`, [month]),
|
||||
])
|
||||
|
||||
const s = summary.rows[0] as any
|
||||
@@ -2166,18 +2245,33 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
|
||||
sendSuccess(res, {
|
||||
summary: {
|
||||
total_lines: parseIntSafe(s?.total_lines),
|
||||
matched_lines: parseIntSafe(s?.matched_lines),
|
||||
dist_only_lines: parseIntSafe(s?.dist_only_lines),
|
||||
inv_only_lines: parseIntSafe(s?.inv_only_lines),
|
||||
// 中央厨房
|
||||
kitchen_opening: parseNum(s?.kitchen_opening),
|
||||
kitchen_purchase: parseNum(s?.kitchen_purchase),
|
||||
kitchen_consumption: parseNum(s?.kitchen_consumption),
|
||||
kitchen_ending: parseNum(s?.kitchen_ending),
|
||||
// 门店
|
||||
store_opening: parseNum(s?.store_opening),
|
||||
store_purchase: parseNum(s?.store_purchase),
|
||||
store_consumption: parseNum(s?.store_consumption),
|
||||
store_ending: parseNum(s?.store_ending),
|
||||
store_total_lines: parseIntSafe(s?.store_total_lines),
|
||||
store_neg_inventory_count: parseIntSafe(s?.store_neg_inventory_count),
|
||||
store_reverse_consumption: parseNum(s?.store_reverse_consumption),
|
||||
store_variance_amt: parseNum(s?.store_variance_amt),
|
||||
store_variance_pct: parseNum(s?.store_variance_pct),
|
||||
// 配送
|
||||
total_dist_amt: parseNum(s?.total_dist_amt),
|
||||
total_dist_cost_excl_tax: parseNum(s?.total_dist_cost_excl_tax),
|
||||
total_opening_amt: parseNum(s?.total_opening_amt),
|
||||
total_consumption_amt: parseNum(s?.total_consumption_amt),
|
||||
total_ending_amt: parseNum(s?.total_ending_amt),
|
||||
reverse_consumption_amt: parseNum(s?.reverse_consumption_amt),
|
||||
variance_amt: parseNum(s?.variance_amt),
|
||||
variance_pct: parseNum(s?.variance_pct),
|
||||
dist_to_stores_amt: parseNum(s?.dist_to_stores_amt),
|
||||
dist_to_kitchen_amt: parseNum(s?.dist_to_kitchen_amt),
|
||||
// 中央厨房库存变动与加工损耗
|
||||
kitchen_inventory_change: parseNum(s?.kitchen_inventory_change),
|
||||
kitchen_loss: parseNum(s?.kitchen_loss),
|
||||
real_total_consumption: parseNum(s?.real_total_consumption),
|
||||
// 配送vs采购差异
|
||||
dist_purchase_variance_amt: parseNum(s?.dist_purchase_variance_amt),
|
||||
dist_purchase_variance_pct: parseNum(s?.dist_purchase_variance_pct),
|
||||
},
|
||||
storeReconciliation: storeReconciliation.rows.map((row: any) => ({
|
||||
...row,
|
||||
@@ -2185,18 +2279,21 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
dist_amt: parseNum(row.dist_amt),
|
||||
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
|
||||
opening_amt: parseNum(row.opening_amt),
|
||||
purchase_amt: parseNum(row.purchase_amt),
|
||||
consumption_amt: parseNum(row.consumption_amt),
|
||||
ending_amt: parseNum(row.ending_amt),
|
||||
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
|
||||
variance_amt: parseNum(row.variance_amt),
|
||||
variance_pct: parseNum(row.variance_pct),
|
||||
neg_inventory_count: parseIntSafe(row.neg_inventory_count),
|
||||
is_unmatched: row.is_unmatched || false,
|
||||
})),
|
||||
topVariances: topVariances.rows.map((row: any) => ({
|
||||
...row,
|
||||
dist_qty: parseNum(row.dist_qty),
|
||||
dist_amt: parseNum(row.dist_amt),
|
||||
opening_amt: parseNum(row.opening_amt),
|
||||
purchase_amt: parseNum(row.purchase_amt),
|
||||
consumption_amt: parseNum(row.consumption_amt),
|
||||
ending_amt: parseNum(row.ending_amt),
|
||||
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
|
||||
@@ -2209,6 +2306,20 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
dist_amt: parseNum(row.dist_amt),
|
||||
store_count: parseIntSafe(row.store_count),
|
||||
})),
|
||||
unmatchedStores: unmatchedStores.rows.map((row: any) => ({
|
||||
...row,
|
||||
dist_amt: parseNum(row.dist_amt),
|
||||
item_count: parseIntSafe(row.item_count),
|
||||
})),
|
||||
kitchenDetails: kitchenDetails.rows.map((row: any) => ({
|
||||
...row,
|
||||
opening_amt: parseNum(row.opening_amt),
|
||||
dist_received_amt: parseNum(row.dist_received_amt),
|
||||
purchase_amt: parseNum(row.purchase_amt),
|
||||
consumption_amt: parseNum(row.consumption_amt),
|
||||
ending_amt: parseNum(row.ending_amt),
|
||||
balance: parseNum(row.balance),
|
||||
})),
|
||||
categoryReconciliation: categoryReconciliation.rows.map((row: any) => ({
|
||||
...row,
|
||||
item_count: parseIntSafe(row.item_count),
|
||||
@@ -2216,8 +2327,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
dist_amt: parseNum(row.dist_amt),
|
||||
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
|
||||
consumption_amt: parseNum(row.consumption_amt),
|
||||
purchase_amt: parseNum(row.purchase_amt),
|
||||
ending_amt: parseNum(row.ending_amt),
|
||||
reverse_opening_amt: parseNum(row.reverse_opening_amt),
|
||||
variance_amt: parseNum(row.variance_amt),
|
||||
variance_pct: parseNum(row.variance_pct),
|
||||
})),
|
||||
@@ -2227,6 +2338,55 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 未匹配品项的门店配送明细
|
||||
router.get('/distribution/unmatched-item-details', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const itemCode = (req.query.item_code as string) || ''
|
||||
if (!itemCode) return sendError(res, 'item_code is required')
|
||||
|
||||
const KITCHEN_CODES = "('2','3','6')"
|
||||
const rows = await query(`
|
||||
SELECT d.store_code, d.store_name, d.item_code, d.item_name, d.unit,
|
||||
d.major_category, d.minor_category,
|
||||
round(d.total_quantity::numeric,2) AS dist_qty,
|
||||
round(d.outbound_total_amount::numeric,2) AS dist_amt,
|
||||
round(d.cost_excl_tax_amount::numeric,2) AS dist_cost_excl_tax,
|
||||
COALESCE(i.consumption_amt,0) AS consumption_amt,
|
||||
COALESCE(i.purchase_amt,0) AS purchase_amt,
|
||||
CASE WHEN i.material_code IS NOT NULL THEN true ELSE false END AS has_inventory
|
||||
FROM mv_distribution_monthly d
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
|
||||
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
|
||||
f.material_code
|
||||
FROM analytics.fact_inventory_snapshot f
|
||||
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
|
||||
AND LPAD(f.store_code, 4, '0') = LPAD(d.store_code, 4, '0')
|
||||
AND f.material_code = d.item_code
|
||||
GROUP BY f.material_code
|
||||
) i ON true
|
||||
WHERE d.report_month = $1::date
|
||||
AND d.item_code = $2
|
||||
AND d.store_code NOT IN ${KITCHEN_CODES}
|
||||
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
|
||||
ORDER BY d.outbound_total_amount DESC
|
||||
`, [month, itemCode])
|
||||
|
||||
sendSuccess(res, rows.rows.map((row: any) => ({
|
||||
...row,
|
||||
dist_qty: parseFloat(row.dist_qty) || 0,
|
||||
dist_amt: parseFloat(row.dist_amt) || 0,
|
||||
dist_cost_excl_tax: parseFloat(row.dist_cost_excl_tax) || 0,
|
||||
consumption_amt: parseFloat(row.consumption_amt) || 0,
|
||||
purchase_amt: parseFloat(row.purchase_amt) || 0,
|
||||
has_inventory: row.has_inventory || false,
|
||||
})))
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 多级BOM成本穿透
|
||||
router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
|
||||
@@ -319,7 +319,7 @@ router.get('/achievement', async (req: AuthRequest, res) => {
|
||||
t.bill_count_target,
|
||||
COALESCE(s.bill_count, 0) AS actual_bill_count,
|
||||
CASE WHEN t.bill_count_target > 0
|
||||
THEN ROUND(COALESCE(s.bill_count, 0) / t.bill_count_target * 100, 2)
|
||||
THEN ROUND(COALESCE(s.bill_count, 0)::numeric / t.bill_count_target * 100, 2)
|
||||
ELSE 0 END AS bill_achievement_rate
|
||||
FROM analytics.v3_store_monthly_target t
|
||||
LEFT JOIN analytics.mv_store_risk_rating_monthly s
|
||||
|
||||
@@ -264,12 +264,28 @@ router.post('/indicators', async (req: AuthRequest, res) => {
|
||||
router.get('/loop-health', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await query(`SELECT * FROM analytics.mv_loop_health`)
|
||||
sendSuccess(res, result.rows[0] || {
|
||||
const loopHealth = result.rows[0] || {
|
||||
task_generation_rate: 0,
|
||||
store_execution_rate: 0,
|
||||
weekly_check_rate: 0,
|
||||
monthly_review_rate: 0,
|
||||
practice_promotion_rate: 0,
|
||||
}
|
||||
|
||||
// 信号发现数:最新业务月中红色+黄色风险门店数
|
||||
const signalResult = await query(`
|
||||
SELECT count(*) AS cnt FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE month_start = (SELECT max(month_start) FROM analytics.mv_store_risk_rating_monthly WHERE received IS NOT NULL)
|
||||
AND risk_level IN ('红色', '黄色')
|
||||
`)
|
||||
|
||||
// 经验标准化数:standardized_practice 表记录数
|
||||
const practiceResult = await query(`SELECT count(*) AS cnt FROM analytics.standardized_practice`)
|
||||
|
||||
sendSuccess(res, {
|
||||
...loopHealth,
|
||||
signal_count: parseInt(signalResult.rows[0].cnt),
|
||||
practice_count: parseInt(practiceResult.rows[0].cnt),
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
|
||||
@@ -55,15 +55,26 @@ async function dailyTargetCard() {
|
||||
async function hourlyAchievementCheck() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
// 查最新有业务数据的月份(业务数据可能滞后于当前月)
|
||||
const latestMonthResult = await query(`
|
||||
SELECT month_start FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE received IS NOT NULL ORDER BY month_start DESC LIMIT 1
|
||||
`)
|
||||
if (latestMonthResult.rows.length === 0) {
|
||||
console.log('[hourlyAchievementCheck] No business data available, skipping')
|
||||
return
|
||||
}
|
||||
const latestMonth = latestMonthResult.rows[0].month_start
|
||||
|
||||
const targets = await query(`
|
||||
SELECT dt.store_code, dt.store_name, dt.revenue_target,
|
||||
COALESCE(s.received, 0) AS actual_revenue
|
||||
FROM analytics.v3_daily_target dt
|
||||
LEFT JOIN analytics.mv_store_risk_rating_monthly s
|
||||
ON s.store_code = dt.store_code
|
||||
AND s.month_start = DATE_TRUNC('month', $1::date)::date
|
||||
AND s.month_start = $2::date
|
||||
WHERE dt.target_date = $1::date
|
||||
`, [today])
|
||||
`, [today, latestMonth])
|
||||
|
||||
for (const row of targets.rows) {
|
||||
const achievement = Number(row.revenue_target) > 0
|
||||
@@ -83,7 +94,7 @@ async function hourlyAchievementCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[hourlyAchievementCheck] Checked ${targets.rows.length} stores`)
|
||||
console.log(`[hourlyAchievementCheck] Checked ${targets.rows.length} stores (business month: ${latestMonth})`)
|
||||
}
|
||||
|
||||
// T-125: 每日22:30 生成日复盘模板
|
||||
@@ -118,6 +129,17 @@ async function alertEngineScan() {
|
||||
WHERE is_enabled = true
|
||||
`)
|
||||
|
||||
// 查最新有业务数据的月份(业务数据可能滞后于当前月)
|
||||
const latestMonthResult = await query(`
|
||||
SELECT month_start FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE received IS NOT NULL ORDER BY month_start DESC LIMIT 1
|
||||
`)
|
||||
if (latestMonthResult.rows.length === 0) {
|
||||
console.log('[alertEngineScan] No business data available, skipping')
|
||||
return
|
||||
}
|
||||
const latestMonth = latestMonthResult.rows[0].month_start
|
||||
|
||||
for (const rule of rules.rows) {
|
||||
let metricData: { store_code: string; store_name: string; value: number }[] = []
|
||||
|
||||
@@ -131,25 +153,23 @@ async function alertEngineScan() {
|
||||
FROM analytics.v3_daily_target dt
|
||||
LEFT JOIN analytics.mv_store_risk_rating_monthly s
|
||||
ON s.store_code = dt.store_code
|
||||
AND s.month_start = DATE_TRUNC('month', $1::date)::date
|
||||
AND s.month_start = $2::date
|
||||
WHERE dt.target_date = $1::date
|
||||
`, [today])
|
||||
`, [today, latestMonth])
|
||||
metricData = result.rows
|
||||
} else if (rule.metric === 'theoretical_margin_pct') {
|
||||
const monthStart = new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`
|
||||
SELECT store_code, store_name, COALESCE(theoretical_margin_pct, 0) AS value
|
||||
FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE month_start = $1::date
|
||||
`, [monthStart])
|
||||
`, [latestMonth])
|
||||
metricData = result.rows
|
||||
} else if (rule.metric === 'member_bill_share_pct') {
|
||||
const monthStart = new Date().toISOString().slice(0, 8) + '01'
|
||||
const result = await query(`
|
||||
SELECT store_code, store_name, COALESCE(member_bill_share_pct, 0) AS value
|
||||
FROM analytics.mv_store_risk_rating_monthly
|
||||
WHERE month_start = $1::date
|
||||
`, [monthStart])
|
||||
`, [latestMonth])
|
||||
metricData = result.rows
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user