dalongxia/stock-risk-alerts
2026-03-04 13:25:48 +08:00

489 lines
16 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
import argparse
import subprocess
import sys
def build_sql(days: int, lead_days: int, warn_days: int, limit: int, include_safe: bool) -> str:
include_safe_bit = 1 if include_safe else 0
return f"""
USE [JSDataMiddlePlatform];
SET NOCOUNT ON;
DECLARE @lookback_days int = {days};
DECLARE @lead_days int = {lead_days};
DECLARE @warn_days int = {warn_days};
DECLARE @limit int = {limit};
DECLARE @include_safe bit = {include_safe_bit};
DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE());
DECLARE @end_time datetime2 = GETDATE();
IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL
DROP TABLE #ship_base;
SELECT
NULLIF(a.undefined10, '') AS sku_id,
NULLIF(a.undefined2, '') AS order_id,
NULLIF(a.undefined12, '') AS product_id,
ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name,
CASE
WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0
THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1)
ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道')
END AS shop_channel,
ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name,
COALESCE(
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120),
TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120),
TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111),
TRY_CONVERT(datetime2, a.createdAt)
) AS ship_dt,
COALESCE(
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')),
TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')),
CAST(0 AS decimal(18, 2))
) AS shipped_qty
INTO #ship_base
FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK)
WHERE ISNULL(NULLIF(a.undefined10, ''), '') <> ''
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%'
AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%';
IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL
DROP TABLE #ship_stats;
SELECT
sb.sku_id,
COUNT(DISTINCT sb.order_id) AS order_count_30d,
COUNT(DISTINCT CASE WHEN ISNULL(sb.product_id, '') <> '' THEN sb.product_id END) AS product_id_count_30d,
COUNT(DISTINCT sb.shop_name) AS shop_count_30d,
SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -7, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_7d,
SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -30, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_30d,
MAX(sb.ship_dt) AS last_ship_time
INTO #ship_stats
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id;
IF OBJECT_ID('tempdb..#sku_ref') IS NOT NULL
DROP TABLE #sku_ref;
SELECT
s.sku_id,
s.name AS sku_name,
s.other_1 AS spu,
s.i_id AS style_code,
s.category,
s.item_type,
s.brand,
s.supplier_name,
s.other_3 AS default_warehouse,
s.other_5 AS sku_status,
s.cost_price,
s.other_10 AS board_material,
s.modified
INTO #sku_ref
FROM dbo.jst_sku AS s WITH (NOLOCK)
WHERE NULLIF(s.sku_id, '') IN (SELECT sku_id FROM #ship_stats);
IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL
DROP TABLE #inv_latest;
WITH inv_rank AS (
SELECT
NULLIF(i.sku_id, '') AS sku_id,
TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty,
TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty,
TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty,
TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty,
TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty,
TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty,
TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty,
TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty,
TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty,
TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty,
TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty,
i.wms_co_id,
i.name AS inventory_note,
COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time,
ROW_NUMBER() OVER (
PARTITION BY NULLIF(i.sku_id, '')
ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC
) AS rn
FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK)
WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #ship_stats)
)
SELECT
sku_id,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
virtual_qty,
defective_qty,
purchase_qty,
return_qty,
in_qty,
min_qty,
max_qty,
wms_co_id,
inventory_note,
inventory_snapshot_time,
CASE
WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0
THEN CAST(0 AS decimal(18, 2))
ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0)
END AS available_qty
INTO #inv_latest
FROM inv_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL
DROP TABLE #sku_owner;
SELECT
sr.sku_id,
COALESCE(
NULLIF(pc_exact.purchaser, ''),
NULLIF(pc_style.purchaser, ''),
NULLIF(pc_base.purchaser, ''),
'未分配'
) AS purchaser_name,
COALESCE(
NULLIF(po_exact.oper, ''),
NULLIF(po_style.oper, ''),
NULLIF(po_base.oper, ''),
NULLIF(pc_exact.oper, ''),
NULLIF(pc_style.oper, ''),
NULLIF(pc_base.oper, ''),
'未分配'
) AS spu_oper_name
INTO #sku_owner
FROM #sku_ref AS sr
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = NULLIF(sr.spu, '')
ORDER BY c.Id DESC
) AS pc_exact
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = NULLIF(sr.style_code, '')
ORDER BY c.Id DESC
) AS pc_style
OUTER APPLY (
SELECT TOP 1 c.purchaser, c.oper
FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK)
WHERE NULLIF(c.spu, '') = CASE
WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0
THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1)
ELSE NULL
END
ORDER BY c.Id DESC
) AS pc_base
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = NULLIF(sr.spu, '')
ORDER BY o.Id DESC
) AS po_exact
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = NULLIF(sr.style_code, '')
ORDER BY o.Id DESC
) AS po_style
OUTER APPLY (
SELECT TOP 1 o.oper
FROM dbo.op_operManage AS o WITH (NOLOCK)
WHERE NULLIF(o.spu, '') = CASE
WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0
THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1)
ELSE NULL
END
ORDER BY o.Id DESC
) AS po_base;
IF OBJECT_ID('tempdb..#operator_top') IS NOT NULL
DROP TABLE #operator_top;
WITH oper_rank AS (
SELECT
sb.sku_id,
NULLIF(t.oper, '') AS oper_name,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, NULLIF(t.oper, '') ASC
) AS rn
FROM #ship_base AS sb
INNER JOIN dbo.op_TargetManagement AS t WITH (NOLOCK)
ON NULLIF(t.shopproid, '') = NULLIF(sb.product_id, '')
AND ISNULL(NULLIF(t.shopname, ''), '') = ISNULL(NULLIF(sb.shop_name, ''), '')
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
AND ISNULL(NULLIF(t.oper, ''), '') <> ''
GROUP BY sb.sku_id, NULLIF(t.oper, '')
)
SELECT
sku_id,
oper_name AS operator_name
INTO #operator_top
FROM oper_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#channel_top') IS NOT NULL
DROP TABLE #channel_top;
WITH channel_rank AS (
SELECT
sb.sku_id,
sb.shop_channel,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_channel ASC
) AS rn
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id, sb.shop_channel
)
SELECT
sku_id,
shop_channel AS top_shop_channel,
shipped_qty_sum_30d AS top_channel_shipped_qty_30d
INTO #channel_top
FROM channel_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#shop_top') IS NOT NULL
DROP TABLE #shop_top;
WITH shop_rank AS (
SELECT
sb.sku_id,
sb.shop_name,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_name ASC
) AS rn
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
GROUP BY sb.sku_id, sb.shop_name
)
SELECT
sku_id,
shop_name AS top_shop_name,
shipped_qty_sum_30d AS top_shop_shipped_qty_30d
INTO #shop_top
FROM shop_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#warehouse_top') IS NOT NULL
DROP TABLE #warehouse_top;
WITH wh_rank AS (
SELECT
sb.sku_id,
sb.ship_warehouse_name,
SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d,
ROW_NUMBER() OVER (
PARTITION BY sb.sku_id
ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.ship_warehouse_name ASC
) AS rn
FROM #ship_base AS sb
WHERE sb.ship_dt >= @start_time
AND sb.ship_dt < @end_time
AND ISNULL(NULLIF(sb.ship_warehouse_name, ''), '') <> ''
GROUP BY sb.sku_id, sb.ship_warehouse_name
)
SELECT
sku_id,
ship_warehouse_name AS top_warehouse_name
INTO #warehouse_top
FROM wh_rank
WHERE rn = 1;
IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL
DROP TABLE #risk_eval;
SELECT
sr.sku_id,
sr.sku_name,
sr.spu,
sr.category,
sr.item_type,
sr.brand,
sr.cost_price,
sr.board_material,
sr.sku_status,
COALESCE(NULLIF(sr.supplier_name, ''), '未标注') AS factory_name,
COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name,
COALESCE(NULLIF(ot.operator_name, ''), NULLIF(ow.spu_oper_name, ''), '未分配') AS operator_name,
COALESCE(NULLIF(wt.top_warehouse_name, ''), NULLIF(sr.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name,
ct.top_shop_channel,
stp.top_shop_name,
ISNULL(ss.product_id_count_30d, 0) AS product_id_count_30d,
ISNULL(ss.shop_count_30d, 0) AS shop_count_30d,
ISNULL(ss.order_count_30d, 0) AS order_count_30d,
ISNULL(ss.shipped_qty_7d, 0) AS shipped_qty_7d,
ISNULL(ss.shipped_qty_30d, 0) AS shipped_qty_30d,
ss.last_ship_time,
inv.stock_qty,
inv.order_lock_qty,
inv.pick_lock_qty,
inv.lock_qty,
inv.available_qty,
inv.virtual_qty,
inv.defective_qty,
inv.purchase_qty,
inv.return_qty,
inv.in_qty,
inv.min_qty,
inv.max_qty,
inv.inventory_snapshot_time,
CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d,
CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL
ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)
END AS decimal(18, 2)
) AS estimated_stockout_days,
@lead_days AS replenishment_lead_days,
CAST(
CASE
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0
ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty
END AS decimal(18, 2)
) AS suggest_replenish_qty_to_lead_days,
CASE
WHEN inv.sku_id IS NULL THEN '无库存快照'
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货'
WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)'
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)'
ELSE '库存安全'
END AS stock_risk_level,
CASE
WHEN inv.sku_id IS NULL THEN 1
WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0
WHEN inv.available_qty <= 0 THEN 1
WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN 1
ELSE 0
END AS has_stock_risk
INTO #risk_eval
FROM #ship_stats AS ss
LEFT JOIN #sku_ref AS sr
ON sr.sku_id = ss.sku_id
LEFT JOIN #inv_latest AS inv
ON inv.sku_id = ss.sku_id
LEFT JOIN #sku_owner AS ow
ON ow.sku_id = ss.sku_id
LEFT JOIN #operator_top AS ot
ON ot.sku_id = ss.sku_id
LEFT JOIN #channel_top AS ct
ON ct.sku_id = ss.sku_id
LEFT JOIN #shop_top AS stp
ON stp.sku_id = ss.sku_id
LEFT JOIN #warehouse_top AS wt
ON wt.sku_id = ss.sku_id;
-- 1) 汇总:近30天在售SKU + 缺货风险SKU数量
SELECT
COUNT(*) AS sold_sku_count_30d,
SUM(CASE WHEN has_stock_risk = 1 THEN 1 ELSE 0 END) AS stock_risk_sku_count,
SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN 1 ELSE 0 END) AS urgent_sku_count,
SUM(CASE WHEN stock_risk_level LIKE '补货预警%' THEN 1 ELSE 0 END) AS warning_sku_count,
@lookback_days AS lookback_days,
@lead_days AS replenishment_lead_days,
@warn_days AS warning_days
FROM #risk_eval;
-- 2) 明细:全量SKU风险清单(默认只显示风险项)
SELECT TOP (@limit)
sku_id,
sku_name,
spu,
category,
item_type,
brand,
sku_status,
cost_price,
board_material,
factory_name,
purchaser_name,
operator_name,
warehouse_name,
top_shop_channel,
top_shop_name,
product_id_count_30d,
shop_count_30d,
order_count_30d,
shipped_qty_7d,
shipped_qty_30d,
avg_daily_shipped_7d,
avg_daily_shipped_30d,
stock_qty,
order_lock_qty,
pick_lock_qty,
lock_qty,
available_qty,
estimated_stockout_days,
replenishment_lead_days,
suggest_replenish_qty_to_lead_days,
stock_risk_level,
inventory_snapshot_time,
last_ship_time
FROM #risk_eval
WHERE @include_safe = 1
OR has_stock_risk = 1
ORDER BY
CASE
WHEN stock_risk_level LIKE '缺货%' THEN 1
WHEN stock_risk_level LIKE '补货紧急%' THEN 2
WHEN stock_risk_level LIKE '补货预警%' THEN 3
WHEN stock_risk_level = '无库存快照' THEN 4
ELSE 9
END,
estimated_stockout_days ASC,
shipped_qty_30d DESC,
sku_id ASC;
"""
def main() -> int:
parser = argparse.ArgumentParser(description="All-SKU stock risk alert report")
parser.add_argument("--days", type=int, default=30, help="lookback window days, default 30")
parser.add_argument("--lead-days", type=int, default=10, help="replenishment lead time days")
parser.add_argument("--warn-days", type=int, default=15, help="warning line days (must >= lead-days)")
parser.add_argument("--limit", type=int, default=500, help="max rows for detail result set")
parser.add_argument("--include-safe", action="store_true", help="include safe SKUs in detail output")
args = parser.parse_args()
days = max(7, min(args.days, 180))
lead_days = max(1, min(args.lead_days, 60))
warn_days = max(lead_days, min(args.warn_days, 90))
limit = max(1, min(args.limit, 5000))
sql = build_sql(days, lead_days, warn_days, limit, args.include_safe)
cmd = ["company-db", "query", "--sql", sql]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "company-db failed").strip()
print(err, file=sys.stderr)
return proc.returncode
print(proc.stdout.rstrip())
return 0
if __name__ == "__main__":
raise SystemExit(main())