393 lines
20 KiB
Python
393 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""AI分析P1:确定性经营异常扫描与角色化洞察。
|
||
|
||
异常由后端规则计算,LLM只负责后续报告归纳,避免把关键数字交给模型臆测。
|
||
"""
|
||
from collections import defaultdict
|
||
from datetime import date, datetime
|
||
import hashlib
|
||
import re
|
||
|
||
from services.ai_datasources import AI_DATASOURCES, datasource_meta
|
||
from services.simple_cache import get as cache_get, set as cache_set, delete as cache_delete
|
||
|
||
CACHE_KEY = 'ai_p1_operational_insights_v1'
|
||
CACHE_TTL = 300
|
||
|
||
|
||
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 _parse_date(value):
|
||
if isinstance(value, (datetime, date)):
|
||
return value.date() if isinstance(value, datetime) else value
|
||
text = str(value or '').strip()
|
||
if not text:
|
||
return None
|
||
# 订单历史数据包含 YYYY-MM-DD、YYYY-MM-DD HH:mm、YYYY/M/D 等形式。
|
||
match = re.search(r'(20\d{2})[-/年](\d{1,2})[-/月](\d{1,2})', text)
|
||
if not match:
|
||
return None
|
||
try:
|
||
return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _severity_rank(level):
|
||
return {'高': 3, '中': 2, '低': 1, '信息': 0}.get(level, 0)
|
||
|
||
|
||
def _stable_id(kind, *parts):
|
||
raw = '|'.join([kind] + [str(x or '') for x in parts])
|
||
return kind + '-' + hashlib.md5(raw.encode('utf-8')).hexdigest()[:12]
|
||
|
||
|
||
def _insight(kind, category, severity, title, detail, question, source_keys,
|
||
product_code='', supplier='', metrics=None, source_page=''):
|
||
return {
|
||
'id': _stable_id(kind, product_code, supplier, title),
|
||
'type': kind,
|
||
'category': category,
|
||
'severity': severity,
|
||
'title': title,
|
||
'detail': detail,
|
||
'question': question,
|
||
'source_keys': source_keys,
|
||
'product_code': product_code,
|
||
'supplier': supplier,
|
||
'metrics': metrics or {},
|
||
'source_page': source_page,
|
||
}
|
||
|
||
|
||
def _fetch(key):
|
||
ds = AI_DATASOURCES[key]
|
||
result = ds['handler']({'_no_limit': True}) or {}
|
||
return result.get('rows') or []
|
||
|
||
|
||
def _build_raw_insights():
|
||
planning = _fetch('planning_metrics')
|
||
profiles = _fetch('product_profile')
|
||
sales = _fetch('product_sales')
|
||
orders = _fetch('order_detail')
|
||
suppliers = _fetch('supplier_delivery')
|
||
bom = _fetch('bom_supply_chain')
|
||
values = _fetch('inventory_value')
|
||
|
||
profile_map = {str(r.get('product_code') or ''): r for r in profiles}
|
||
sales_map = {str(r.get('product_code') or ''): r for r in sales}
|
||
planning_map = {str(r.get('product_code') or ''): r for r in planning}
|
||
value_map = {str(r.get('product_code') or ''): r for r in values}
|
||
today = date.today()
|
||
items = []
|
||
|
||
# 1. 缺货/补货风险、积压与销量异常。
|
||
for row in planning:
|
||
code = str(row.get('product_code') or '')
|
||
if not code:
|
||
continue
|
||
profile = profile_map.get(code) or {}
|
||
sale = sales_map.get(code) or {}
|
||
daily = _num(row.get('日销量预测'))
|
||
support = _num(row.get('总支撑天数'), 999)
|
||
inventory_support = _num(row.get('库存可支撑天数'), 999)
|
||
suggested = _int(row.get('建议下单数量'))
|
||
inventory = _int(row.get('库存')) + _int(row.get('加工厂库存'))
|
||
lead = _int(profile.get('生产货期'))
|
||
logistics = _int(profile.get('物流时间'))
|
||
product_type = str(profile.get('产品类型') or '')
|
||
product_name = str(profile.get('货品名称') or '')
|
||
# 物料/内料/标签等非销售SKU不进入成品销量与积压预警,避免历史分类不规范造成噪音。
|
||
material_terms = ('【内料】', '【瓶子】', '【瓶盖】', '【泵头】', '【彩盒】', '【标签】', '防伪码标签')
|
||
if product_type in ('包材', '原材料', '其他') or any(term in product_name for term in material_terms):
|
||
continue
|
||
threshold = lead + logistics + (0 if product_type == '现货' else 7)
|
||
avg7 = _num(sale.get('7天平均销量'))
|
||
avg30 = _num(sale.get('30天平均销量'))
|
||
|
||
if daily > 0 and (support < threshold or suggested > 0):
|
||
severity = '高' if support < max(lead + logistics, 1) else '中'
|
||
items.append(_insight(
|
||
'stockout', '缺货与补货', severity,
|
||
f'{code} 存在补货风险',
|
||
f'总支撑天数{support:.1f}天,补货阈值{threshold}天,建议下单{suggested}件,日销量预测{daily:g}件。',
|
||
f'分析{code}为什么需要下单,并检查库存、在途和BOM包材是否足够',
|
||
['planning_metrics', 'product_profile'],
|
||
product_code=code,
|
||
metrics={'总支撑天数': support, '补货阈值': threshold, '建议下单数量': suggested,
|
||
'日销量预测': daily}, source_page='/planning'))
|
||
|
||
if daily <= 0 and inventory >= 1000:
|
||
severity = '高' if inventory >= 10000 else '中'
|
||
items.append(_insight(
|
||
'overstock', '库存健康', severity,
|
||
f'{code} 无销量但仍有库存',
|
||
f'日销量预测为0,当前库存及加工厂库存合计{inventory}件,存在滞销或库存积压风险。',
|
||
f'分析{code}为什么近期无销量,库存应该如何处理',
|
||
['planning_metrics'],
|
||
product_code=code, metrics={'库存合计': inventory, '日销量预测': daily},
|
||
source_page='/planning'))
|
||
elif daily > 0 and support >= 120 and inventory >= daily * 60:
|
||
value = value_map.get(code) or {}
|
||
severity = '高' if support >= 180 else '中'
|
||
items.append(_insight(
|
||
'overstock', '库存健康', severity,
|
||
f'{code} 支撑天数偏高',
|
||
f'总支撑天数{support:.1f}天,库存及加工厂库存合计{inventory}件,明显高于近期销售需要。',
|
||
f'分析{code}库存积压原因和去库存建议',
|
||
['planning_metrics'],
|
||
product_code=code,
|
||
metrics={'总支撑天数': support, '库存合计': inventory,
|
||
}, source_page='/planning'))
|
||
|
||
if avg30 >= 3 and avg7 <= avg30 * 0.6:
|
||
decline = round((1 - avg7 / avg30) * 100, 1) if avg30 else 0
|
||
items.append(_insight(
|
||
'sales_decline', '销量异常', '高' if decline >= 60 else '中',
|
||
f'{code} 近7天销量明显下降',
|
||
f'7天平均销量{avg7:g}件,30天平均销量{avg30:g}件,下降约{decline}%。',
|
||
f'分析{code}近7天销量下降的原因及库存风险',
|
||
['product_sales'],
|
||
product_code=code, metrics={'7天平均销量': avg7, '30天平均销量': avg30, '下降幅度': decline},
|
||
source_page='/outbound'))
|
||
elif avg30 >= 1 and avg7 >= max(avg30 * 1.5, avg30 + 3):
|
||
growth = round((avg7 / avg30 - 1) * 100, 1) if avg30 else 0
|
||
severity = '高' if inventory_support < max(threshold, 15) else '中'
|
||
items.append(_insight(
|
||
'sales_growth', '销量异常', severity,
|
||
f'{code} 近7天销量快速增长',
|
||
f'7天平均销量{avg7:g}件,30天平均销量{avg30:g}件,增长约{growth}%;库存可支撑{inventory_support:.1f}天。',
|
||
f'分析{code}销量增长后是否需要提前补货',
|
||
['product_sales', 'planning_metrics'],
|
||
product_code=code, metrics={'7天平均销量': avg7, '30天平均销量': avg30,
|
||
'增长幅度': growth, '库存支撑天数': inventory_support},
|
||
source_page='/planning'))
|
||
|
||
# 2. 已逾期但仍未到货的订单。
|
||
for row in orders:
|
||
status = str(row.get('状态') or '')
|
||
outstanding = _int(row.get('未到货数量'))
|
||
expected = _parse_date(row.get('预计到货日期'))
|
||
if status in ('已完结', '已完成') or outstanding <= 0 or not expected or expected >= today:
|
||
continue
|
||
overdue_days = (today - expected).days
|
||
severity = '高' if overdue_days >= 7 or outstanding >= 5000 else '中'
|
||
code = str(row.get('product_code') or '')
|
||
supplier = str(row.get('供应商') or '')
|
||
items.append(_insight(
|
||
'overdue_order', '订单交付', severity,
|
||
f'{row.get("订单类型") or "订单"} {row.get("订单ID")} 已逾期{overdue_days}天',
|
||
f'{code or row.get("货品名称") or "该商品"}未到货{outstanding}件,预计到货日期{expected.isoformat()},供应商{supplier or "未填写"}。',
|
||
f'分析订单{row.get("订单ID")}延期风险及对{code or "相关商品"}库存的影响',
|
||
['order_detail'], product_code=code,
|
||
supplier=supplier, metrics={'逾期天数': overdue_days, '未到货数量': outstanding,
|
||
'预计到货日期': expected.isoformat()},
|
||
source_page={'国内订单': '/orders/domestic', '海外订单': '/orders/overseas',
|
||
'包材订单': '/orders/packaging'}.get(row.get('订单类型'), '/orders/domestic')))
|
||
|
||
# 3. 供应商履约风险。
|
||
for row in suppliers:
|
||
supplier = str(row.get('供应商') or '')
|
||
open_orders = _int(row.get('未完结订单数'))
|
||
outstanding = _int(row.get('未到货数量'))
|
||
completion = _num(row.get('到货完成率'))
|
||
defect = _num(row.get('不良品率'))
|
||
if open_orders < 2 and outstanding < 3000 and defect < 3:
|
||
continue
|
||
if completion < 50 or outstanding >= 20000 or defect >= 5:
|
||
severity = '高'
|
||
elif completion < 75 or outstanding >= 5000 or defect >= 2:
|
||
severity = '中'
|
||
else:
|
||
continue
|
||
items.append(_insight(
|
||
'supplier_risk', '供应商风险', severity,
|
||
f'{supplier or "未填写供应商"} 交付风险偏高',
|
||
f'未完结订单{open_orders}单,未到货{outstanding}件,到货完成率{completion:.2f}%,不良品率{defect:.2f}%。',
|
||
f'分析{supplier}供应商的未完结订单和交付风险',
|
||
['supplier_delivery'], supplier=supplier,
|
||
metrics={'未完结订单数': open_orders, '未到货数量': outstanding,
|
||
'到货完成率': completion, '不良品率': defect}, source_page='/suppliers'))
|
||
|
||
# 4. BOM瓶颈:按成品取最小可支撑数量,与建议下单/30天需求对比。
|
||
bom_by_product = defaultdict(list)
|
||
for row in bom:
|
||
code = str(row.get('product_code') or '')
|
||
if code:
|
||
bom_by_product[code].append(row)
|
||
for code, materials in bom_by_product.items():
|
||
plan = planning_map.get(code) or {}
|
||
daily = _num(plan.get('日销量预测'))
|
||
suggested = _int(plan.get('建议下单数量'))
|
||
# P1只把系统已经建议下单的成品纳入BOM预警,避免将低销量/历史BOM误报为即时缺口。
|
||
target = suggested
|
||
if target <= 0:
|
||
continue
|
||
usable = [m for m in materials if _num(m.get('单位用量')) > 0]
|
||
if not usable:
|
||
continue
|
||
bottleneck = min(usable, key=lambda m: _num(m.get('可支撑成品数量')))
|
||
support_qty = _int(bottleneck.get('可支撑成品数量'))
|
||
if support_qty >= target:
|
||
continue
|
||
gap = target - support_qty
|
||
severity = '高' if support_qty < target * 0.7 else '中'
|
||
material = str(bottleneck.get('物料名称') or bottleneck.get('material_code') or 'BOM物料')
|
||
items.append(_insight(
|
||
'bom_shortage', 'BOM物料', severity,
|
||
f'{code} 的{material}可能不足',
|
||
f'目标需求{target}件成品,当前该物料仅可支撑{support_qty}件,预计缺口{gap}件。',
|
||
f'分析{code}的BOM包材缺口并给出补货建议',
|
||
['bom_supply_chain', 'planning_metrics'], product_code=code,
|
||
supplier=str(bottleneck.get('物料供应商') or ''),
|
||
metrics={'目标需求': target, '可支撑成品数量': support_qty, '预计缺口': gap,
|
||
'瓶颈物料': material}, source_page='/bom'))
|
||
|
||
# 同一类/对象保留最高严重度,避免相同规则重复刷屏。
|
||
unique = {}
|
||
for item in items:
|
||
key = (item['type'], item.get('product_code'), item.get('supplier'), item['title'])
|
||
current = unique.get(key)
|
||
if not current or _severity_rank(item['severity']) > _severity_rank(current['severity']):
|
||
unique[key] = item
|
||
items = list(unique.values())
|
||
items.sort(key=lambda x: (-_severity_rank(x['severity']), x['category'], x['title']))
|
||
return {
|
||
'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
'items': items,
|
||
'data_dates': {
|
||
key: datasource_meta(key).get('data_date') or ''
|
||
for key in ('planning_metrics', 'product_sales', 'product_inventory', 'inventory_value')
|
||
},
|
||
}
|
||
|
||
|
||
def get_operational_insights(force=False):
|
||
if force:
|
||
cache_delete(CACHE_KEY)
|
||
data = None if force else cache_get(CACHE_KEY)
|
||
if data is None:
|
||
data = _build_raw_insights()
|
||
cache_set(CACHE_KEY, data, ttl=CACHE_TTL)
|
||
return data
|
||
|
||
|
||
def position_profile(position, role=''):
|
||
position = str(position or '')
|
||
profiles = {
|
||
'procurement_planner': {
|
||
'name': '采购计划', 'focus_types': ['stockout', 'bom_shortage', 'overstock', 'sales_growth'],
|
||
'questions': ['哪些商品最需要优先补货?', '哪些建议下单商品存在BOM包材缺口?',
|
||
'哪些商品库存支撑天数过高?', '哪些销量增长商品需要提前备货?']},
|
||
'procurement_follower': {
|
||
'name': '采购跟单', 'focus_types': ['overdue_order', 'supplier_risk', 'bom_shortage'],
|
||
'questions': ['当前有哪些逾期未到货订单?', '哪些供应商未完结订单最多?',
|
||
'哪些延期订单会造成商品缺货?', '供应商到货完成率和不良品情况如何?']},
|
||
'operator': {
|
||
'name': '运营专员', 'focus_types': ['sales_decline', 'sales_growth', 'overstock', 'stockout'],
|
||
'questions': ['哪些商品近7天销量明显下降?', '哪些商品销量增长但库存不足?',
|
||
'哪些商品无销量但仍有大量库存?', '分析各品牌最近30天出库表现']},
|
||
'product_developer': {
|
||
'name': '产品开发', 'focus_types': ['bom_shortage', 'supplier_risk', 'overstock'],
|
||
'questions': ['哪些成品存在BOM物料瓶颈?', '哪些包材供应商交付风险较高?',
|
||
'新品相关产品是否存在库存积压?', '查询某个产品的BOM和供应商信息']},
|
||
'finance_specialist': {
|
||
'name': '财务专员', 'focus_types': ['overstock', 'supplier_risk'],
|
||
'questions': ['当前库存金额及主要构成是什么?', '哪些高库存金额商品动销较差?',
|
||
'供应商未到货数量和资金占用情况如何?', '各品牌库存金额和动销SKU数如何?']},
|
||
'bp': {
|
||
'name': 'BP', 'focus_types': ['stockout', 'overstock', 'sales_decline', 'sales_growth',
|
||
'overdue_order', 'supplier_risk', 'bom_shortage'],
|
||
'questions': ['生成今日经营风险分析报告', '当前最高风险的商品和订单有哪些?',
|
||
'库存、销量、订单和供应商有哪些异常?', '哪些问题需要管理层优先推动?']},
|
||
'procurement_trainee': {
|
||
'name': '采购管培生', 'focus_types': ['stockout', 'overdue_order', 'supplier_risk', 'bom_shortage'],
|
||
'questions': ['当前有哪些需要关注的采购风险?', '哪些订单已经逾期?',
|
||
'哪些商品需要补货?', '哪些BOM物料可能不足?']},
|
||
}
|
||
if position in profiles:
|
||
return profiles[position]
|
||
if role in ('admin', 'super_admin'):
|
||
return profiles['bp'] | {'name': '管理人员'}
|
||
return {
|
||
'name': '业务人员',
|
||
'focus_types': ['stockout', 'overstock', 'sales_decline', 'overdue_order', 'bom_shortage'],
|
||
'questions': ['当前有哪些高风险业务异常?', '哪些商品需要补货?',
|
||
'哪些订单已经延期?', '哪些商品库存积压?']
|
||
}
|
||
|
||
|
||
def _impact_score(item):
|
||
"""在同一严重度内按业务影响排序,防止大量低影响异常淹没真正风险。"""
|
||
metrics = item.get('metrics') or {}
|
||
qty = max(_num(metrics.get(k)) for k in (
|
||
'预计缺口', '未到货数量', '建议下单数量', '库存合计', '下单数量'
|
||
))
|
||
days = max(_num(metrics.get(k)) for k in ('逾期天数', '补货阈值'))
|
||
ratio = max(_num(metrics.get(k)) for k in ('下降幅度', '增长幅度', '不良品率'))
|
||
amount = _num(metrics.get('库存金额')) / 100.0
|
||
return qty + days * 100 + ratio * 10 + amount
|
||
|
||
def filter_insights(raw, allowed_sources, profile, limit=80):
|
||
allowed = set(allowed_sources or [])
|
||
focus = profile.get('focus_types') or []
|
||
items = []
|
||
for item in raw.get('items') or []:
|
||
source_keys = item.get('source_keys') or []
|
||
# 生成该异常实际用到的数据源必须全部有权限,避免跨菜单侧漏。
|
||
if not source_keys or any(k not in allowed for k in source_keys):
|
||
continue
|
||
copy = dict(item)
|
||
copy['source_keys'] = list(source_keys)
|
||
copy['metrics'] = dict(copy.get('metrics') or {})
|
||
if 'inventory_value' not in allowed:
|
||
copy['metrics'].pop('库存金额', None)
|
||
copy['focused'] = copy.get('type') in focus
|
||
items.append(copy)
|
||
items.sort(key=lambda x: (0 if x.get('focused') else 1, -_severity_rank(x['severity']),
|
||
-_impact_score(x), x['title']))
|
||
# 按异常类型和风险等级均衡取样,避免高风险数量太多导致中风险完全看不到。
|
||
max_items = max(1, min(int(limit or 80), 200))
|
||
grouped = defaultdict(list)
|
||
for item in items:
|
||
grouped[item['type']].append(item)
|
||
quota = max(4, (max_items + max(len(grouped), 1) - 1) // max(len(grouped), 1))
|
||
balanced = []
|
||
for group_items in grouped.values():
|
||
by_severity = defaultdict(list)
|
||
for item in group_items:
|
||
by_severity[item['severity']].append(item)
|
||
chosen = by_severity['高'][:max(1, int(quota * 0.65))]
|
||
chosen += by_severity['中'][:max(1, int(quota * 0.3))]
|
||
chosen += by_severity['低'][:max(1, quota - len(chosen))]
|
||
if len(chosen) < quota:
|
||
seen_ids = {x['id'] for x in chosen}
|
||
chosen += [x for x in group_items if x['id'] not in seen_ids][:quota-len(chosen)]
|
||
balanced.extend(chosen[:quota])
|
||
balanced.sort(key=lambda x: (0 if x.get('focused') else 1, -_severity_rank(x['severity']),
|
||
-_impact_score(x), x['title']))
|
||
items = balanced[:max_items]
|
||
counts = defaultdict(int)
|
||
categories = defaultdict(int)
|
||
for item in items:
|
||
counts[item['severity']] += 1
|
||
categories[item['category']] += 1
|
||
return {
|
||
'generated_at': raw.get('generated_at'),
|
||
'data_dates': raw.get('data_dates') or {},
|
||
'items': items,
|
||
'counts': {'high': counts['高'], 'medium': counts['中'], 'low': counts['低'], 'total': len(items)},
|
||
'categories': dict(categories),
|
||
'profile': profile,
|
||
}
|