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

851 lines
26 KiB
Markdown
Raw 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.

# 计划预测 - 计算列逻辑详解
> 最后更新:2026-06-23(第二版,精确到代码级细节)
>
> 本文档按代码执行路径逐步记录计划预测页面所有计算列的逻辑,不省略任何细节。
---
## 一、数据源说明
### 1.1 销量数据 → `daily_outbound` 表
取**最新日期**:
```python
latest = query("SELECT MAX(日期) AS d FROM daily_outbound")[0]['d']
# → latest_str = 格式化后的日期字符串
```
以最新日期为基准日,逐天向前构造日期列表:
```python
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 查询原始数据:
```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` 表
取**最新库存日期**:
```python
latest = query("SELECT MAX(data_date) AS d FROM inventory_detail")[0]['d']
```
**库存**(排除 15 号仓库):
```sql
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 号仓库):
```sql
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`
**生产在途 + 采购在途**(所有仓库合计):
```sql
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` 表
```sql
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` 表
```sql
SELECT config_key, config_value FROM global_config
```
→ `gc = {'safety_stock_days': '7', 'suggested_return_days': '15', ...}`
默认值(数据库无数据时):
```python
gc = {
'safety_stock_days': '7',
'suggested_return_days': '15',
}
```
---
## 二、销量统计列
### 2.1 昨日销量
```python
# 取 latest_str(最新日期)当天该产品的出货量
yesterday_qty = dmap.get(latest_str, 0)
```
**类型**:整数(`int`,但最终存储时未额外转换,直接存 Python int)
### 2.2 3天平均销量
```python
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天平均销量
```python
qty_7 = [dmap.get(d, 0) for d in dates_7] # 列表,长度7
avg_7 = int(round(sum(qty_7) / 7))
```
### 2.4 14天平均销量
```python
qty_14 = [dmap.get(d, 0) for d in dates_14] # 列表,长度14
avg_14 = int(round(sum(qty_14) / 14))
```
### 2.5 30天平均销量
```python
qty_30 = [dmap.get(d, 0) for d in dates_30] # 列表,长度30
avg_30 = int(round(sum(qty_30) / 30))
```
### 2.6 60天平均销量
```python
qty_60 = [dmap.get(d, 0) for d in dates_60] # 列表,长度60
avg_60 = int(round(sum(qty_60) / 60))
```
### 2.7 3天最高销量
```python
max_3 = max(qty_3)
```
### 2.8 7天最高销量
```python
max_7 = max(qty_7)
```
### 2.9 14天最高销量
```python
max_14 = max(qty_14)
```
### 2.10 30天最高销量
```python
max_30 = max(qty_30)
```
### 2.11 14天最低销量
```python
min_14 = min(qty_14)
```
### 2.12 14天标准差
```python
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 最低销量偏差率
```python
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 最高销量偏差率
```python
最高销量偏差率 = round((max_14 - avg_3) / base, 4)
```
**公式**:`(14天最高销量 - 3天平均销量) / max(3天平均销量, 1)`,保留4位小数
### 2.15 极值判断
```python
极小值 = 0.7
极大值 = 1
if 最低销量偏差率 <= 极小值 and 最高销量偏差率 >= 极大值:
极值判断 = '极大/极小'
elif 最低销量偏差率 < 极小值:
极值判断 = '极小'
elif 最高销量偏差率 > 极大值:
极值判断 = '极大'
else:
极值判断 = '正常'
```
**判定细节**:
- `<= 0.7`(含等于)算极小;`< 0.7`(不含等于)才触发"极小"
- `>= 1.0`(含等于)算极大;`> 1.0`(不含等于)才触发"极大"
- 同时满足两个条件 → `极大/极小`
- 优先级:`极大/极小` > `极小` > `极大` > `正常`
### 2.16 14天波动率
```python
avg_base = avg_14 if avg_14 != 0 else 1
波动率 = round(std_14 / avg_base, 4)
```
**公式**:`14天标准差 / max(14天平均销量, 1)`,保留4位小数
### 2.17 波动风险
```python
if 波动率 <= 0.5:
波动风险 = 'X'
elif 波动率 <= 1:
波动风险 = 'Y'
else:
波动风险 = 'Z'
```
**分级**:
| 条件 | 结果 |
|------|------|
| 波动率 ≤ 0.5 | `X` |
| 0.5 < 波动率 ≤ 1.0 | `Y` |
| 波动率 > 1.0 | `Z` |
---
## 三、销售趋势(10 种)
### 函数签名
```python
_determine_trend(avg_3, avg_7, avg_14)
```
### 调用方式
```python
# 有销量数据时
row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量'])
# 无销量数据时
row['销售趋势'] = _determine_trend(0, 0, 0)
```
### 完整逻辑(按代码执行顺序判定)
```python
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 视为"平稳"
- 规则有先后顺序:前三条(近期无销售/开始上升/开始下跌)优先匹配,然后才是平稳,最后是各趋势
---
## 四、产品分级
### 函数签名
```python
get_product_grade(monthly_sales, product_name='', product_type='')
```
### 调用方式
```python
row['产品分级'] = get_product_grade(
row.get('30天平均销量', 0) * 30, # 月销量 = 30天平均销量 × 30
row.get('product_name', ''),
row.get('产品种类', row.get('product_type', ''))
)
```
### 完整逻辑
```python
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', ''))`,优先用"产品种类",兜底用数据库字段
---
## 五、是否新品
### 函数签名
```python
is_new_product(created_at_str)
```
### 调用方式
```python
row['是否新品'] = is_new_product(row.get('创建时间', ''))
```
### 完整逻辑
```python
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个月 → `新品`,否则 → `返单品`
- 无创建时间或解析失败都返回 `返单品`
---
## 六、产品种类
### 函数签名
```python
get_product_type_cached(name, brand, db_product_type=None)
```
### 调用方式
```python
row['产品种类'] = get_product_type_cached(
row.get('product_name', ''),
row.get('brand', ''),
row.get('product_type')
)
```
### 完整逻辑
```python
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 '其他'
```
**判定优先级**:赠品(数据库) > 包材(关键词) > 小样(关键词) > 成品(有品牌) > 赠品(关键词) > 成品(数据库兜底) > 其他
---
## 七、日销量预测
### 函数签名
```python
compute_daily_forecast(row, sales_predict_map)
```
### 调用方式
```python
row['日销量预测'] = compute_daily_forecast(row, sp_map)
# sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
```
### 完整逻辑
```python
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)` 中计算
### 前置处理
```python
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` 的取值逻辑**:
```python
fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0
```
- 如果 `日销量预测` 的值是字符串 `'近期无销售'`(虽然在预测函数中不会出现,但代码做了兼容),则视为 0
- 如果值是 `0` 或 `'0'`,也视为 0
### 8.1 库存可支撑天数
```python
if fcast == 0:
row['库存可支撑天数'] = 999
else:
row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999
```
**公式**:`库存 / 日销量预测`,保留 1 位小数
**无销量时**:999
### 8.2 库存工厂可支撑天数(前端显示为"现货采购在途")
```python
if fcast == 0:
row['库存工厂可支撑天数'] = 999
else:
row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999
```
**公式**:`采购在途 / 日销量预测`,保留 1 位小数
### 8.3 库存工厂待生产可支撑天数(前端显示为"定制品在途")
```python
if fcast == 0:
row['库存工厂待生产可支撑天数'] = 999
else:
row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999
```
**公式**:`生产在途 / 日销量预测`,保留 1 位小数
### 8.4 加工厂库存支撑天数
```python
if fcast == 0:
row['加工厂库存支撑天数'] = 999
else:
row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999
```
**公式**:`加工厂库存 / 日销量预测`,保留 1 位小数
### 8.5 总支撑天数
```python
if fcast == 0:
row['总支撑天数'] = 999
else:
total_inv = prod_transit + inv15_val
row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999
```
**公式**:`(生产在途 + 加工厂库存) / 日销量预测`,保留 1 位小数
---
## 九、库存健康指数
```python
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
---
## 十、风险把控
```python
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 建议下单数量
```python
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 实际下单数量
```python
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/失焦 → 恢复编辑前原值
---
## 十二、建议回货数量
```python
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` 表)读取,不经计算:
```sql
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 取出后,按以下顺序注入每一行:
```python
_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
├─ 库存工厂待生产可支撑 │
├─ 加工厂库存支撑 ▼
└─ 总支撑天数 [下单数量]
├─ 建议下单数量
├─ 实际下单数量(千位取整)
└─ 建议回货数量
```