341 lines
16 KiB
Python
341 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""AI分析P2:情景模拟、未来风险预测和只读行动草稿。
|
||
|
||
所有结果均为模拟/草稿,不写入订单、库存或待下单业务表。
|
||
"""
|
||
from collections import defaultdict
|
||
from datetime import date, timedelta
|
||
import math
|
||
|
||
from services.ai_datasources import AI_DATASOURCES, datasource_meta
|
||
from services.ai_insights import get_operational_insights, filter_insights, position_profile
|
||
from services.simple_cache import get as cache_get, set as cache_set
|
||
|
||
FORECAST_CACHE_PREFIX = 'ai_p2_forecast_v1'
|
||
|
||
|
||
def _num(value, default=0.0):
|
||
try:
|
||
return float(value or 0)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _int(value):
|
||
return int(round(_num(value)))
|
||
|
||
|
||
def _first(key, params):
|
||
result = AI_DATASOURCES[key]['handler'](params) or {}
|
||
rows = result.get('rows') or []
|
||
return rows[0] if rows else {}
|
||
|
||
|
||
def _rows(key, params=None):
|
||
result = AI_DATASOURCES[key]['handler'](params or {'_no_limit': True}) or {}
|
||
return result.get('rows') or []
|
||
|
||
|
||
def _risk_level(support_days, threshold_days, daily_sales):
|
||
if daily_sales <= 0:
|
||
return '低'
|
||
if support_days < max(threshold_days, 1):
|
||
return '高'
|
||
if support_days < threshold_days + 15:
|
||
return '中'
|
||
return '低'
|
||
|
||
|
||
def _round_qty(value):
|
||
return max(0, int(round(_num(value))))
|
||
|
||
|
||
def simulate_product(product_code, sales_change_pct=0, supplier_delay_days=0,
|
||
planned_order_qty=0, horizon_days=30, allowed_sources=None):
|
||
"""对单个SKU进行只读情景模拟。"""
|
||
product_code = str(product_code or '').strip()
|
||
if not product_code:
|
||
raise ValueError('请输入产品编码')
|
||
sales_change_pct = max(-100.0, min(_num(sales_change_pct), 500.0))
|
||
supplier_delay_days = max(0, min(_int(supplier_delay_days), 180))
|
||
planned_order_qty = max(0, min(_int(planned_order_qty), 100000000))
|
||
horizon_days = max(1, min(_int(horizon_days) or 30, 365))
|
||
params = {'product_code': product_code, 'limit': 200}
|
||
allowed = set(allowed_sources or AI_DATASOURCES.keys())
|
||
if not {'product_profile', 'planning_metrics'}.issubset(allowed):
|
||
raise PermissionError('需要产品信息和计划预测查看权限才能进行模拟')
|
||
|
||
profile = _first('product_profile', params)
|
||
planning = _first('planning_metrics', params)
|
||
sales = _first('product_sales', params) if 'product_sales' in allowed else {}
|
||
inventory = _first('product_inventory', params) if 'product_inventory' in allowed else {}
|
||
orders = _first('orders_in_transit', params) if 'orders_in_transit' in allowed else {}
|
||
bom_rows = _rows('bom_supply_chain', params) if 'bom_supply_chain' in allowed else []
|
||
if not profile and not planning:
|
||
raise ValueError('未找到该产品,请核对产品编码')
|
||
|
||
code = str(planning.get('product_code') or profile.get('product_code') or product_code)
|
||
product_type = str(profile.get('产品类型') or '')
|
||
lead = _int(profile.get('生产货期'))
|
||
logistics = _int(profile.get('物流时间'))
|
||
safety = 0 if product_type == '现货' else 7
|
||
base_threshold = lead + logistics + safety
|
||
scenario_threshold = base_threshold + supplier_delay_days
|
||
base_daily = _num(planning.get('日销量预测'))
|
||
if base_daily <= 0:
|
||
base_daily = _num(sales.get('30天平均销量'))
|
||
scenario_daily = max(0.0, base_daily * (1 + sales_change_pct / 100.0))
|
||
|
||
quantities = {
|
||
'库存': _int(planning.get('库存', inventory.get('库存'))),
|
||
'加工厂库存': _int(planning.get('加工厂库存', inventory.get('加工厂库存'))),
|
||
'生产在途': _int(planning.get('生产在途', inventory.get('生产在途'))),
|
||
'采购在途': _int(planning.get('采购在途', inventory.get('采购在途'))),
|
||
}
|
||
total_supply = sum(quantities.values())
|
||
base_support = round(total_supply / base_daily, 1) if base_daily > 0 else 999
|
||
scenario_support = round(total_supply / scenario_daily, 1) if scenario_daily > 0 else 999
|
||
support_after_order = round((total_supply + planned_order_qty) / scenario_daily, 1) if scenario_daily > 0 else 999
|
||
|
||
if scenario_daily <= 0 or scenario_support >= scenario_threshold:
|
||
recommended_order = 0
|
||
else:
|
||
recommended_order = _round_qty(scenario_daily * scenario_threshold)
|
||
shortage_date = None
|
||
latest_order_date = None
|
||
if scenario_daily > 0 and scenario_support < 999:
|
||
shortage_day = date.today() + timedelta(days=max(0, int(math.floor(scenario_support))))
|
||
shortage_date = shortage_day.isoformat()
|
||
latest_order_date = (shortage_day - timedelta(days=scenario_threshold)).isoformat()
|
||
|
||
target_qty = planned_order_qty or recommended_order
|
||
bom_gaps = []
|
||
for material in bom_rows:
|
||
unit_qty = _num(material.get('单位用量'))
|
||
supported = _int(material.get('可支撑成品数量'))
|
||
if target_qty <= 0 or unit_qty <= 0 or supported >= target_qty:
|
||
continue
|
||
finished_gap = target_qty - supported
|
||
bom_gaps.append({
|
||
'material_code': material.get('material_code') or '',
|
||
'material_name': material.get('物料名称') or '',
|
||
'supplier': material.get('物料供应商') or '',
|
||
'unit_qty': unit_qty,
|
||
'support_finished_qty': supported,
|
||
'target_finished_qty': target_qty,
|
||
'finished_gap': finished_gap,
|
||
'material_gap': int(math.ceil(finished_gap * unit_qty)),
|
||
})
|
||
bom_gaps.sort(key=lambda x: x['finished_gap'], reverse=True)
|
||
|
||
base_level = _risk_level(base_support, base_threshold, base_daily)
|
||
scenario_level = _risk_level(scenario_support, scenario_threshold, scenario_daily)
|
||
if scenario_daily <= 0:
|
||
conclusion = '模拟后日销量为0,按当前规则无需新增下单。'
|
||
elif scenario_support >= scenario_threshold:
|
||
conclusion = f'模拟后总支撑天数{scenario_support:.1f}天,不低于补货阈值{scenario_threshold}天,暂不建议新增下单。'
|
||
else:
|
||
conclusion = f'模拟后总支撑天数{scenario_support:.1f}天,低于补货阈值{scenario_threshold}天,建议评估下单{recommended_order}件。'
|
||
if planned_order_qty:
|
||
conclusion += f' 若下单{planned_order_qty}件,下单后预计可支撑{support_after_order:.1f}天。'
|
||
if bom_gaps:
|
||
conclusion += f' 但有{len(bom_gaps)}项BOM物料不足,需先处理物料缺口。'
|
||
|
||
return {
|
||
'product_code': code,
|
||
'product_name': profile.get('货品名称') or '',
|
||
'brand': profile.get('品牌') or planning.get('品牌') or '',
|
||
'product_type': product_type,
|
||
'inputs': {
|
||
'sales_change_pct': sales_change_pct,
|
||
'supplier_delay_days': supplier_delay_days,
|
||
'planned_order_qty': planned_order_qty,
|
||
'horizon_days': horizon_days,
|
||
},
|
||
'base': {
|
||
'daily_sales': round(base_daily, 2), 'support_days': base_support,
|
||
'threshold_days': base_threshold, 'risk_level': base_level,
|
||
'suggested_order_qty': _int(planning.get('建议下单数量')),
|
||
},
|
||
'scenario': {
|
||
'daily_sales': round(scenario_daily, 2), 'support_days': scenario_support,
|
||
'threshold_days': scenario_threshold, 'risk_level': scenario_level,
|
||
'recommended_order_qty': recommended_order,
|
||
'support_after_order_days': support_after_order,
|
||
'estimated_shortage_date': shortage_date,
|
||
'latest_order_date': latest_order_date,
|
||
'horizon_demand': _round_qty(scenario_daily * horizon_days),
|
||
},
|
||
'inventory': quantities | {
|
||
'合计供应数量': total_supply,
|
||
'国内未到数量': _int(orders.get('国内未到数量')),
|
||
'海外未到数量': _int(orders.get('海外未到数量')),
|
||
'包材未到数量': _int(orders.get('包材未到数量')),
|
||
},
|
||
'bom_gaps': bom_gaps[:30],
|
||
'conclusion': conclusion,
|
||
'assumptions': [
|
||
'模拟不会修改库存、订单、计划预测或待下单数据。',
|
||
'供应商延期天数计入补货阈值,用于压力测试。',
|
||
'销量变化按当前日销量预测等比例调整。',
|
||
'BOM缺口按目标成品数量与当前可支撑成品数量比较。',
|
||
],
|
||
'data_dates': {
|
||
'inventory': (datasource_meta('product_inventory').get('data_date') or '') if 'product_inventory' in allowed else '',
|
||
'sales': (datasource_meta('product_sales').get('data_date') or '') if 'product_sales' in allowed else '',
|
||
},
|
||
}
|
||
|
||
|
||
def forecast_supply_risks(horizon_days=30, limit=100, allowed_sources=None):
|
||
"""预测未来指定天数内的缺货/补货风险。"""
|
||
horizon_days = max(1, min(_int(horizon_days) or 30, 365))
|
||
limit = max(1, min(_int(limit) or 100, 300))
|
||
allowed = set(allowed_sources or AI_DATASOURCES.keys())
|
||
if not {'planning_metrics', 'product_profile'}.issubset(allowed):
|
||
raise PermissionError('需要计划预测和产品信息查看权限才能进行风险预测')
|
||
include_bom = 'bom_supply_chain' in allowed
|
||
cache_key = f'{FORECAST_CACHE_PREFIX}:{horizon_days}:{limit}:{int(include_bom)}'
|
||
cached = cache_get(cache_key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
planning = _rows('planning_metrics')
|
||
profiles = {str(r.get('product_code') or ''): r for r in _rows('product_profile')}
|
||
bom = defaultdict(list)
|
||
if include_bom:
|
||
for row in _rows('bom_supply_chain'):
|
||
bom[str(row.get('product_code') or '')].append(row)
|
||
rows = []
|
||
for row in planning:
|
||
code = str(row.get('product_code') or '')
|
||
daily = _num(row.get('日销量预测'))
|
||
if not code or daily <= 0:
|
||
continue
|
||
profile = profiles.get(code) or {}
|
||
product_type = str(profile.get('产品类型') or '')
|
||
product_name = str(profile.get('货品名称') or '')
|
||
if product_type in ('包材', '原材料', '其他') or any(x in product_name for x in ('【内料】','【瓶子】','【瓶盖】','【泵头】','【彩盒】','【标签】')):
|
||
continue
|
||
lead = _int(profile.get('生产货期'))
|
||
logistics = _int(profile.get('物流时间'))
|
||
threshold = lead + logistics + (0 if product_type == '现货' else 7)
|
||
supply = sum(_int(row.get(k)) for k in ('库存', '加工厂库存', '生产在途', '采购在途'))
|
||
support = round(supply / daily, 1)
|
||
suggested = _int(row.get('建议下单数量'))
|
||
if support > horizon_days and support >= threshold and suggested <= 0:
|
||
continue
|
||
risk = _risk_level(support, threshold, daily)
|
||
shortage_day = date.today() + timedelta(days=max(0, int(math.floor(support))))
|
||
shortage_date = shortage_day.isoformat()
|
||
latest_order_date = (shortage_day - timedelta(days=threshold)).isoformat()
|
||
target = suggested or _round_qty(daily * threshold)
|
||
gaps = []
|
||
if target > 0:
|
||
for material in bom.get(code) or []:
|
||
supported = _int(material.get('可支撑成品数量'))
|
||
if _num(material.get('单位用量')) > 0 and supported < target:
|
||
gaps.append({'material_name': material.get('物料名称') or material.get('material_code') or '',
|
||
'support_qty': supported, 'gap': target - supported})
|
||
rows.append({
|
||
'product_code': code,
|
||
'product_name': product_name,
|
||
'brand': profile.get('品牌') or row.get('品牌') or '',
|
||
'daily_sales': round(daily, 2),
|
||
'supply_qty': supply,
|
||
'support_days': support,
|
||
'threshold_days': threshold,
|
||
'estimated_shortage_date': shortage_date,
|
||
'latest_order_date': latest_order_date,
|
||
'suggested_order_qty': suggested,
|
||
'risk_level': risk,
|
||
'bom_shortage_count': len(gaps),
|
||
'top_bom_gap': gaps[0] if gaps else None,
|
||
'planner': profile.get('采购计划') or '',
|
||
'follower': profile.get('采购跟单') or '',
|
||
'supplier': profile.get('供应商') or '',
|
||
})
|
||
rank = {'高': 3, '中': 2, '低': 1}
|
||
rows.sort(key=lambda x: (-rank.get(x['risk_level'], 0), x['support_days'], -x['suggested_order_qty']))
|
||
result = {
|
||
'generated_at': date.today().isoformat(),
|
||
'horizon_days': horizon_days,
|
||
'total': len(rows),
|
||
'items': rows[:limit],
|
||
'counts': {
|
||
'high': sum(1 for x in rows if x['risk_level'] == '高'),
|
||
'medium': sum(1 for x in rows if x['risk_level'] == '中'),
|
||
'low': sum(1 for x in rows if x['risk_level'] == '低'),
|
||
},
|
||
'data_dates': {
|
||
'inventory': (datasource_meta('product_inventory').get('data_date') or '') if 'product_inventory' in allowed else '',
|
||
'sales': (datasource_meta('product_sales').get('data_date') or '') if 'product_sales' in allowed else '',
|
||
},
|
||
}
|
||
cache_set(cache_key, result, ttl=300)
|
||
return result
|
||
|
||
|
||
def build_action_drafts(allowed_sources, profile, horizon_days=30, limit=30):
|
||
"""根据预测和主动洞察生成待人工确认的行动草稿,不执行任何写操作。"""
|
||
allowed = set(allowed_sources or [])
|
||
profile = profile or position_profile('')
|
||
drafts = []
|
||
if {'planning_metrics', 'product_profile'}.issubset(allowed):
|
||
forecast = forecast_supply_risks(horizon_days=horizon_days, limit=100, allowed_sources=allowed)
|
||
for item in forecast.get('items') or []:
|
||
if item['risk_level'] not in ('高', '中'):
|
||
continue
|
||
drafts.append({
|
||
'id': f"purchase-{item['product_code']}",
|
||
'action_type': '补货复核',
|
||
'priority': 'P0' if item['risk_level'] == '高' else 'P1',
|
||
'title': f"复核 {item['product_code']} 补货需求",
|
||
'product_code': item['product_code'],
|
||
'supplier': item.get('supplier') or '',
|
||
'suggested_qty': item.get('suggested_order_qty') or 0,
|
||
'owner': item.get('planner') or profile.get('name') or '采购计划',
|
||
'reason': f"预计{item['estimated_shortage_date']}库存见底,最晚应于{item.get('latest_order_date') or '尽快'}完成下单复核;支撑{item['support_days']}天,阈值{item['threshold_days']}天。",
|
||
'status': '待人工确认',
|
||
'source_page': '/planning',
|
||
'question': f"分析{item['product_code']}补货数量并检查BOM包材缺口",
|
||
})
|
||
|
||
raw = get_operational_insights()
|
||
insights = filter_insights(raw, allowed, profile, limit=100).get('items') or []
|
||
for item in insights:
|
||
if item.get('type') == 'overdue_order':
|
||
drafts.append({
|
||
'id': 'expedite-' + item['id'], 'action_type': '订单催办',
|
||
'priority': 'P0' if item['severity'] == '高' else 'P1',
|
||
'title': item['title'], 'product_code': item.get('product_code') or '',
|
||
'supplier': item.get('supplier') or '', 'suggested_qty': 0,
|
||
'owner': profile.get('name') or '采购跟单', 'reason': item['detail'],
|
||
'status': '待人工确认', 'source_page': item.get('source_page') or '/orders/domestic',
|
||
'question': item.get('question') or '',
|
||
})
|
||
elif item.get('type') == 'bom_shortage':
|
||
drafts.append({
|
||
'id': 'bom-' + item['id'], 'action_type': 'BOM补料复核',
|
||
'priority': 'P0' if item['severity'] == '高' else 'P1',
|
||
'title': item['title'], 'product_code': item.get('product_code') or '',
|
||
'supplier': item.get('supplier') or '',
|
||
'suggested_qty': _int((item.get('metrics') or {}).get('预计缺口')),
|
||
'owner': profile.get('name') or '采购计划', 'reason': item['detail'],
|
||
'status': '待人工确认', 'source_page': '/bom',
|
||
'question': item.get('question') or '',
|
||
})
|
||
# 去重、排序并限制数量。
|
||
unique = {}
|
||
for draft in drafts:
|
||
unique.setdefault(draft['id'], draft)
|
||
priority_rank = {'P0': 2, 'P1': 1, 'P2': 0}
|
||
rows = sorted(unique.values(), key=lambda x: (-priority_rank.get(x['priority'], 0), x['action_type'], x['title']))
|
||
return {
|
||
'generated_at': date.today().isoformat(),
|
||
'profile': profile,
|
||
'items': rows[:max(1, min(_int(limit) or 30, 100))],
|
||
'total': len(rows),
|
||
'notice': '所有行动均为只读草稿,必须由用户进入原业务菜单核对并确认后才能执行。',
|
||
}
|