#!/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())