955 lines
50 KiB
Python
955 lines
50 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""AI 分析数据源层:把系统已按规则计算好的数据封装为数据源,AI 只选择数据源+参数,不直接查原始表。
|
||
|
||
每个数据源 = {key, name, desc, fields, handler(params) -> {columns, rows}}
|
||
handler 返回 {columns: [列名...], rows: [{列名: 值}...]}
|
||
"""
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from services.db import query
|
||
from services.inventory_calc import (
|
||
compute_inventory_cost_summary,
|
||
compute_product_inventory_cached,
|
||
compute_cloud_inventory_cached,
|
||
compute_external_inventory_cached,
|
||
compute_product_in_transit_cached,
|
||
compute_product_inventory_15,
|
||
resolve_cost_price,
|
||
_get_latest_inv_date,
|
||
)
|
||
from services.sales_calc import (compute_product_sales_extended_cached,
|
||
compute_product_sales_cached)
|
||
from services.forecast import sales_predict_config_cached
|
||
from services.product_type import get_product_type_cached
|
||
|
||
|
||
# 英文字段名 → 中文字段名映射(AI 可能输出英文列名,需归一化后才能排序)
|
||
_FIELD_ALIASES = {
|
||
'available_stock': '库存',
|
||
'stock': '库存',
|
||
'inventory': '库存',
|
||
'cloud_stock': '云仓库存',
|
||
'cloud_inventory': '云仓库存',
|
||
'external_stock': '外仓库存',
|
||
'external_inventory': '外仓库存',
|
||
'factory_stock': '加工厂库存',
|
||
'factory_inventory': '加工厂库存',
|
||
'production_transit': '生产在途',
|
||
'purchase_transit': '采购在途',
|
||
'product_code': 'product_code',
|
||
'昨日销量': '昨日销量',
|
||
'yesterday_sales': '昨日销量',
|
||
'3天平均销量': '3天平均销量',
|
||
'7天平均销量': '7天平均销量',
|
||
'30天平均销量': '30天平均销量',
|
||
'60天平均销量': '60天平均销量',
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 工具:合并库存/在途数据为每个产品的完整视图
|
||
# ──────────────────────────────────────────────
|
||
def _products_map(codes, active_only=False):
|
||
"""批量取产品资料(编码 → 名称/类型/品牌),分片查询避免 IN 参数过长"""
|
||
codes = [c for c in dict.fromkeys(codes) if c]
|
||
out = {}
|
||
for i in range(0, len(codes), 500):
|
||
chunk = codes[i:i + 500]
|
||
ph = ','.join(['%s'] * len(chunk))
|
||
for r in query(
|
||
f"SELECT product_code, product_name, product_type, brand FROM products WHERE product_code IN ({ph})"
|
||
+ (" AND deleted_at IS NULL" if active_only else ""),
|
||
chunk
|
||
):
|
||
out[r['product_code']] = r
|
||
return out
|
||
|
||
|
||
def _build_product_inventory_view():
|
||
inv_map = compute_product_inventory_cached() or {}
|
||
cloud_map = compute_cloud_inventory_cached() or {}
|
||
ext_map = compute_external_inventory_cached() or {}
|
||
inv15_map = compute_product_inventory_15() or {}
|
||
transit_map = compute_product_in_transit_cached() or {}
|
||
|
||
codes = set(inv_map) | set(cloud_map) | set(ext_map) | set(inv15_map) | set(transit_map)
|
||
# 产品资料(判断包材/内料)
|
||
prod_info = {}
|
||
if codes:
|
||
_codes = list(codes)
|
||
_ph = ','.join(['%s'] * len(_codes))
|
||
_rows = query(f"SELECT product_code, product_name, product_type, brand FROM products WHERE deleted_at IS NULL AND product_code IN ({_ph})", _codes)
|
||
prod_info = {r['product_code']: r for r in _rows}
|
||
|
||
def _is_inner_or_packaging(code):
|
||
"""内料:名称含「内料」或编码含 -NL;包材:产品类型为包材;无产品资料的视为虚拟货品(邮费链接/服务包/晒单礼等)排除"""
|
||
code_u = (code or '').upper()
|
||
if '-NL' in code_u:
|
||
return True
|
||
info = prod_info.get(code)
|
||
if not info:
|
||
return True # 无产品资料:虚拟货品/异常编码,不参与库存排行
|
||
name = info.get('product_name') or ''
|
||
ptype = info.get('product_type') or ''
|
||
if '内料' in name:
|
||
return True
|
||
if ptype == '包材':
|
||
return True
|
||
if ptype in ('赠品', '其他', '原材料'):
|
||
return True
|
||
return False
|
||
|
||
rows = []
|
||
for code in codes:
|
||
if _is_inner_or_packaging(code):
|
||
continue
|
||
t = transit_map.get(code, {})
|
||
rows.append({
|
||
'product_code': code,
|
||
'品牌': (prod_info.get(code) or {}).get('brand') or '',
|
||
'库存': int(inv_map.get(code, 0)),
|
||
'云仓库存': int(cloud_map.get(code, 0)),
|
||
'外仓库存': int(ext_map.get(code, 0)),
|
||
'加工厂库存': int(inv15_map.get(code, 0)),
|
||
'生产在途': int(t.get('生产在途', 0)),
|
||
'采购在途': int(t.get('采购在途', 0)),
|
||
})
|
||
return rows
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 数据源 handlers
|
||
# ──────────────────────────────────────────────
|
||
def _h_inventory_cost_summary(params):
|
||
"""库存成本汇总(与数据概览一致:特殊成本价、排除内料、在途规则)"""
|
||
cost = compute_inventory_cost_summary()
|
||
row = {
|
||
'核对日期': cost['latest_date'],
|
||
'库存总成本(元)': cost['total_cost'],
|
||
'成品成本(元)': cost['finished_cost'],
|
||
'包材成本(元)': cost['packaging_cost'],
|
||
'成品库存(元)': cost['finished_inventory_cost'],
|
||
'成品生产在途(元)': cost['finished_production_transit_cost'],
|
||
'成品采购在途(元)': cost['finished_purchase_transit_cost'],
|
||
'包材库存(元)': cost['packaging_inventory_cost'],
|
||
'包材采购在途(元)': cost['packaging_purchase_transit_cost'],
|
||
}
|
||
return {'columns': list(row.keys()), 'rows': [row]}
|
||
|
||
|
||
def _apply_filters(rows, params, text_fields=()):
|
||
"""通用筛选:product_code / brand / limit / sort_by / sort_order
|
||
|
||
sort_by 支持中文字段名或英文别名(available_stock→库存 等);
|
||
降序排行时自动剔除排序字段为 0 的行,避免 TOP 榜混入无效数据。
|
||
"""
|
||
code = (params.get('product_code') or '').strip()
|
||
if code:
|
||
code_lower = code.lower()
|
||
exact = [r for r in rows if code_lower == str(r.get('product_code', '')).strip().lower()]
|
||
# 编码存在精确结果时优先精确匹配,避免成品编码同时命中其-BT/-CH等子物料。
|
||
rows = exact or [r for r in rows if code_lower in str(r.get('product_code', '')).lower()
|
||
or code_lower in str(r.get('product_name', '')).lower()
|
||
or code_lower in str(r.get('货品名称', '')).lower()]
|
||
brand = (params.get('brand') or '').strip()
|
||
if brand and '品牌' in (rows[0] if rows else {}):
|
||
rows = [r for r in rows if brand.lower() in str(r.get('品牌', '')).lower()]
|
||
# P0 多业务域通用筛选。字段不存在时忽略,避免每个handler重复实现。
|
||
common_filters = {
|
||
'supplier': ('供应商', '物料供应商'),
|
||
'status': ('状态',),
|
||
'product_type': ('产品类型', '类型'),
|
||
'planner': ('采购计划',),
|
||
'follower': ('采购跟单',),
|
||
'operator': ('运营',),
|
||
'material_code': ('material_code',),
|
||
}
|
||
sample = rows[0] if rows else {}
|
||
for param_key, candidates in common_filters.items():
|
||
expected = str(params.get(param_key) or '').strip().lower()
|
||
if not expected:
|
||
continue
|
||
target = next((field for field in candidates if field in sample), None)
|
||
if target:
|
||
rows = [r for r in rows if expected in str(r.get(target) or '').lower()]
|
||
sort_by = (params.get('sort_by') or '').strip()
|
||
sort_order = (params.get('sort_order') or 'desc').strip().lower()
|
||
if sort_by and rows:
|
||
# 英文字段名 → 中文字段名映射
|
||
sort_field = _FIELD_ALIASES.get(sort_by, sort_by)
|
||
if sort_field in rows[0]:
|
||
rev = sort_order == 'desc'
|
||
try:
|
||
# 数值字段排序(None 排最后)
|
||
rows = sorted(rows, key=lambda r: (r.get(sort_field) is None, float(r.get(sort_field) or 0)), reverse=rev)
|
||
except (TypeError, ValueError):
|
||
# 文本字段(如是否调拨/产品分级/销售趋势)按字符串排序
|
||
rows = sorted(rows, key=lambda r: (r.get(sort_field) is None, str(r.get(sort_field) or '')), reverse=rev)
|
||
# 降序排行:剔除排序字段为 0 的行(TOP 榜不含无效数据)
|
||
if rev:
|
||
try:
|
||
float(rows[0].get(sort_field) or 0)
|
||
rows = [r for r in rows if r.get(sort_field) is not None and float(r.get(sort_field) or 0) != 0]
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if params.get('_no_limit'):
|
||
return rows
|
||
limit = params.get('limit')
|
||
try:
|
||
limit = int(limit) if limit else 50
|
||
except (TypeError, ValueError):
|
||
limit = 50
|
||
if limit < 1:
|
||
limit = 50
|
||
if limit > 200:
|
||
limit = 200
|
||
return rows[:limit]
|
||
|
||
|
||
def _h_product_inventory(params):
|
||
"""每个产品的最新库存视图(可用/云仓/外仓/加工厂/在途)"""
|
||
rows = _build_product_inventory_view()
|
||
rows = _apply_filters(rows, params)
|
||
columns = ['product_code', '品牌', '库存', '云仓库存', '外仓库存', '加工厂库存', '生产在途', '采购在途']
|
||
return {'columns': columns, 'rows': rows}
|
||
|
||
|
||
def _h_product_sales(params):
|
||
"""每个产品的销量统计(总销量:昨日/3/7/14/30/60天平均、最高)"""
|
||
data = compute_product_sales_extended_cached() or {}
|
||
total = data.get('total') or {}
|
||
brand_map = {c: (p.get('brand') or '') for c, p in _products_map(list(total.keys())).items()}
|
||
rows = []
|
||
for code, s in total.items():
|
||
rows.append({
|
||
'product_code': code,
|
||
'品牌': brand_map.get(code, ''),
|
||
'昨日销量': s.get('昨日销量', 0),
|
||
'3天平均销量': s.get('3天平均销量', 0),
|
||
'7天平均销量': s.get('7天平均销量', 0),
|
||
'14天平均销量': s.get('14天平均销量', 0),
|
||
'30天平均销量': s.get('30天平均销量', 0),
|
||
'60天平均销量': s.get('60天平均销量', 0),
|
||
'3天最高销量': s.get('3天最高销量', 0),
|
||
'7天最高销量': s.get('7天最高销量', 0),
|
||
'14天最高销量': s.get('14天最高销量', 0),
|
||
'30天最高销量': s.get('30天最高销量', 0),
|
||
})
|
||
rows = _apply_filters(rows, params)
|
||
columns = ['product_code', '品牌', '昨日销量', '3天平均销量', '7天平均销量', '14天平均销量',
|
||
'30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', '14天最高销量', '30天最高销量']
|
||
return {'columns': columns, 'rows': rows}
|
||
|
||
|
||
def _h_outbound_stats(params):
|
||
"""出库统计:按日期/品牌/店铺/单品 TOP(与数据概览一致口径)
|
||
|
||
支持 days 参数限定最近 N 天(如"最近30天"传 days=30);
|
||
自动排除无效品牌/店铺(无/-/现货/NA/合计等)和 0 出库数据。
|
||
"""
|
||
kind = (params.get('kind') or 'product').strip()
|
||
days = params.get('days') or None
|
||
try:
|
||
days = int(days)
|
||
except (TypeError, ValueError):
|
||
days = None
|
||
if days is not None:
|
||
days = max(1, min(days, 365))
|
||
limit = params.get('limit') or 10
|
||
try:
|
||
limit = int(limit)
|
||
except (TypeError, ValueError):
|
||
limit = 10
|
||
limit = max(1, min(limit, 50))
|
||
|
||
if kind == 'brand':
|
||
date_cond = ''
|
||
date_args = []
|
||
if days:
|
||
date_cond = ' AND 日期 >= DATE_SUB(CURDATE(), INTERVAL %s DAY)'
|
||
date_args.append(days)
|
||
rows = query("""
|
||
SELECT 品牌, SUM(货品数量) AS 总数量, ROUND(SUM(货品成本),2) AS 总成本,
|
||
COUNT(DISTINCT 货品编号) AS SKU数
|
||
FROM daily_outbound
|
||
WHERE 品牌 IS NOT NULL AND 品牌 != '' AND 品牌 != 'NA'
|
||
AND 品牌 NOT IN ('无', '-', '现货', '新媒体现货', '潘思宇代发')
|
||
""" + date_cond + """
|
||
GROUP BY 品牌
|
||
HAVING SUM(货品数量) > 0
|
||
ORDER BY 总数量 DESC LIMIT %s
|
||
""", tuple(date_args + [limit]))
|
||
return {'columns': ['品牌', '总数量', '总成本', 'SKU数'], 'rows': rows}
|
||
if kind == 'shop':
|
||
date_cond = ''
|
||
date_args = []
|
||
if days:
|
||
date_cond = ' AND 日期 >= DATE_SUB(CURDATE(), INTERVAL %s DAY)'
|
||
date_args.append(days)
|
||
rows = query("""
|
||
SELECT 店铺, SUM(货品数量) AS 总数量, ROUND(SUM(货品成本),2) AS 总成本
|
||
FROM daily_outbound
|
||
WHERE 店铺 IS NOT NULL AND 店铺 != '' AND 店铺 != 'NA'
|
||
AND 店铺 NOT LIKE '%%合计%%'
|
||
""" + date_cond + """
|
||
GROUP BY 店铺
|
||
HAVING SUM(货品数量) > 0
|
||
ORDER BY 总数量 DESC LIMIT %s
|
||
""", tuple(date_args + [limit]))
|
||
return {'columns': ['店铺', '总数量', '总成本'], 'rows': rows}
|
||
# 默认:单品 TOP
|
||
date_cond = ''
|
||
date_args = []
|
||
if days:
|
||
date_cond = ' AND d.日期 >= DATE_SUB(CURDATE(), INTERVAL %s DAY)'
|
||
date_args.append(days)
|
||
rows = query("""
|
||
SELECT d.货品编号, MAX(d.货品名称) AS 货品名称, SUM(d.货品数量) AS 总数量,
|
||
ROUND(SUM(d.货品成本),2) AS 总成本
|
||
FROM daily_outbound d
|
||
LEFT JOIN products p ON d.货品编号 = p.product_code
|
||
WHERE d.货品编号 IS NOT NULL AND d.货品编号 != '' AND d.货品编号 != 'NA'
|
||
AND d.货品编号 NOT LIKE '%%合计%%'
|
||
AND d.货品名称 NOT LIKE '%%赠品%%'
|
||
AND d.货品名称 NOT LIKE '%%小样%%'
|
||
AND COALESCE(p.product_type, '') != '赠品'
|
||
""" + date_cond + """
|
||
GROUP BY d.货品编号
|
||
HAVING SUM(d.货品数量) > 0
|
||
ORDER BY 总数量 DESC LIMIT %s
|
||
""", tuple(date_args + [limit]))
|
||
return {'columns': ['货品编号', '货品名称', '总数量', '总成本'], 'rows': rows}
|
||
|
||
|
||
def _h_outbound_daily(params):
|
||
"""按日期出库统计(最近 N 天)"""
|
||
days = params.get('days') or 10
|
||
try:
|
||
days = int(days)
|
||
except (TypeError, ValueError):
|
||
days = 10
|
||
days = max(1, min(days, 60))
|
||
rows = query("""
|
||
SELECT DATE_FORMAT(日期, '%%Y-%%m-%%d') AS 日期, COUNT(*) AS 记录数,
|
||
SUM(货品数量) AS 总数量, ROUND(SUM(货品成本),2) AS 总成本
|
||
FROM daily_outbound GROUP BY 日期 ORDER BY 日期 DESC LIMIT %s
|
||
""", (days,))
|
||
return {'columns': ['日期', '记录数', '总数量', '总成本'], 'rows': rows}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 计划预测口径(复用计划预测页同一套计算,避免口径不一致)
|
||
# ──────────────────────────────────────────────
|
||
PLANNING_FIELDS = ['product_code', '品牌', '日销量预测', '总支撑天数', '云仓支撑天数', '外仓支撑天数',
|
||
'库存', '云仓库存', '外仓库存', '加工厂库存', '生产在途', '采购在途',
|
||
'云仓可分配库存', '外仓可分配库存', '是否调拨', '建议下单数量',
|
||
'产品分级', '销售趋势', '库存可支撑天数', '昨日销量', '30天平均销量']
|
||
|
||
|
||
def _h_planning_metrics(params):
|
||
"""计划预测口径:日销量预测、支撑天数、可分配库存、是否调拨、建议下单数量、分级/趋势"""
|
||
from routes.planning import _enrich_row_with_planning_data, _ensure_global_config
|
||
base_rows = query("""
|
||
SELECT p.product_code, p.product_name, p.brand, p.product_type,
|
||
p.lead_time AS 生产货期, p.logistics_time AS 物流时间, p.created_at AS 创建时间
|
||
FROM products p
|
||
WHERE p.deleted_at IS NULL
|
||
AND COALESCE(p.product_type, '') NOT IN ('包材', '其他')
|
||
AND p.product_code NOT IN (SELECT product_code FROM plan_hidden)
|
||
""")
|
||
sales = compute_product_sales_cached()
|
||
inv_map = compute_product_inventory_cached()
|
||
inv15_map = compute_product_inventory_15()
|
||
transit_map = compute_product_in_transit_cached()
|
||
cloud_inv_map = compute_cloud_inventory_cached()
|
||
ext_inv_map = compute_external_inventory_cached()
|
||
extended_sales = compute_product_sales_extended_cached()
|
||
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
|
||
gc = _ensure_global_config()
|
||
rows = []
|
||
for row in base_rows:
|
||
row['生产货期'] = row.get('生产货期') or 0
|
||
row['物流时间'] = row.get('物流时间') or 0
|
||
if row.get('创建时间') and hasattr(row['创建时间'], 'strftime'):
|
||
row['创建时间'] = row['创建时间'].strftime('%Y-%m-%d')
|
||
row['产品种类'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type'))
|
||
row['品牌'] = row.get('brand') or ''
|
||
row = _enrich_row_with_planning_data(
|
||
row, sales, inv_map, inv15_map, transit_map, sp_map, gc,
|
||
cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map, extended_sales=extended_sales
|
||
)
|
||
rows.append({k: row.get(k) for k in PLANNING_FIELDS})
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': PLANNING_FIELDS, 'rows': rows}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 订单在途与到货
|
||
# ──────────────────────────────────────────────
|
||
def _h_orders_in_transit(params):
|
||
"""未完结订单的未到数量(国内/海外/包材),包材按所属成品归集"""
|
||
ORDER_FIELDS = ['product_code', '品牌', '货品名称', '国内未到数量', '海外未到数量', '包材未到数量',
|
||
'在途未到合计', '国内未完结订单数', '海外未完结订单数', '包材未完结订单数']
|
||
by_code = {}
|
||
|
||
def _merge(code, name, qty, orders, qty_key, order_key):
|
||
code = (code or '').strip()
|
||
if not code:
|
||
return
|
||
row = by_code.setdefault(code, {'product_code': code, '货品名称': name or '',
|
||
'国内未到数量': 0, '海外未到数量': 0, '包材未到数量': 0,
|
||
'国内未完结订单数': 0, '海外未完结订单数': 0, '包材未完结订单数': 0})
|
||
row[qty_key] += int(qty or 0)
|
||
row[order_key] += int(orders or 0)
|
||
if name and not row['货品名称']:
|
||
row['货品名称'] = name
|
||
|
||
for r in query("""
|
||
SELECT 货品编号 AS code, MAX(货品名称) AS name, COUNT(*) AS n,
|
||
SUM(COALESCE(未到货数, COALESCE(数量, 0) - COALESCE(实际到货数量, 0))) AS qty
|
||
FROM domestic_orders WHERE COALESCE(状态, '') != '已完结'
|
||
GROUP BY 货品编号
|
||
"""):
|
||
_merge(r['code'], r['name'], r['qty'], r['n'], '国内未到数量', '国内未完结订单数')
|
||
for r in query("""
|
||
SELECT 产品编码 AS code, MAX(货品名称) AS name, COUNT(*) AS n,
|
||
SUM(COALESCE(未到货数, COALESCE(数量, 0) - COALESCE(实际到货数量, 0))) AS qty
|
||
FROM overseas_orders WHERE COALESCE(状态, '') != '已完结'
|
||
GROUP BY 产品编码
|
||
"""):
|
||
_merge(r['code'], r['name'], r['qty'], r['n'], '海外未到数量', '海外未完结订单数')
|
||
for r in query("""
|
||
SELECT 来源产品编码 AS code, MAX(名称) AS name, COUNT(*) AS n,
|
||
SUM(COALESCE(未到货数, COALESCE(数量, 0) - COALESCE(实际到货数量, 0))) AS qty
|
||
FROM packaging_orders WHERE COALESCE(状态, '') != '已完结'
|
||
GROUP BY 来源产品编码
|
||
"""):
|
||
_merge(r['code'], r['name'], r['qty'], r['n'], '包材未到数量', '包材未完结订单数')
|
||
|
||
rows = list(by_code.values())
|
||
brand_map = {c: (p.get('brand') or '') for c, p in _products_map([r['product_code'] for r in rows]).items()}
|
||
for r in rows:
|
||
r['品牌'] = brand_map.get(r['product_code'], '')
|
||
r['在途未到合计'] = r['国内未到数量'] + r['海外未到数量'] + r['包材未到数量']
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': ORDER_FIELDS, 'rows': rows}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 包材/内料库存
|
||
# ──────────────────────────────────────────────
|
||
def _h_material_inventory(params):
|
||
"""包材/内料库存:产品库存视图已排除的物料维度库存(可用/云仓/外仓/加工厂/在途)"""
|
||
MATERIAL_FIELDS = ['product_code', '名称', '类型', '品牌', '可用库存', '云仓库存', '外仓库存',
|
||
'加工厂库存', '生产在途', '采购在途']
|
||
latest = _get_latest_inv_date()
|
||
raw = query("""
|
||
SELECT product_code, MAX(product_name) AS 名称,
|
||
SUM(available_qty) AS 可用库存,
|
||
SUM(production_in_transit) AS 生产在途,
|
||
SUM(purchase_in_transit) AS 采购在途
|
||
FROM inventory_detail WHERE data_date=%s
|
||
GROUP BY product_code
|
||
""", (latest,))
|
||
prod = _products_map([r['product_code'] for r in raw])
|
||
cloud_map = compute_cloud_inventory_cached() or {}
|
||
ext_map = compute_external_inventory_cached() or {}
|
||
inv15_map = compute_product_inventory_15() or {}
|
||
rows = []
|
||
for r in raw:
|
||
code = r['product_code'] or ''
|
||
info = prod.get(code) or {}
|
||
name = info.get('product_name') or r.get('名称') or ''
|
||
is_inner = ('-NL' in code.upper()) or ('内料' in name)
|
||
is_packaging = (info.get('product_type') or '') == '包材'
|
||
if not (is_inner or is_packaging):
|
||
continue
|
||
rows.append({
|
||
'product_code': code,
|
||
'名称': name,
|
||
'类型': '内料' if is_inner else '包材',
|
||
'品牌': info.get('brand') or '',
|
||
'可用库存': int(r['可用库存'] or 0),
|
||
'云仓库存': int(cloud_map.get(code, 0) or 0),
|
||
'外仓库存': int(ext_map.get(code, 0) or 0),
|
||
'加工厂库存': int(inv15_map.get(code, 0) or 0),
|
||
'生产在途': int(r['生产在途'] or 0),
|
||
'采购在途': int(r['采购在途'] or 0),
|
||
})
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': MATERIAL_FIELDS, 'rows': rows}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 产品库存金额
|
||
# ──────────────────────────────────────────────
|
||
VALUE_FIELDS = ['product_code', '品牌', '产品名称', '库存', '成本价', '库存金额']
|
||
|
||
|
||
def _inventory_value_rows(latest=None):
|
||
"""成品口径的逐商品库存金额(库存 × 成本价,命中特殊成本价规则)"""
|
||
latest = latest or _get_latest_inv_date()
|
||
raw = query("""
|
||
SELECT product_code, ANY_VALUE(cost_price) AS 成本价, SUM(available_qty) AS 库存
|
||
FROM inventory_detail
|
||
WHERE data_date=%s AND product_code NOT LIKE '%%-NL%%' AND product_name NOT LIKE '%%内料%%'
|
||
GROUP BY product_code
|
||
""", (latest,))
|
||
prod = _products_map([r['product_code'] for r in raw], active_only=True)
|
||
rows = []
|
||
for r in raw:
|
||
code = r['product_code'] or ''
|
||
info = prod.get(code) or {}
|
||
if (info.get('product_type') or '') == '包材' or '内料' in (info.get('product_name') or ''):
|
||
continue
|
||
if not info:
|
||
continue # 无产品资料的虚拟货品/异常编码不计入
|
||
stock = int(r['库存'] or 0)
|
||
price = resolve_cost_price(code, float(r['成本价'] or 0))
|
||
rows.append({
|
||
'product_code': code,
|
||
'品牌': (info.get('brand') or '').strip(),
|
||
'产品名称': info.get('product_name') or '',
|
||
'库存': stock,
|
||
'成本价': price,
|
||
'库存金额': float((Decimal(stock) * Decimal(str(price))).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)),
|
||
})
|
||
return rows
|
||
|
||
|
||
def _inventory_value_filtered(rows, params):
|
||
"""汇总只应用业务筛选;排序、零值排行剔除和条数限制只影响明细。"""
|
||
business = {k: v for k, v in params.items() if k not in ('sort_by', 'sort_order', 'limit')}
|
||
brand = str(params.get('brand') or '').strip()
|
||
if brand:
|
||
exact = [r for r in rows if str(r.get('品牌') or '').strip().casefold() == brand.casefold()]
|
||
if exact:
|
||
rows = exact
|
||
business.pop('brand', None)
|
||
return _apply_filters(rows, {**business, '_no_limit': True})
|
||
|
||
|
||
def _inventory_value_summary(filtered, latest, params):
|
||
total_value = sum((Decimal(str(r.get('库存金额') or 0)) for r in filtered), Decimal('0'))
|
||
top = sorted(filtered, key=lambda r: (-float(r.get('库存金额') or 0), str(r.get('product_code') or '')))[:5]
|
||
return {
|
||
'kind': 'inventory_value',
|
||
'authoritative': True,
|
||
'date': latest,
|
||
'row_count': len(filtered),
|
||
'sku_count': len(filtered),
|
||
'stock_qty': sum(int(r.get('库存') or 0) for r in filtered),
|
||
'inventory_value': float(total_value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)),
|
||
'zero_stock_sku_count': sum(1 for r in filtered if not r.get('库存')),
|
||
'unpriced_stock_sku_count': sum(1 for r in filtered if r.get('库存') and not r.get('成本价')),
|
||
'filter': {k: str(params.get(k) or '').strip() for k in ('brand', 'product_code')
|
||
if str(params.get(k) or '').strip()},
|
||
'top_items': [
|
||
{'product_code': r.get('product_code'), 'product_name': r.get('产品名称'),
|
||
'stock_qty': int(r.get('库存') or 0), 'inventory_value': float(r.get('库存金额') or 0),
|
||
'cost_price': float(r.get('成本价') or 0)}
|
||
for r in top
|
||
],
|
||
}
|
||
|
||
|
||
def _h_inventory_value(params):
|
||
"""逐商品库存金额明细及独立于展示条数的全量汇总。"""
|
||
latest = _get_latest_inv_date()
|
||
filtered = _inventory_value_filtered(_inventory_value_rows(latest), params)
|
||
return {'columns': VALUE_FIELDS, 'rows': _apply_filters(filtered, params),
|
||
'summary': _inventory_value_summary(filtered, latest, params)}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 品牌维度汇总
|
||
# ──────────────────────────────────────────────
|
||
BRAND_FIELDS = ['品牌', 'SKU数', '库存', '库存金额', '昨日销量', '30天平均销量',
|
||
'近30天动销SKU数', '近100天动销SKU数']
|
||
|
||
|
||
def _h_brand_overview(params):
|
||
"""按品牌汇总库存/金额/销量与动销SKU数(默认按库存金额倒序)"""
|
||
latest = _get_latest_inv_date()
|
||
value_rows = _inventory_value_filtered(_inventory_value_rows(latest), params)
|
||
sales = compute_product_sales_cached() or {}
|
||
active_30 = {r['货品编号'] for r in query(
|
||
"SELECT DISTINCT 货品编号 FROM daily_outbound WHERE 日期 >= DATE_SUB(%s, INTERVAL 29 DAY)", (latest,))}
|
||
active_100 = {r['货品编号'] for r in query(
|
||
"SELECT DISTINCT 货品编号 FROM daily_outbound WHERE 日期 >= DATE_SUB(%s, INTERVAL 99 DAY)", (latest,))}
|
||
agg = {}
|
||
for r in value_rows:
|
||
b = r['品牌'] or '(无品牌)'
|
||
row = agg.setdefault(b, {'品牌': b, 'SKU数': 0, '库存': 0, '库存金额': 0.0,
|
||
'昨日销量': 0, '30天平均销量': 0.0,
|
||
'近30天动销SKU数': 0, '近100天动销SKU数': 0})
|
||
code = r['product_code']
|
||
s = sales.get(code) or {}
|
||
row['SKU数'] += 1
|
||
row['库存'] += int(r['库存'] or 0)
|
||
row['库存金额'] = float(Decimal(str(row['库存金额'])) + Decimal(str(r['库存金额'] or 0)))
|
||
row['昨日销量'] += int(s.get('昨日销量', 0) or 0)
|
||
row['30天平均销量'] = round(row['30天平均销量'] + float(s.get('30天平均销量', 0) or 0), 2)
|
||
if code in active_30:
|
||
row['近30天动销SKU数'] += 1
|
||
if code in active_100:
|
||
row['近100天动销SKU数'] += 1
|
||
rows = sorted(agg.values(), key=lambda r: r['库存金额'], reverse=True)
|
||
summary = _inventory_value_summary(value_rows, latest, params)
|
||
summary.update(kind='brand_overview', row_count=len(rows))
|
||
return {
|
||
'columns': BRAND_FIELDS,
|
||
'rows': _apply_filters(rows, params),
|
||
'summary': summary,
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# P0 经营分析补充数据源:产品档案 / BOM供应链 / 订单明细 / 供应商履约
|
||
# ──────────────────────────────────────────────
|
||
def _h_product_profile(params):
|
||
"""产品主数据档案(不返回成本价,避免绕过金额字段权限)。"""
|
||
fields = ['product_code', '货品名称', '品牌', '产品类型', 'SKU类型', '生产货期', '物流时间',
|
||
'起订量', '供应商', '采购计划', '采购跟单', '运营', '开发', '创建时间']
|
||
rows = query("""
|
||
SELECT product_code, product_name AS 货品名称, COALESCE(brand, '') AS 品牌,
|
||
COALESCE(product_type, '') AS 产品类型, COALESCE(sku_type, '') AS SKU类型,
|
||
COALESCE(lead_time, 0) AS 生产货期, COALESCE(logistics_time, 0) AS 物流时间,
|
||
COALESCE(min_order_qty, 0) AS 起订量,
|
||
COALESCE(supplier_name_match, '') AS 供应商,
|
||
COALESCE(procurement_planner, '') AS 采购计划,
|
||
COALESCE(procurement_follower, '') AS 采购跟单,
|
||
COALESCE(operator, '') AS 运营, COALESCE(developer, '') AS 开发,
|
||
DATE_FORMAT(created_at, '%%Y-%%m-%%d') AS 创建时间
|
||
FROM products
|
||
WHERE deleted_at IS NULL
|
||
""")
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': fields, 'rows': rows}
|
||
|
||
|
||
def _h_bom_supply_chain(params):
|
||
"""成品-BOM-包材库存与在途联查,用于识别“成品能下单但包材不足”。"""
|
||
fields = ['product_code', '成品名称', '品牌', 'material_code', '物料名称', '单位用量',
|
||
'物料供应商', '物料库存', '加工厂库存', '生产在途', '采购在途',
|
||
'可支撑成品数量', '包材下单前数量', '包材下单后数量']
|
||
rows = query("""
|
||
SELECT b.product_code, MAX(b.product_name) AS 成品名称,
|
||
COALESCE(MAX(p.brand), '') AS 品牌,
|
||
b.material_code, MAX(b.material_name) AS 物料名称,
|
||
COALESCE(MAX(b.quantity), 0) AS 单位用量,
|
||
COALESCE(MAX(b.supplier), '') AS 物料供应商,
|
||
COALESCE(MAX(bp.pre_pack_qty), 0) AS 包材下单前数量,
|
||
COALESCE(MAX(bp.post_pack_qty), 0) AS 包材下单后数量
|
||
FROM bom b
|
||
LEFT JOIN products p ON p.product_code=b.product_code AND p.deleted_at IS NULL
|
||
LEFT JOIN bom_packaging bp ON bp.product_code=b.product_code AND bp.material_code=b.material_code
|
||
GROUP BY b.product_code, b.material_code
|
||
""")
|
||
inv = compute_product_inventory_cached() or {}
|
||
inv15 = compute_product_inventory_15() or {}
|
||
transit = compute_product_in_transit_cached() or {}
|
||
for r in rows:
|
||
code = r.get('material_code')
|
||
qty = float(r.get('单位用量') or 0)
|
||
t = transit.get(code) or {}
|
||
r['物料库存'] = int(inv.get(code, 0) or 0)
|
||
r['加工厂库存'] = int(inv15.get(code, 0) or 0)
|
||
r['生产在途'] = int(t.get('生产在途', 0) or 0)
|
||
r['采购在途'] = int(t.get('采购在途', 0) or 0)
|
||
available = r['物料库存'] + r['加工厂库存'] + r['生产在途'] + r['采购在途']
|
||
r['可支撑成品数量'] = int(available / qty) if qty > 0 else 0
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': fields, 'rows': rows}
|
||
|
||
|
||
def _h_order_detail(params):
|
||
"""国内/海外/包材订单统一明细,用于分析交付、未到货和延期。"""
|
||
fields = ['订单类型', '订单ID', 'product_code', '货品名称', '供应商', '计划下单时间',
|
||
'预计到货日期', '实际到货日期', '下单数量', '实际到货数量', '未到货数量',
|
||
'不良品数量', '状态', '进度', '采购计划', '下单人']
|
||
rows = []
|
||
for r in query("""
|
||
SELECT '国内订单' AS 订单类型, d.id AS 订单ID, d.货品编号 AS product_code,
|
||
d.货品名称 AS 货品名称, COALESCE(d.供货商, '') AS 供应商,
|
||
d.计划下单时间, d.预计到货日期, d.实际到货日期,
|
||
COALESCE(d.数量, 0) AS 下单数量, COALESCE(d.实际到货数量, 0) AS 实际到货数量,
|
||
COALESCE(d.未到货数, COALESCE(d.数量,0)-COALESCE(d.实际到货数量,0)) AS 未到货数量,
|
||
COALESCE((SELECT SUM(a.defective_qty) FROM arrival_batches a
|
||
WHERE a.order_type='domestic' AND a.order_id=d.id), 0) AS 不良品数量,
|
||
COALESCE(d.状态, '') AS 状态, COALESCE(d.进度, '') AS 进度,
|
||
COALESCE(d.procurement_planner, '') AS 采购计划, COALESCE(d.下单人, '') AS 下单人
|
||
FROM domestic_orders d
|
||
"""):
|
||
rows.append(r)
|
||
for r in query("""
|
||
SELECT '海外订单' AS 订单类型, o.id AS 订单ID, o.产品编码 AS product_code,
|
||
COALESCE(o.货品名称, o.product_name, '') AS 货品名称, COALESCE(o.供应商, '') AS 供应商,
|
||
o.计划下单时间, o.预计到货日期, o.实际到货日期,
|
||
COALESCE(o.数量, 0) AS 下单数量, COALESCE(o.实际到货数量, 0) AS 实际到货数量,
|
||
COALESCE(o.未到货数, COALESCE(o.数量,0)-COALESCE(o.实际到货数量,0)) AS 未到货数量,
|
||
COALESCE((SELECT SUM(a.defective_qty) FROM arrival_batches a
|
||
WHERE a.order_type='overseas' AND a.order_id=o.id), 0) AS 不良品数量,
|
||
COALESCE(o.状态, '') AS 状态, COALESCE(o.进度, '') AS 进度,
|
||
COALESCE(o.procurement_planner, '') AS 采购计划, COALESCE(o.下单人, '') AS 下单人
|
||
FROM overseas_orders o
|
||
"""):
|
||
rows.append(r)
|
||
for r in query("""
|
||
SELECT '包材订单' AS 订单类型, p.id AS 订单ID,
|
||
COALESCE(NULLIF(p.来源产品编码,''), p.编码) AS product_code,
|
||
p.名称 AS 货品名称, COALESCE(p.供应商, '') AS 供应商,
|
||
p.计划下单时间, '' AS 预计到货日期, p.到货日期 AS 实际到货日期,
|
||
COALESCE(p.数量, 0) AS 下单数量, COALESCE(p.实际到货数量, 0) AS 实际到货数量,
|
||
COALESCE(p.未到货数, COALESCE(p.数量,0)-COALESCE(p.实际到货数量,0)) AS 未到货数量,
|
||
COALESCE((SELECT SUM(a.defective_qty) FROM arrival_batches a
|
||
WHERE a.order_type='packaging' AND a.order_id=p.id), 0) AS 不良品数量,
|
||
COALESCE(p.状态, '') AS 状态, COALESCE(p.进度, '') AS 进度,
|
||
COALESCE(p.采购计划, '') AS 采购计划, COALESCE(p.下单人, p.采购下单人, '') AS 下单人
|
||
FROM packaging_orders p
|
||
"""):
|
||
rows.append(r)
|
||
|
||
code = (params.get('product_code') or '').strip().lower()
|
||
if code:
|
||
rows = [r for r in rows if code in str(r.get('product_code') or '').lower()
|
||
or code in str(r.get('货品名称') or '').lower()]
|
||
supplier = (params.get('supplier') or '').strip().lower()
|
||
if supplier:
|
||
rows = [r for r in rows if supplier in str(r.get('供应商') or '').lower()]
|
||
status = (params.get('status') or '').strip().lower()
|
||
if status:
|
||
rows = [r for r in rows if status in str(r.get('状态') or '').lower()]
|
||
if not params.get('_no_limit'):
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': fields, 'rows': rows}
|
||
|
||
|
||
def _h_supplier_delivery(params):
|
||
"""按供应商汇总订单交付表现,作为P0供应商风险分析的确定性数据底座。"""
|
||
fields = ['供应商', '订单数', '下单数量', '有效到货数量', '未到货数量', '不良品数量',
|
||
'未完结订单数', '到货完成率', '不良品率']
|
||
detail = _h_order_detail({'_no_limit': True}).get('rows') or []
|
||
supplier_alias = {}
|
||
for s in query("SELECT supplier_name, short_name, contract_short_name FROM suppliers"):
|
||
full = (s.get('supplier_name') or '').strip()
|
||
if not full:
|
||
continue
|
||
for alias in (full, s.get('short_name'), s.get('contract_short_name')):
|
||
alias = (alias or '').strip()
|
||
if alias:
|
||
supplier_alias[alias] = full
|
||
agg = {}
|
||
for r in detail:
|
||
raw_supplier = (r.get('供应商') or '(未填写供应商)').strip()
|
||
supplier = supplier_alias.get(raw_supplier, raw_supplier)
|
||
row = agg.setdefault(supplier, {'供应商': supplier, '订单数': 0, '下单数量': 0,
|
||
'有效到货数量': 0, '未到货数量': 0, '不良品数量': 0,
|
||
'未完结订单数': 0})
|
||
ordered = int(r.get('下单数量') or 0)
|
||
arrived = int(r.get('实际到货数量') or 0)
|
||
defective = int(r.get('不良品数量') or 0)
|
||
outstanding = max(0, int(r.get('未到货数量') or 0))
|
||
row['订单数'] += 1
|
||
row['下单数量'] += ordered
|
||
row['有效到货数量'] += max(0, arrived - defective)
|
||
row['未到货数量'] += outstanding
|
||
row['不良品数量'] += defective
|
||
if str(r.get('状态') or '') not in ('已完结', '已完成'):
|
||
row['未完结订单数'] += 1
|
||
rows = []
|
||
for row in agg.values():
|
||
ordered = row['下单数量']
|
||
arrived = row['有效到货数量']
|
||
row['到货完成率'] = round(arrived * 100.0 / ordered, 2) if ordered else 0
|
||
row['不良品率'] = round(row['不良品数量'] * 100.0 / max(arrived + row['不良品数量'], 1), 2)
|
||
rows.append(row)
|
||
supplier = (params.get('supplier') or '').strip().lower()
|
||
if supplier:
|
||
rows = [r for r in rows if supplier in str(r.get('供应商') or '').lower()]
|
||
rows = _apply_filters(rows, params)
|
||
return {'columns': fields, 'rows': rows}
|
||
|
||
def _latest_outbound_date():
|
||
"""最新出库日期(销量/出库类数据源的核对日期)"""
|
||
rows = query("SELECT MAX(日期) AS d FROM daily_outbound")
|
||
d = rows[0]['d'] if rows else None
|
||
if hasattr(d, 'strftime'):
|
||
return d.strftime('%Y-%m-%d')
|
||
return str(d)[:10] if d else ''
|
||
|
||
|
||
def datasource_meta(key):
|
||
"""数据源展示信息:名称 + 数据日期(供前端标明口径)"""
|
||
ds = AI_DATASOURCES.get(key) or {}
|
||
kind = ds.get('date_kind') or ''
|
||
date = ''
|
||
if kind == 'inventory':
|
||
date = _get_latest_inv_date()
|
||
elif kind == 'outbound':
|
||
date = _latest_outbound_date()
|
||
return {'key': key, 'name': ds.get('name') or key, 'data_date': date, 'date_kind': kind}
|
||
|
||
|
||
def suggest_datasources(question, limit=3):
|
||
"""按关键词匹配推荐最接近的数据源(AI 选不到数据源时给用户兜底建议)"""
|
||
q = (question or '').lower()
|
||
scored = []
|
||
for idx, (key, ds) in enumerate(AI_DATASOURCES.items()):
|
||
score = 0
|
||
for word in ds.get('keywords') or []:
|
||
w = str(word).lower()
|
||
if w and w in q:
|
||
score += len(w)
|
||
name = str(ds.get('name') or '')
|
||
for token in [name, key]:
|
||
if token and token.lower() in q:
|
||
score += len(token)
|
||
if score > 0:
|
||
scored.append((score, -idx, key, ds.get('name') or key))
|
||
scored.sort(reverse=True)
|
||
return [{'key': k, 'name': n} for _, _, k, n in scored[:limit]]
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 数据源注册表(AI 可见)
|
||
# ──────────────────────────────────────────────
|
||
AI_DATASOURCES = {
|
||
'inventory_cost_summary': {
|
||
'name': '库存成本汇总',
|
||
'desc': '当前库存总成本(按系统数据概览口径:含特殊成本价覆盖、排除内料、成品/包材分类、在途成本)。可回答:库存总额/总成本/库存金额是多少、成品与包材成本各多少。',
|
||
'fields': ['核对日期', '库存总成本(元)', '成品成本(元)', '包材成本(元)', '成品库存(元)', '成品生产在途(元)', '成品采购在途(元)', '包材库存(元)', '包材采购在途(元)'],
|
||
'handler': _h_inventory_cost_summary,
|
||
'permission_pages': ['data_summary'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['库存总额', '总成本', '库存金额合计', '库存总值', '多少钱', '库存价值'],
|
||
'perm_key': '库存成本汇总',
|
||
},
|
||
'product_inventory': {
|
||
'name': '产品库存视图',
|
||
'desc': '每个产品的最新库存(可用库存、云仓、外仓、加工厂、生产在途、采购在途)。可回答:某产品还有多少库存、库存最多的SKU排行、云仓/外仓库存等。',
|
||
'fields': ['product_code', '库存', '云仓库存', '外仓库存', '加工厂库存', '生产在途', '采购在途'],
|
||
'handler': _h_product_inventory,
|
||
'permission_pages': ['inventory_finished'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['库存最多', '库存排行', '还有多少库存', '云仓库存', '外仓库存', '加工厂库存', '在途库存'],
|
||
},
|
||
'product_sales': {
|
||
'name': '产品销量统计',
|
||
'desc': '每个产品的销量(昨日销量、3/7/14/30/60天平均销量、各周期最高销量)。可回答:销量最大的SKU、某产品日均销量、昨日销量排行等。',
|
||
'fields': ['product_code', '昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', '14天最高销量', '30天最高销量'],
|
||
'handler': _h_product_sales,
|
||
'permission_pages': ['data_summary'],
|
||
'date_kind': 'outbound',
|
||
'keywords': ['销量', '日均销量', '昨日销量', '销量排行', '平均销量'],
|
||
},
|
||
'outbound_stats': {
|
||
'name': '出库统计排行',
|
||
'desc': '出库量排行(kind=brand 按品牌 / kind=shop 按店铺 / 默认按单品TOP)。可回答:出库最多的品牌/店铺/SKU 是哪些。',
|
||
'fields': ['kind(品牌=brand/店铺=shop/单品=product)', 'limit'],
|
||
'handler': _h_outbound_stats,
|
||
'permission_pages': ['data_summary'],
|
||
'date_kind': 'outbound',
|
||
'keywords': ['出库排行', '出库量', 'TOP', '品牌出库', '店铺出库', '出库最多'],
|
||
},
|
||
'outbound_daily': {
|
||
'name': '按日出库统计',
|
||
'desc': '按日期统计出库(最近 N 天,参数 days)。可回答:最近几天每天出库多少。',
|
||
'fields': ['日期', '记录数', '总数量', '总成本'],
|
||
'handler': _h_outbound_daily,
|
||
'permission_pages': ['data_summary'],
|
||
'date_kind': 'outbound',
|
||
'keywords': ['每天出库', '按天出库', '出库趋势', '最近几天', '日出库'],
|
||
},
|
||
'planning_metrics': {
|
||
'name': '计划预测口径',
|
||
'desc': '计划预测页每个商品的计算结果(与页面同一套计算):日销量预测、总/云仓/外仓支撑天数、库存可支撑天数、云仓与外仓可分配库存、是否调拨、建议下单数量、产品分级、销售趋势、各仓库存与在途。可回答:哪些商品需要调拨、建议下单数量最多的是谁、支撑天数最短的SKU、某商品的日销量预测等。',
|
||
'fields': PLANNING_FIELDS,
|
||
'handler': _h_planning_metrics,
|
||
'permission_pages': ['planning'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['计划预测', '支撑天数', '建议下单', '调拨', '产品分级', '销售趋势', '日销量预测', '可分配库存'],
|
||
},
|
||
'orders_in_transit': {
|
||
'name': '订单在途与到货',
|
||
'desc': '未完结订单的未到货数量(国内/海外/包材三类,按商品汇总;包材按所属成品归集)。可回答:某商品还有多少货没到、谁的在途最多、哪个商品有未完结订单。',
|
||
'fields': ['product_code', '品牌', '货品名称', '国内未到数量', '海外未到数量', '包材未到数量',
|
||
'在途未到合计', '国内未完结订单数', '海外未完结订单数', '包材未完结订单数'],
|
||
'handler': _h_orders_in_transit,
|
||
'permission_pages': ['planning'],
|
||
'date_kind': '',
|
||
'keywords': ['在途', '未到货', '到货', '未完结订单', '订单'],
|
||
},
|
||
'material_inventory': {
|
||
'name': '包材与内料库存',
|
||
'desc': '包材、内料物料的库存(可用/云仓/外仓/加工厂/生产在途/采购在途)。产品库存视图已把这些物料排除,问包材/内料库存时用本数据源。可回答:某包材(瓶子/彩盒/泵头)还有多少库存、内料库存多少等。',
|
||
'fields': ['product_code', '名称', '类型', '品牌', '可用库存', '云仓库存', '外仓库存',
|
||
'加工厂库存', '生产在途', '采购在途'],
|
||
'handler': _h_material_inventory,
|
||
'permission_pages': ['inventory_packaging'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['包材', '内料', '瓶子', '彩盒', '泵头', '标贴', '收缩膜'],
|
||
},
|
||
'inventory_value': {
|
||
'name': '商品库存金额',
|
||
'desc': '逐商品的库存金额(库存 × 成本价,含特殊成本价规则,排除已删除产品、内料与包材)。summary提供独立于limit和排序的全量SKU数、库存数量及金额,含零库存商品。可回答:某品牌库存总金额、库存金额最高的商品、某商品库存值多少钱。注意:含包材及在途的全库合计请用「库存成本汇总」数据源。',
|
||
'fields': VALUE_FIELDS,
|
||
'handler': _h_inventory_value,
|
||
'permission_pages': ['inventory_finished'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['库存金额', '金额排行', '库存值', '成本价', '库存金额最高'],
|
||
'perm_key': '商品库存金额',
|
||
},
|
||
'product_profile': {
|
||
'name': '产品基础档案',
|
||
'desc': '产品编码、名称、品牌、产品类型、货期、物流时间、供应商和负责人等基础信息,不返回成本价。可用于产品属性、供应商、货期和责任人分析。',
|
||
'fields': ['product_code', '货品名称', '品牌', '产品类型', '供应商', '生产货期', '物流时间', '采购计划', '采购跟单', '运营', '开发', '创建时间'],
|
||
'handler': _h_product_profile,
|
||
'permission_pages': ['products'],
|
||
'date_kind': '',
|
||
'keywords': ['产品信息', '产品档案', '货期', '物流时间', '供应商', '负责人', '创建时间'],
|
||
},
|
||
'bom_supply_chain': {
|
||
'name': 'BOM物料供应链',
|
||
'desc': '成品与BOM物料、包材库存、在途及可支撑成品数量的联查数据。可识别成品下单后包材是否不足。',
|
||
'fields': ['product_code', '成品名称', '品牌', 'material_code', '物料名称', '单位用量', '物料供应商', '物料库存', '加工厂库存', '生产在途', '采购在途', '可支撑成品数量'],
|
||
'handler': _h_bom_supply_chain,
|
||
'permission_pages': ['bom', 'inventory_packaging'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['BOM', '物料清单', '包材缺口', '物料不足', '包材支撑', '成品包材'],
|
||
},
|
||
'order_detail': {
|
||
'name': '订单明细与交付',
|
||
'desc': '国内、海外、包材订单统一明细,包含下单、到货、不良品、未到货、供应商和订单状态。',
|
||
'fields': ['订单类型', '订单ID', 'product_code', '货品名称', '供应商', '计划下单时间', '预计到货日期', '实际到货日期', '下单数量', '实际到货数量', '未到货数量', '不良品数量', '状态', '进度'],
|
||
'handler': _h_order_detail,
|
||
'permission_pages': ['order_domestic', 'order_overseas', 'order_packaging'],
|
||
'date_kind': '',
|
||
'keywords': ['订单明细', '订单交付', '不良品', '延期', '订单状态', '到货完成率'],
|
||
},
|
||
'supplier_delivery': {
|
||
'name': '供应商交付表现',
|
||
'desc': '按供应商汇总订单数、下单数量、有效到货、不良品、未到货和未完结订单,可用于供应商交付风险排行。',
|
||
'fields': ['供应商', '订单数', '下单数量', '有效到货数量', '未到货数量', '不良品数量', '未完结订单数', '到货完成率', '不良品率'],
|
||
'handler': _h_supplier_delivery,
|
||
'permission_pages': ['suppliers', 'order_domestic', 'order_overseas', 'order_packaging'],
|
||
'date_kind': '',
|
||
'keywords': ['供应商表现', '供应商交付', '供应商排名', '交付率', '不良品率', '供应商风险'],
|
||
},
|
||
'brand_overview': {
|
||
'name': '品牌汇总',
|
||
'desc': '按品牌汇总的 SKU数、库存数量、库存金额、昨日销量、30天平均销量与近30/100天动销SKU数(默认按库存金额倒序)。可回答:哪个品牌库存金额最高、某品牌有多少个动销SKU、各品牌销量对比。',
|
||
'fields': BRAND_FIELDS,
|
||
'handler': _h_brand_overview,
|
||
'permission_pages': ['data_summary'],
|
||
'date_kind': 'inventory',
|
||
'keywords': ['品牌', '动销', '品牌汇总', '品牌对比', '品牌库存'],
|
||
'perm_key': '品牌汇总',
|
||
},
|
||
}
|
||
|
||
# AI 可用的数据源清单文本(注入提示词)
|
||
AI_DATASOURCES_DESC = "\n".join(
|
||
f"- {key}({ds['name']}):{ds['desc']}"
|
||
for key, ds in AI_DATASOURCES.items()
|
||
)
|