ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用SQL实现ABC库存分类:帕累托分析实战指南

用SQL实现ABC库存分类:帕累托分析实战指南 我见过不少做仓库和供应链的团队手里商品几万个SKU库存金额压得死死的但你要问他们到底哪一批货需要精细化管理哪一批只要保证不缺货就行很多人答不上来。原因很简单大家平时只看了单品的销量或库存数量没有把价值贡献这件事拉开来看。ABC库存分类帕累托分析就是解决这个问题的经典工具把贡献了主要销售额的那一小撮SKU挑出来重点管把那些又杂又散的尾部商品归为一类粗放管。这套东西用Excel也能算但一旦SKU数量过万或者需要按周按月滚动刷新SQL就成了最靠谱的批量计算方式。这篇文章我会把ABC分类背后的规则、SQL实现、边界处理和真实项目里的坑一次性讲透。1. 先弄清楚ABC分类到底在算什么1.1 帕累托法则为什么在库存管理里有效大约在19世纪末经济学家帕累托研究意大利土地分配时发现约20%的人掌握了80%的土地。这个少数关键、多数次要的分布规律后来被大量行业验证放在库存管理里表现就是一小部分SKU贡献了大部分销售额另一大堆SKU只贡献很小一部分。这个规律之所以在库存领域反复出现本质原因是需求分布天然不均匀。爆款永远只有那几个长尾商品则数不胜数。如果企业不对商品做区分对每一款SKU用同一套补货、盘点、周转标准那结果一定是人手被长尾商品拖垮资金压在不值钱的库存上真正值钱的爆款反而因为管理粗糙而缺货。所以ABC分类的核心目标不是给商品贴个标签而是回答一个问题有限的仓储空间、采购资金和管理精力应该优先投给哪些商品这决定了后续的补货策略、盘点频率、安全库存设置。说到底这是一次资源分配规则的梳理而SQL只是把规则落到数据上的执行手段。1.2 A/B/C三档到底怎么划最经典的口径是下面这套分类累计销售额占比区间管理策略A类0%~70%重点管理高频盘点安全库存拉高B类70%~90%常规管理定期复盘平衡补货C类90%~100%粗放管理系统自动补货减少人工干预这个70/90来源于管理经验不是数学铁律。实际操作中有些公司会把A类的线拉到60%有些会把B类的线拉到95%都没有问题。真正重要的是你一旦定下口径就要在全公司统一执行并且把累计贡献率的计算方式写清楚否则财务、运营、仓库各算各的最后一定会吵架。需要特别注意的是ABC划分的底层变量是累计销售额占比不是单品销售额排名。两者看着长得像实际区别很大。单纯看排名你只知道第10名是谁看累计占比你才知道从第1名到第10名的商品合计贡献了全盘多少销售额。后者的信息量才能支撑资源分配决策。1.3 为什么排序选金额而不是数量讲一个我实际遇到的例子。某包装材料公司有2万个SKU按出库数量排序前100名几乎全是几块钱的胶带和气泡膜按销售额排序排在最前面的却是单价两三千元的专用设备耗材。如果按数量做重点管理仓库会拼命备4块钱的胶带结果耗材缺货的投诉不断。ABC分析的本质是钱流管理因为库存金额、仓储费用、资金占用都和商品单价直接相关。所以绝大多数企业选销售额或毛利额作为排序字段。只有少数行业例外比如快递包装按体积重量收费或者生鲜电商按销量摊销损耗才会考虑用数量或体积作为主维度。选哪一个指标取决于你的瓶颈资源是什么这个选择是动手写SQL之前必须想清楚的第一件事。2. 动手写SQL之前必须定死的一堆口径2.1 用销售明细还是出库明细我见过不少新手一上来就写SELECT sku_id, SUM(amount) FROM order_detail GROUP BY sku_id然后拿到的分类结果被业务方全盘否决。为什么因为你统计的是下单金额里面可能包含用户拍了又退的订单也可能把赠品、售后补发件算进去了。做ABC分类建议优先使用已经出库、且确认收入的交易明细。如果企业有独立的出库流水表或者发货表用那张表更干净。没有的话就得在下单明细里通过状态字段过滤只保留已支付且未退款、已发货且未退货的单据。口径的差异在整体数据量大时会被放大C类商品可能因为一股异常退款冲进来好几十万伪销售额。2.2 统计周期选多长周期太短比如只看近7天冷门但季度性强的商品会被严重低估周期太长比如看两年今年的新品又会被一两年前的爆款压住。我常用的做法是滚动90天对大多数快消、制造、电商场景都比较稳。如果你的业务有明显的季节性比如服装、节日礼品建议同时算两个窗口近90天用于日常管理近一年用于备货计划。你要的ABC分类是跟着管理动作走的管理动作频率不同统计窗口就该不同。用SQL实现时只需要把WHERE order_date ...里的日期条件换掉其他代码完全不用改。2.3 粒度划到SKU还是品类粒度决定分类结果的可执行性。按SKU分颗粒度细但SKU动辄几万个A类可能仍然有几百个按品类分颗粒度粗适合集团层面看资源配置但不适合仓库具体排货。我的建议是分层做先用SQL按SKU算出最基础的ABC得到每个商品的分类然后按品类聚合看品类层面的ABC分布。这样无论是采购部门管单品还是管理层看品牌线都能找到对应的口径。SQL层面只需要在GROUP BY那里切换字段逻辑完全复用。2.4 脏数据怎么处理这一步必须在SQL里写死不要指望业务方事后解释。常见要过滤的测试订单、负数金额的调整单、赠品零金额单、内部领用和员工内购。还要注意退款处理如果一笔订单在统计期初下单、期末退款金额可能还在表里需要按最终实际成交状态过滤。一个比较稳妥的过滤条件是同时满足order_status completed、pay_status paid、refund_status none、amount 0。如果数据质量差的系统里没有这些状态字段至少要排除掉金额小于等于0的单据再用业务备注字段补一层过滤。脏数据不清理ABC分类的结果就是空中楼阁后面所有管理决策都会跟着歪。3. 一条SQL算出累计贡献占比的完整写法3.1 为什么用窗口函数而不是自连接很多人第一反应是算累计金额嘛我用自连接把比自己销售额大的都加起来。这个思路本身没错但存在两个问题一是自连接是笛卡尔积几万个SKU做笛卡尔积会产生上亿行中间结果性能完全扛不住二是代码又长又难读维护成本高。窗口函数SUM() OVER (ORDER BY ...)就是专门干这个的。它在一次扫描中同时保留每个SKU的销售额和到当前行为止的累计值不需要自连接。SQL从2003标准开始就支持窗口函数主流数据库都有所以这条路是通的。3.2 基于销售明细表的完整SQL示例下面这段SQL以SQL Server为例逻辑可以直接平移。前提是明细表sales_detail里每个订单会有多行明细一行代表一个SKU。WITH sku_sales AS ( SELECT sku_id, MAX(sku_name) AS sku_name, SUM(amount) AS sales_amount, SUM(qty) AS sales_qty, COUNT(DISTINCT order_no) AS order_cnt FROM sales_detail WHERE order_date DATEADD(MONTH, -3, GETDATE()) AND order_status completed AND refund_status none AND amount 0 GROUP BY sku_id ), ranked AS ( SELECT sku_id, sku_name, sales_amount, sales_qty, order_cnt, ROW_NUMBER() OVER (ORDER BY sales_amount DESC, sku_id) AS rn, SUM(sales_amount) OVER ( ORDER BY sales_amount DESC, sku_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_amount, SUM(sales_amount) OVER () AS total_amount FROM sku_sales ) SELECT sku_id, sku_name, sales_amount, sales_qty, order_cnt, rn, running_amount, total_amount, ROUND(running_amount * 1.0 / total_amount, 4) AS cum_ratio, CASE WHEN running_amount * 1.0 / total_amount 0.70 THEN A WHEN running_amount * 1.0 / total_amount 0.90 THEN B ELSE C END AS abc_category FROM ranked ORDER BY rn;这段代码分两层第一层sku_sales把明细聚合成每个SKU一行第二层ranked用窗口函数同时算出三样东西——全量总金额total_amount、累计金额running_amount、排序号rn。最后在SELECT里直接算累计占比并套CASE得出ABC分类。这里有一个非常关键的细节窗口函数的ORDER BY sales_amount DESC, sku_id。我故意在后面加了sku_id作为次级排序键目的在下一章讲并列值时会详细说明。你先记住尽量不要只写ORDER BY sales_amount DESC否则并列销售额的商品之间会以不确定的顺序排列累计金额结果会不稳定。3.3 各个数据库的写法差异如果把上面这段代码移植到其他数据库只有日期函数和几个关键字不同数据库日期过滤写法注意事项MySQL 8.0order_date DATE_SUB(CURDATE(), INTERVAL 3 MONTH)窗口函数支持完整放心用PostgreSQLorder_date CURRENT_DATE - INTERVAL 3 month语法最标准* 1.0可省略SQLiteorder_date date(now, -3 month)3.25版本之后才支持窗口函数Hive / Spark SQLorder_date add_months(current_date, -3)支持ROWS BETWEEN注意并行度设置MySQL 8.0版本只要把DATEADD(MONTH, -3, GETDATE())换成DATE_SUB(CURDATE(), INTERVAL 3 MONTH)其余代码完全不用动。用MySQL 5.7及以下、SQL Server 2008等不支持窗口函数的旧版本替代方案我在第5章专门讲这里先不展开。4. 分类判定的边界问题4.1 累计占比刚好等于0.70算A还是算B我见过好几个团队在这上面吵起来。有的说等于0.70说明还没超过70%应该算A有的说已经到线了算B更稳妥。我的建议是把阈值当成闭区间处理也就是 0.70算A、 0.90算B、其余算C。原因很简单闭区间逻辑直观不会因为浮点精度问题把恰好在线上的商品算到下一档。SQL里的浮点计算有精度误差比如实际是0.7000001你写 0.70它就落进了B类写 0.70它就老老实实待在A类。为了让阈值边界可控我更推荐把阈值做成变量或参数比如DECLARE a_threshold DECIMAL(10,2) 0.70后面需要调整时只改变量不碰CASE逻辑。4.2 多个SKU销售额完全一样怎么办这是个常见的坑。假设第49名到第52名的销售额都是8888元累计到第48名时刚好是69.5%那第49名能不能进A类如果只按销售额排序这四个SKU顺序随机A类可能只选进其中一个也可能四个全选进结果完全取决于数据库的物理顺序。解决方法是把排序键从销售额扩展为销售额 业务唯一键。我在第3章的SQL里写的ORDER BY sales_amount DESC, sku_id就是这个用途销售额一样时按SKU编号决定先后顺序保证每次跑结果都一致。这样虽然A类末尾可能有销售额相同但编号靠后的SKU被挡在门外至少结果可复现、可解释不会第二天重跑一遍就变了。4.3 零销量和负销量商品怎么处理零销量商品分两种一种是刚上架的新品还没开始产生销售一种是长期滞销的老库存。它们如果都拉进来参与累计占比计算会拉低每个SKU的占比数值还会让A类几乎集中在几个老爆款上对上新计划没有参考价值。我的建议是把统计周期内销售额为0的SKU直接过滤掉只对有动销的SKU做ABC滞销品单独用另一个滞销报表管理。负销量商品更麻烦。比如大额退货冲减后某个SKU的汇总销售额变成负数。这种SKU排进排序后会把累计金额往负方向拉影响后续所有商品的累计占比。处理方式就是前面2.4节说的先过滤明细用状态字段排除退款单据从源头避免负值的产生。如果源头数据实在改不了在sku_sales这层也要加个HAVING SUM(amount) 0。4.4 A类商品数量太多怎么办有时候你会遇到一种尴尬情况按累计占比70%一卡A类居然有40%的SKU数量。这说明你的商品结构极度分散单品贡献低所谓爆款并不爆。这时候不要硬套70/90可以把阈值调整到50/85或者引入最小A类数量约束。比较实用的办法是在SQL里加一个控制参数按累计占比不超过70% 且 排名在前N个双条件判定A类。这样既尊重帕累托逻辑又避免A类无限膨胀。实际项目中A类SKU数量最好控制在全量的15%~25%之间否则重点管理就名存实亡了。5. 真实项目里的坑与替代方案5.1 数据库版本太老不支持窗口函数怎么办真实环境里总有那么一两个旧系统跑着SQL Server 2008或者MySQL 5.7。窗口函数用不了也不是没有办法。第一招是用相关子查询算累计金额SELECT a.sku_id, a.sales_amount, ( SELECT SUM(b.sales_amount) FROM sku_sales b WHERE b.sales_amount a.sales_amount OR (b.sales_amount a.sales_amount AND b.sku_id a.sku_id) ) AS running_amount, (SELECT SUM(sales_amount) FROM sku_sales) AS total_amount FROM sku_sales a ORDER BY a.sales_amount DESC, a.sku_id;这段代码的逻辑是对每个SKU找出所有销售额比它大、或者销售额相等但SKU编号排在它之前的SKU把它们的金额加总。结果和窗口函数一致但性能差很多。几万个SKU还能跑十几万以上就要慎重了。第二招是用数据库变量比如在MySQL里这样写SET running : 0; SELECT sku_id, sales_amount, running : running sales_amount AS running_amount FROM sku_sales ORDER BY sales_amount DESC, sku_id;这招性能最好但有个隐患变量的赋值顺序依赖数据库对SELECT列的计算顺序在复杂SQL里容易出bug而且多线程并发下不建议在生产环境直接用。宁可先用相关子查询把结果算出来再考虑优化。5.2 多仓库、多店铺怎么各自独立分类如果公司有多个仓库或多个线上店铺直接对全量数据做ABC结果会被体量大的仓库或店铺带偏小仓库里的利润款根本进不了A类。正确做法是加一个PARTITION BY让窗口函数在每个仓库内部独立计算。WITH wh_sales AS ( SELECT warehouse_id, sku_id, SUM(amount) AS sales_amount FROM sales_detail WHERE ... GROUP BY warehouse_id, sku_id ) SELECT warehouse_id, sku_id, sales_amount, SUM(sales_amount) OVER ( PARTITION BY warehouse_id ORDER BY sales_amount DESC, sku_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) * 1.0 / SUM(sales_amount) OVER (PARTITION BY warehouse_id) AS cum_ratio FROM wh_sales;注意这里所有窗口函数都必须带上同一个PARTITION BY包括求总金额的那个窗口函数。只给累计函数加、不给总数函数加算出来的累计占比就是仓库累计值除以全公司总值结果完全错乱。另外小仓库SKU只有几十个时A类可能占据一大半这种仓库需要单独定阈值不能和主仓库用同一套70/90标准。5.3 明细表数据量巨大怎么保证性能千万级甚至亿级明细表直接跑窗口函数虽然能跑但没必要。原则是先瘦身再计算先用WHERE把时间范围和状态过滤掉再用GROUP BY聚合到SKU粒度让进入窗口函数的数据量从亿级降到万级。如果数据量大到聚合都要跑很久再看两件事一是 WHERE 条件里的日期字段有没有走索引没有索引就先建二是把中间结果落到临时表或者用物化视图避免每条分析SQL都重新扫全表。我曾经处理过一张日增500万行的订单明细表就是把每月跑一次的ABC分析改成月结后把销售汇总写入分类结果表日常只查结果表查询耗时从两分钟降到了两秒。5.4 业务数据里的雷退货、赠品、测试单这一节说的坑几乎每个接手供应链数据的人都会遇到。退货问题前面讲过了赠品则更隐蔽订单里赠品的单价是0但数量是正的它会把SKU的销量拉高、金额影响不大。如果按数量做ABC赠品会把真实销售数量挤下去如果按金额做ABC零金额赠品倒不影响排序但会在后续计算平均单价时污染数据。测试单是另一个常见的雷。业务方拿一个内部测试账号反复下单金额和数量都真实入表但没有任何意义。过滤方法是在明细表里加一个is_test字段或者在账号维度维护一张测试账号表SQL里用LEFT JOIN把这些账号的订单剔除。没有条件的系统至少按订单备注里的测试关键字做一轮排除能挡掉七八成。6. 从基础ABC再往前走一步6.1 金额维度和数量维度的二维分类单个ABC维度能解决什么货值钱但解决不了什么货走量大。有些商品金额贡献高是因为单价高一年卖不了几件占用库存资金却很大有些商品金额贡献高是因为走量大单价便宜但天天出货。两者管理重点完全不同前者要控库存防积压后者要保供应防断货。用SQL做二维分类的思路是分别对金额和数量排两个名次再把两个维度组合WITH sku_metrics AS ( SELECT sku_id, SUM(amount) AS amt, SUM(qty) AS qty, NTILE(5) OVER (ORDER BY SUM(amount) DESC) AS amt_tile, NTILE(5) OVER (ORDER BY SUM(qty) DESC) AS qty_tile FROM sales_detail WHERE ... GROUP BY sku_id ) SELECT sku_id, amt, qty, CASE WHEN amt_tile 2 AND qty_tile 2 THEN 重点款 WHEN amt_tile 2 AND qty_tile 4 THEN 高值慢流 WHEN amt_tile 4 AND qty_tile 2 THEN 低值快流 ELSE 普通款 END AS strategy_type FROM sku_metrics;NTILE(5)把SKU按金额和数量各分成5等份前两档算高后两档算低。这样组合出来的重点款就是既值钱又走量的核心商品资源和精力应该优先压在这类SKU上。6.2 用窗口函数做周期滚动对比ABC分类不是一次性的工作最好每个月滚一次。如果你想知道哪些SKU从上个月的A类掉到了这个月的C类可以在两张分类结果表之间做关联对比或者用LAG函数看同一条SKU在连续两个月里的分类变化。SELECT sku_id, cur_month, cur_category, LAG(cur_category) OVER (PARTITION BY sku_id ORDER BY cur_month) AS prev_category FROM abc_result WHERE cur_month 2025-01-01;这种前后对比的价值在于提前发现趋势变化某个SKU连续两个月从A滑向C说明需求在萎缩采购计划该踩刹车了反过来从C升到A则可能是新爆款起量要赶紧补货。SQL能把这些变化自动算出来省得运营每个月自己肉眼比对Excel。6.3 分类结果如何落到管理动作ABC计算得再漂亮最后落不到管理动作上也是白做。以我的经验分类结果的落地至少要覆盖三件事补货频率、盘点周期、库存深度。A类商品可以设置更高的安全库存、每天循环盘点、补货周期缩短到周维度B类商品保持常规周补货、月度盘点C类商品让系统自动按最低库存触发补货人工尽量不碰。我见过最成功的一个落地案例是把ABC分类结果直接写进ERP的物料主数据表然后在采购模块里按分类读不同的审批流程。A类采购单需要计划员和经理双审C类自动过单。这样分类结果就从一个分析报表变成了日常业务流程的一部分价值才真正发挥出来。我个人的体会是ABC分类的SQL实现并不难难的是口径想清楚、边界处理好、结果用起来。每次跑ABC之前先问自己三个问题——用什么指标、什么周期、什么粒度跑完之后再问三个问题——A类会不会太多、边界有没有争议、业务方认不认这个结果。这套问答做顺了你手里的SQL才真正在为库存管理服务。最后再分享一个小技巧把ABC计算封装成一个存储过程或定时任务每月自动跑一遍并把结果写进一张sku_abc_result表业务部门随时可以自助查询比临时跑SQL要省心太多。
返回列表