dy-dashboard/vault/知识库/工作指南/计划预测计算列逻辑详解.md
2026-10-28 11:14:17 +08:00

26 KiB
Raw Permalink Blame History

计划预测 - 计算列逻辑详解

最后更新:2026-06-23(第二版,精确到代码级细节)

本文档按代码执行路径逐步记录计划预测页面所有计算列的逻辑,不省略任何细节。


一、数据源说明

1.1 销量数据 → daily_outbound 表

取最新日期:

latest = query("SELECT MAX(日期) AS d FROM daily_outbound")[0]['d']
# → latest_str = 格式化后的日期字符串

以最新日期为基准日,逐天向前构造日期列表:

dates_3 = [latest-0d, latest-1d, latest-2d]                                               # 3天
dates_7 = [latest-0d, latest-1d, latest-2d, latest-3d, latest-4d, latest-5d, latest-6d]   # 7天
dates_14 = [latest-0d .. latest-13d]   # 14天
dates_30 = [latest-0d .. latest-29d]   # 30天
dates_60 = [latest-0d .. latest-59d]   # 60天

SQL 查询原始数据:

SELECT 货品编号, 日期, SUM(货品数量) AS 日销量
FROM daily_outbound
WHERE 日期 >= dates_60[-1]
GROUP BY 货品编号, 日期

数据整理为:{product_code: {date_string: qty}}

  • qty = int(r['日销量']) if r['日销量'] else 0

1.2 库存数据 → inventory_detail 表

取最新库存日期:

latest = query("SELECT MAX(data_date) AS d FROM inventory_detail")[0]['d']

库存(排除 15 号仓库):

SELECT product_code, SUM(available_qty) AS total_qty
FROM inventory_detail
WHERE data_date = '{latest}' AND warehouse_code != '15'
GROUP BY product_code

→ inv_map[pc] = int(r['total_qty']) if r['total_qty'] else 0

加工厂库存(仅 15 号仓库):

SELECT product_code, SUM(available_qty) AS total_qty
FROM inventory_detail
WHERE data_date = '{latest}' AND warehouse_code = '15'
GROUP BY product_code

→ inv15_map[pc] = int(r['total_qty']) if r['total_qty'] else 0

生产在途 + 采购在途(所有仓库合计):

SELECT product_code,
       SUM(production_in_transit) AS prod_in_transit,
       SUM(purchase_in_transit) AS purch_in_transit
FROM inventory_detail
WHERE data_date = '{latest}'
GROUP BY product_code

→ transit_map[pc] = {'生产在途': int(prod_in_transit) or 0, '采购在途': int(purch_in_transit) or 0}

1.3 预测配置 → sales_predict_config 表

SELECT * FROM sales_predict_config ORDER BY id

→ sp_map = {r['trend_type']: r for r in ...}

每条记录字段:trend_type, avg_3d, avg_7d, avg_14d, adjust_factor

1.4 全局配置 → global_config 表

SELECT config_key, config_value FROM global_config

→ gc = {'safety_stock_days': '7', 'suggested_return_days': '15', ...}

默认值(数据库无数据时):

gc = {
    'safety_stock_days': '7',
    'suggested_return_days': '15',
}

二、销量统计列

2.1 昨日销量

# 取 latest_str(最新日期)当天该产品的出货量
yesterday_qty = dmap.get(latest_str, 0)

类型:整数(int,但最终存储时未额外转换,直接存 Python int)

2.2 3天平均销量

qty_3 = [dmap.get(d, 0) for d in dates_3]   # 列表,长度3
avg_3 = int(round(sum(qty_3) / 3))

类型:int,先 round 再 int

2.3 7天平均销量

qty_7 = [dmap.get(d, 0) for d in dates_7]   # 列表,长度7
avg_7 = int(round(sum(qty_7) / 7))

2.4 14天平均销量

qty_14 = [dmap.get(d, 0) for d in dates_14]  # 列表,长度14
avg_14 = int(round(sum(qty_14) / 14))

2.5 30天平均销量

qty_30 = [dmap.get(d, 0) for d in dates_30]  # 列表,长度30
avg_30 = int(round(sum(qty_30) / 30))

2.6 60天平均销量

qty_60 = [dmap.get(d, 0) for d in dates_60]  # 列表,长度60
avg_60 = int(round(sum(qty_60) / 60))

2.7 3天最高销量

max_3 = max(qty_3)

2.8 7天最高销量

max_7 = max(qty_7)

2.9 14天最高销量

max_14 = max(qty_14)

2.10 30天最高销量

max_30 = max(qty_30)

2.11 14天最低销量

min_14 = min(qty_14)

2.12 14天标准差

def _calc_stddev(qty_list):
    n = len(qty_list)
    if n < 2:
        return 0
    avg = sum(qty_list) / n
    variance = sum((x - avg) ** 2 for x in qty_list) / n
    return math.sqrt(variance)

std_14 = int(round(_calc_stddev(qty_14)))

公式:

avg = sum(qty_14) / 14
variance = sum((x - avg)² for x in qty_14) / 14   # 总体标准差(分母n,而非n-1)
std = sqrt(variance)

类型:输出为 int(round(...))

2.13 最低销量偏差率

base = avg_3 if avg_3 != 0 else 1
最低销量偏差率 = round((min_14 - avg_3) / base, 4)

公式:(14天最低销量 - 3天平均销量) / max(3天平均销量, 1),保留4位小数

  • 注意:base 用的是 avg_3(int),如果 avg_3 == 0 则 base = 1
  • 不是用 max(avg_3, 1) 而是条件赋值
  • 由于此时 avg_3 是 int,min_14 也是 int,除法结果可能是负数

2.14 最高销量偏差率

最高销量偏差率 = round((max_14 - avg_3) / base, 4)

公式:(14天最高销量 - 3天平均销量) / max(3天平均销量, 1),保留4位小数

2.15 极值判断

极小值 = 0.7
极大值 = 1

if 最低销量偏差率 <= 极小值 and 最高销量偏差率 >= 极大值:
    极值判断 = '极大/极小'
elif 最低销量偏差率 < 极小值:
    极值判断 = '极小'
elif 最高销量偏差率 > 极大值:
    极值判断 = '极大'
else:
    极值判断 = '正常'

判定细节:

  • <= 0.7(含等于)算极小;< 0.7(不含等于)才触发"极小"
  • >= 1.0(含等于)算极大;> 1.0(不含等于)才触发"极大"
  • 同时满足两个条件 → 极大/极小
  • 优先级:极大/极小 > 极小 > 极大 > 正常

2.16 14天波动率

avg_base = avg_14 if avg_14 != 0 else 1
波动率 = round(std_14 / avg_base, 4)

公式:14天标准差 / max(14天平均销量, 1),保留4位小数

2.17 波动风险

if 波动率 <= 0.5:
    波动风险 = 'X'
elif 波动率 <= 1:
    波动风险 = 'Y'
else:
    波动风险 = 'Z'

分级:

条件 结果
波动率 ≤ 0.5 X
0.5 < 波动率 ≤ 1.0 Y
波动率 > 1.0 Z

三、销售趋势(10 种)

函数签名

_determine_trend(avg_3, avg_7, avg_14)

调用方式

# 有销量数据时
row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量'])

# 无销量数据时
row['销售趋势'] = _determine_trend(0, 0, 0)

完整逻辑(按代码执行顺序判定)

def _determine_trend(avg_3, avg_7, avg_14):
    a3, a7, a14 = float(avg_3), float(avg_7), float(avg_14)

    # 规则1:全部为0
    if a3 == 0 and a7 == 0 and a14 == 0:
        return '近期无销售'

    # 防除零
    base_7 = a7 if a7 != 0 else 1
    base_14 = a14 if a14 != 0 else 1

    # 计算比值和差值
    ratio_3_7 = a3 / base_7      # 3天/7天 比值
    ratio_7_14 = a7 / base_14    # 7天/14天 比值
    diff_3_7 = abs(a3 - a7)      # 3天与7天的绝对差值
    diff_7_14 = abs(a7 - a14)    # 7天与14天的绝对差值

    # 规则2:开始上升
    if a3 > a7 and 0.95 <= ratio_7_14 <= 1.05:
        return '开始上升'

    # 规则3:开始下跌
    if a3 < a7 and 0.95 <= ratio_7_14 <= 1.05:
        return '开始下跌'

    # 规则4:平稳趋势(差值均 ≤ 20)
    if diff_3_7 <= 20 and diff_7_14 <= 20:
        return '平稳趋势'

    # 规则5:上升趋稳(3天≈7天 且 7天>14天)
    if 0.95 <= ratio_3_7 <= 1.05 and a7 > a14:
        return '上升趋稳'

    # 规则6:下跌趋稳(3天≈7天 且 7天<14天)
    if 0.95 <= ratio_3_7 <= 1.05 and a7 < a14:
        return '下跌趋稳'

    # 规则7:持续上升(3天>7天>14天)
    if a3 > a7 and a7 > a14:
        return '持续上升'

    # 规则8:急跌(3天<7天 且 7天>14天)
    if a3 < a7 and a7 > a14:
        return '急跌'

    # 规则9:持续下跌(3天<7天<14天)
    if a3 < a7 and a7 < a14:
        return '持续下跌'

    # 规则10:急增(3天>7天 且 7天<14天)
    if a3 > a7 and a7 < a14:
        return '急增'

    # 兜底
    return '平稳趋势'

注意:

  • 所有输入先用 float() 转换
  • 0.95 <= ratio <= 1.05 表示波动在 ±5% 以内视为"≈"
  • diff <= 20 表示绝对差值 ≤ 20 视为"平稳"
  • 规则有先后顺序:前三条(近期无销售/开始上升/开始下跌)优先匹配,然后才是平稳,最后是各趋势

四、产品分级

函数签名

get_product_grade(monthly_sales, product_name='', product_type='')

调用方式

row['产品分级'] = get_product_grade(
    row.get('30天平均销量', 0) * 30,   # 月销量 = 30天平均销量 × 30
    row.get('product_name', ''),
    row.get('产品种类', row.get('product_type', ''))
)

完整逻辑

def get_product_grade(monthly_sales, product_name='', product_type=''):
    # 前置过滤1:赠品或含"晒图卡" → 不参与评级
    if product_type == '赠品' or '晒图卡' in (product_name or ''):
        return '不参与评级'

    # 前置过滤2:小样 → 最高C级
    if product_type == '小样':
        return 'C级'

    # 类型转换
    try:
        ms = float(monthly_sales)
    except:
        return 'C级'

    # 销量分级
    if ms >= 10000:
        return 'S级'
    elif ms >= 5000:
        return 'A级'
    elif ms >= 3000:
        return 'B级'
    else:
        return 'C级'

注意:

  • monthly_sales 已在外层乘以 30:row.get('30天平均销量', 0) * 30,所以 ms 是月销量估算值
  • product_type 是数据库字段(不是"产品种类"),但外层调用传了 row.get('产品种类', row.get('product_type', '')),优先用"产品种类",兜底用数据库字段

五、是否新品

函数签名

is_new_product(created_at_str)

调用方式

row['是否新品'] = is_new_product(row.get('创建时间', ''))

完整逻辑

def is_new_product(created_at_str):
    if not created_at_str:
        return '返单品'

    try:
        if isinstance(created_at_str, str):
            created = datetime.strptime(created_at_str[:10], '%Y-%m-%d')
        else:
            created = created_at_str

        if hasattr(created, 'strftime'):
            five_months_ago = datetime.now() - relativedelta(months=5)
            return '新品' if created >= five_months_ago else '返单品'
        return '返单品'
    except:
        return '返单品'

逻辑:创建时间 ≥ 当前时间往前推5个月 → 新品,否则 → 返单品

  • 无创建时间或解析失败都返回 返单品

六、产品种类

函数签名

get_product_type_cached(name, brand, db_product_type=None)

调用方式

row['产品种类'] = get_product_type_cached(
    row.get('product_name', ''),
    row.get('brand', ''),
    row.get('product_type')
)

完整逻辑

PACKAGE_KEYWORDS = {
    '瓶子', '泵头', '软管', '标签', '标贴', '盖子', '彩盒', '收缩膜',
    '阀门', '瓶标', '喷枪', '海绵头', '内料', '膜袋', '唇膏管',
    '面膜布', '滴管', '说明书', '卡托', '中盒', '展示盒', '底托', '次抛管',
    '旋盖', '瓶体', '礼袋', '管子', '条码', '瓶盖', '丝带', '按摩棒',
    '刮板', '刮痧板', '拉菲草', '膜纸', '硅胶刷', '刮勺', '加工', '勺子',
    '瓶身', '喷雾头', '瓶贴', '上药器', '内袋', '套膜', '胶头', '塑封膜',
    '翻盖', '贴纸', '珍珠棉', '洗眼杯', '棉棒',
}

def _get_product_type(name, brand, db_product_type=None):
    if not name:
        return '其他'

    # 1. 数据库标记为赠品
    if db_product_type and db_product_type == '赠品':
        return '赠品'

    # 2. 名称含包材关键词
    for kw in PACKAGE_KEYWORDS:
        if kw in name:
            return '包材'

    # 3. 名称含"小样"
    if '小样' in name:
        return '小样'

    # 4. 有品牌且不为空/无
    if brand and brand.strip() and brand.strip() != '无':
        return '成品'

    # 5. 名称含"赠品"
    if '赠品' in name:
        return '赠品'

    # 6. 数据库标记为成品
    if db_product_type and db_product_type in ('成品',):
        return db_product_type

    # 7. 兜底
    return '其他'

判定优先级:赠品(数据库) > 包材(关键词) > 小样(关键词) > 成品(有品牌) > 赠品(关键词) > 成品(数据库兜底) > 其他


七、日销量预测

函数签名

compute_daily_forecast(row, sales_predict_map)

调用方式

row['日销量预测'] = compute_daily_forecast(row, sp_map)
# sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}

完整逻辑

def compute_daily_forecast(row, sales_predict_map):
    avg3 = float(row.get('3天平均销量', 0) or 0)
    avg7 = float(row.get('7天平均销量', 0) or 0)
    avg14 = float(row.get('14天平均销量', 0) or 0)
    max30 = float(row.get('30天最高销量', 0) or 0)
    trend = row.get('销售趋势', '平稳趋势')

    # 如果全部为0 → 无法预测
    if avg3 == 0 and avg7 == 0 and avg14 == 0 and max30 == 0:
        return 0

    # 根据趋势类型获取预测系数
    cfg = sales_predict_map.get(trend, {})
    w3 = float(cfg.get('avg_3d', 0.5))      # 3天权重,默认0.5
    w7 = float(cfg.get('avg_7d', 0.3))      # 7天权重,默认0.3
    w14 = float(cfg.get('avg_14d', 0.2))    # 14天权重,默认0.2
    adj = float(cfg.get('adjust_factor', 1.0))  # 调节系数,默认1.0

    # 加权计算
    forecast = (avg3 * w3 + avg7 * w7 + avg14 * w14) * adj
    return round(forecast)

注意:

  • 所有取值用了 row.get(..., 0) or 0 —— 如果值为 None 或 0 或 '',都统一变 0
  • 查找配置时用 trend(即"销售趋势"的10种值之一)匹配 sales_predict_config.trend_type
  • 找不到趋势配置时使用默认值(w3=0.5, w7=0.3, w14=0.2, adj=1.0)
  • 最后结果 round(forecast) 取整

八、支撑天数列(5 列)

全部在 _calc_support_days(row, gc) 中计算

前置处理

safety_days = int(gc.get('safety_stock_days', '7'))
return_days = int(gc.get('suggested_return_days', '15'))
forecast_val = row.get('日销量预测', 0)
fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0

inv_val = float(row.get('库存', 0) or 0)
inv15_val = float(row.get('加工厂库存', 0) or 0)
prod_transit = float(row.get('生产在途', 0) or 0)
purch_transit = float(row.get('采购在途', 0) or 0)

注意 fcast 的取值逻辑:

fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0
  • 如果 日销量预测 的值是字符串 '近期无销售'(虽然在预测函数中不会出现,但代码做了兼容),则视为 0
  • 如果值是 0 或 '0',也视为 0

8.1 库存可支撑天数

if fcast == 0:
    row['库存可支撑天数'] = 999
else:
    row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999

公式:库存 / 日销量预测,保留 1 位小数 无销量时:999

8.2 库存工厂可支撑天数(前端显示为"现货采购在途")

if fcast == 0:
    row['库存工厂可支撑天数'] = 999
else:
    row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999

公式:采购在途 / 日销量预测,保留 1 位小数

8.3 库存工厂待生产可支撑天数(前端显示为"定制品在途")

if fcast == 0:
    row['库存工厂待生产可支撑天数'] = 999
else:
    row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999

公式:生产在途 / 日销量预测,保留 1 位小数

8.4 加工厂库存支撑天数

if fcast == 0:
    row['加工厂库存支撑天数'] = 999
else:
    row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999

公式:加工厂库存 / 日销量预测,保留 1 位小数

8.5 总支撑天数

if fcast == 0:
    row['总支撑天数'] = 999
else:
    total_inv = prod_transit + inv15_val
    row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999

公式:(生产在途 + 加工厂库存) / 日销量预测,保留 1 位小数


九、库存健康指数

sup_days = row.get('库存可支撑天数', 999)
s1 = sup_days if isinstance(sup_days, (int, float)) and sup_days != 999 else None
lead_h = float(row.get('生产货期', 0) or 0)
logis_h = float(row.get('物流时间', 0) or 0)

if s1 is not None and (lead_h + logis_h) > 0:
    health = s1 / (lead_h + logis_h)
    row['库存健康指数'] = round(min(health, 999), 1)
else:
    row['库存健康指数'] = 999

逻辑细节:

  • s1 只有当 库存可支撑天数 是数字类型且不等于 999 时才有效
  • 即:有销量(支撑天数不为 999)时才计算健康指数
  • 公式:库存可支撑天数 / (生产货期 + 物流时间)
  • 上限 999:min(health, 999)
  • 保留 1 位小数:round(..., 1)
  • 分母为 0 时(货期+物流时间为 0)→ 返回 999

十、风险把控

created = row.get('创建时间', '')
sup_days_val = row.get('库存可支撑天数', 999)

try:
    sup_days_val = float(sup_days_val)
except:
    sup_days_val = 999

days_since_created = 999
try:
    if created:
        if isinstance(created, str):
            created_dt = datetime.strptime(created[:10], '%Y-%m-%d')
        else:
            created_dt = created
        days_since_created = (datetime.now() - created_dt).days
except:
    pass

if days_since_created < 90:
    row['风险把控'] = '新品'
elif days_since_created > 180 and sup_days_val > 240:
    row['风险把控'] = '需启动清库存'
elif days_since_created > 90 and sup_days_val > 90:
    row['风险把控'] = '呆滞品'
else:
    row['风险把控'] = '正常'

判定表(按代码执行顺序):

条件 结果 说明
创建天数 < 90 新品 优先匹配
创建天数 > 180 且 库存可支撑天数 > 240 需启动清库存 超半年库存过多
创建天数 > 90 且 库存可支撑天数 > 90 呆滞品 超过3个月且库存能撑3个月
其他 正常

注意:

  • days_since_created 默认 999,如果创建时间解析失败将保持 999
  • 第一个条件 < 90 优先于后面的条件(新品的判断级别最高)
  • days_since_created > 180 是严格大于 180(不含等于)
  • sup_days_val > 240 和 > 90 都是严格大于(不含等于)

十一、建议下单数量 & 实际下单数量

11.1 建议下单数量

if fcast == 0 and inv_val > 0:
    row['建议下单数量'] = 0
else:
    row['建议下单数量'] = int(fcast * (safety_days + lead_h + logis_h))

公式:int(日销量预测 × (安全库存天数 + 生产货期 + 物流时间))

边界条件:

  • 如果日销量预测为 0 但库存 > 0 → 结果为 0(有库存但无销量,不下单)
  • 如果日销量预测为 0 且库存也为 0 → 进入 else 分支,结果 = int(0 * ...) = 0
  • 如果日销量预测 > 0 → 正常计算并用 int() 截断小数

11.2 实际下单数量

if row['建议下单数量'] == 0:
    row['实际下单数量'] = 0
else:
    row['实际下单数量'] = ((row['建议下单数量'] + 999) // 1000) * 1000

公式:((建议下单数量 + 999) // 1000) * 1000

效果:按 1000 向上取整

  • 1 → 1000
  • 999 → 1000
  • 1000 → 1000(1000+999=1999//1000=1 → 1×1000=1000)
  • 1001 → 2000
  • 1234 → 2000

11.3 正式下单数量

  • 非自动计算列
  • 用户手动编辑输入
  • 编辑交互:回车+确认 → 保存到后端 | 取消/Esc/失焦 → 恢复编辑前原值

十二、建议回货数量

total_transit = prod_transit + purch_transit

if fcast > 0 and total_transit > 0:
    calc_val = fcast * return_days - inv_val
    round_up = int(calc_val * 10 + 0.99999) / 10
    if round_up < 0:
        round_up = 0
    row['建议回货数量'] = round_up if round_up <= total_transit else total_transit
    if row['建议回货数量'] < 0:
        row['建议回货数量'] = 0
else:
    row['建议回货数量'] = 0

公式:

calc_val = 日销量预测 × 建议回货天数 - 库存
round_up = ceil_1(calc_val)    # 向上取整到1位小数(通过 int(x * 10 + 0.99999) / 10 实现)
最终值 = min(round_up, 总在途)
最终值 = max(最终值, 0)

注意:

  • return_days 来自全局配置 suggested_return_days(默认 15 天)
  • 向上取整方式:int(calc_val * 10 + 0.99999) / 10,等价于 math.ceil(calc_val * 10) / 10
  • 上限为 total_transit(生产在途 + 采购在途的总和)
  • 下限为 0
  • 仅当 fcast > 0 and total_transit > 0 时才计算,否则返回 0

十三、本地数据库字段

以下列直接从 products 表(或关联 suppliers 表)读取,不经计算:

SELECT
    p.product_code,
    p.product_name,
    p.brand,
    p.product_type,
    p.specification,
    p.cost_price,
    p.lead_time,           -- 对应"生产货期"
    p.logistics_time,      -- 对应"物流时间"
    p.min_order_qty,       -- 对应"最低起订量"
    p.created_at,          -- 对应"创建时间"
    COALESCE(s.supplier_name, p.supplier_name_match) AS "供应商名称",
    p.procurement_follower,  -- 对应"采购跟单"
    p.procurement_planner,   -- 对应"采购计划"
    p.operator,              -- 对应"运营专员"
    p.developer,             -- 对应"开发专员"
    p.platform,              -- 对应"归属平台"
    p.category1,             -- 对应"一级类目"
    p.category2,             -- 对应"二级类目"
    p.category3,             -- 对应"三级类目"
    p.bom                    -- 对应"物料清单"
FROM products p
LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code

十四、注入流程(代码执行路径)

数据从 SQL 取出后,按以下顺序注入每一行:

_enrich_row_with_planning_data(row, sales, inv_map, inv15_map, transit_map, sp_map, gc):
    ① row['库存']   = inv_map.get(pc, 0)
    ② row['加工厂库存'] = inv15_map.get(pc, 0)
       t = transit_map.get(pc, {})
    ③ row['生产在途'] = t.get('生产在途', 0)
    ④ row['采购在途'] = t.get('采购在途', 0)

    ⑤ 销量数据注入(如果 pc in sales):
       - 昨日销量, 3/7/14/30/60天平均销量
       - 3/7/14/30天最高销量, 14天最低销量
       - 14天标准差, 最低/最高销量偏差率
       - 极值判断, 14天波动率, 波动风险
       否则全部赋 0

    ⑥ row['销售趋势'] = _determine_trend(avg3, avg7, avg14)

    ⑦ row['产品分级'] = get_product_grade(30天平均*30, product_name, 产品种类/产品类型)

    ⑧ row['是否新品'] = is_new_product(创建时间)

    ⑨ row['日销量预测'] = compute_daily_forecast(row, sp_map)

    ⑩ _calc_support_days(row, gc):
        - 库存可支撑天数 / 库存工厂可支撑天数 / 库存工厂待生产可支撑天数
        - 加工厂库存支撑天数 / 总支撑天数
        - 库存健康指数
        - 风险把控
        - 建议下单数量 / 实际下单数量
        - 建议回货数量

附录:依赖关系图

[daily_outbound 表]                   [inventory_detail 表]
        │                                     │
        ├─ 昨日销量                            ├─ 库存(≠15号仓)
        ├─ 3/7/14/30/60天平均                  ├─ 加工厂库存(15号仓)
        ├─ 3/7/14/30天最高                      ├─ 生产在途
        ├─ 14天最低                              └─ 采购在途
        ├─ 14天标准差 ──→ 14天波动率 ──→ 波动风险     │
        ├─ 最低/最高偏差率 ──→ 极值判断            ▼
        └─ [平均值] ──→ 销售趋势            [products 表]
                               │               ├─ 生产货期/物流时间
                               ▼               ├─ 创建时间 ──→ 是否新品
                     [sales_predict_config]     │           └─→ 风险把控
                     ├─ avg_3d/w7/w14           └─ 品牌/产品类型 ──→ 产品分级
                     └─ adjust_factor                 │
                               │                      ▼
                               ▼              [产品种类判定]
                      [日销量预测]              (关键词/品牌规则)
                               │
          ┌────────────────────┼────────────────────┐
          ▼                    ▼                    ▼
    [支撑天数×5]        [库存健康指数]         [全局配置]
    ├─ 库存可支撑天数      = 支撑天数              ├─ safety_stock_days
    ├─ 库存工厂可支撑       ÷ (货期+物流)          ├─ suggested_return_days
    ├─ 库存工厂待生产可支撑                          │
    ├─ 加工厂库存支撑                                ▼
    └─ 总支撑天数                            [下单数量]
                                              ├─ 建议下单数量
                                              ├─ 实际下单数量(千位取整)
                                              └─ 建议回货数量