dy-dashboard/services/ai_decision.py
2026-10-28 11:14:17 +08:00

341 lines
16 KiB
Python
Raw Permalink 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.

# -*- 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': '所有行动均为只读草稿,必须由用户进入原业务菜单核对并确认后才能执行。',
}