340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""销量计算相关函数"""
|
||
import math
|
||
from datetime import datetime, timedelta
|
||
from services.db import query
|
||
from services.cache import RefreshableCache, register_cache
|
||
|
||
|
||
def _build_daily_sales_data(rows):
|
||
"""从SQL查询结果构建 {product_code: {date: qty}} 数据结构"""
|
||
daily = {}
|
||
for r in rows:
|
||
pc = r['货品编号']
|
||
d = r['日期'].strftime('%Y-%m-%d') if hasattr(r['日期'], 'strftime') else str(r['日期'])[:10]
|
||
qty = int(r['日销量']) if r['日销量'] else 0
|
||
if pc not in daily:
|
||
daily[pc] = {}
|
||
daily[pc][d] = qty
|
||
return daily
|
||
|
||
|
||
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)
|
||
|
||
|
||
def _compute_product_sales():
|
||
"""计算每个产品的昨日销量、多天平均/最高/最低/标准差"""
|
||
# 获取最新日期
|
||
latest = query("SELECT MAX(日期) AS d FROM daily_outbound")[0]['d']
|
||
if hasattr(latest, 'strftime'):
|
||
latest_str = latest.strftime('%Y-%m-%d')
|
||
else:
|
||
latest_str = str(latest)[:10]
|
||
|
||
latest_dt = datetime.strptime(latest_str, '%Y-%m-%d')
|
||
|
||
# 计算各时间范围(扩展到60天)
|
||
dates_3 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(3)]
|
||
dates_7 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
|
||
dates_14 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(14)]
|
||
dates_30 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(30)]
|
||
dates_60 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(60)]
|
||
|
||
# 查询所有产品的每日销量(60天范围)
|
||
rows = query("""
|
||
SELECT 货品编号, 日期, SUM(货品数量) AS 日销量
|
||
FROM daily_outbound
|
||
WHERE 日期 >= %s
|
||
GROUP BY 货品编号, 日期
|
||
""", (dates_60[-1],))
|
||
|
||
# 构建数据结构
|
||
daily = _build_daily_sales_data(rows)
|
||
|
||
result = {}
|
||
for pc, dmap in daily.items():
|
||
yesterday_qty = dmap.get(latest_str, 0)
|
||
|
||
# 各范围数值
|
||
qty_3 = [dmap.get(d, 0) for d in dates_3]
|
||
qty_7 = [dmap.get(d, 0) for d in dates_7]
|
||
qty_14 = [dmap.get(d, 0) for d in dates_14]
|
||
qty_30 = [dmap.get(d, 0) for d in dates_30]
|
||
qty_60 = [dmap.get(d, 0) for d in dates_60]
|
||
|
||
# 平均值
|
||
avg_3 = int(round(sum(qty_3) / 3))
|
||
avg_7 = int(round(sum(qty_7) / 7))
|
||
avg_14 = int(round(sum(qty_14) / 14))
|
||
avg_30 = int(round(sum(qty_30) / 30))
|
||
avg_60 = int(round(sum(qty_60) / 60))
|
||
|
||
# 最高值
|
||
max_3 = max(qty_3)
|
||
max_7 = max(qty_7)
|
||
max_14 = max(qty_14)
|
||
max_30 = max(qty_30)
|
||
|
||
# 最低值
|
||
min_14 = min(qty_14)
|
||
|
||
# 14天标准差
|
||
std_14 = int(round(_calc_stddev(qty_14)))
|
||
|
||
# 偏差率计算(防止除零)
|
||
base = avg_3 if avg_3 != 0 else 1
|
||
最低销量偏差率 = round((min_14 - avg_3) / base, 4)
|
||
最高销量偏差率 = round((max_14 - avg_3) / base, 4)
|
||
|
||
# 极值判断
|
||
极小值 = 0.7
|
||
极大值 = 1
|
||
if 最低销量偏差率 <= 极小值 and 最高销量偏差率 >= 极大值:
|
||
极值判断 = '极大/极小'
|
||
elif 最低销量偏差率 < 极小值:
|
||
极值判断 = '极小'
|
||
elif 最高销量偏差率 > 极大值:
|
||
极值判断 = '极大'
|
||
else:
|
||
极值判断 = '正常'
|
||
|
||
# 14天波动率与波动风险判断
|
||
avg_base = avg_14 if avg_14 != 0 else 1
|
||
波动率 = round(std_14 / avg_base, 4)
|
||
if 波动率 <= 0.5:
|
||
波动风险 = 'X'
|
||
elif 波动率 <= 1:
|
||
波动风险 = 'Y'
|
||
else:
|
||
波动风险 = 'Z'
|
||
|
||
result[pc] = {
|
||
'昨日销量': yesterday_qty,
|
||
'3天平均销量': avg_3,
|
||
'7天平均销量': avg_7,
|
||
'14天平均销量': avg_14,
|
||
'30天平均销量': avg_30,
|
||
'60天平均销量': avg_60,
|
||
'3天最高销量': max_3,
|
||
'7天最高销量': max_7,
|
||
'14天最高销量': max_14,
|
||
'30天最高销量': max_30,
|
||
'14天最低销量': min_14,
|
||
'14天标准差': std_14,
|
||
'最低销量偏差率': 最低销量偏差率,
|
||
'最高销量偏差率': 最高销量偏差率,
|
||
'极值判断': 极值判断,
|
||
'14天波动率': 波动率,
|
||
'波动风险': 波动风险,
|
||
}
|
||
|
||
return result
|
||
|
||
|
||
def _get_warehouse_names_by_property(property_keyword):
|
||
"""获取指定仓库属性标签下的所有仓库名称列表"""
|
||
rows = query(
|
||
"SELECT warehouse_name FROM warehouses WHERE FIND_IN_SET(%s, warehouse_property) > 0",
|
||
(property_keyword,)
|
||
)
|
||
return [r['warehouse_name'] for r in rows if r['warehouse_name']]
|
||
|
||
|
||
def _compute_product_sales_cloud_yesterday():
|
||
"""计算每个产品的云仓昨日销量(仅WDT来源,仓库属性含云仓)"""
|
||
latest = query("SELECT MAX(日期) AS d FROM daily_outbound")[0]['d']
|
||
if hasattr(latest, 'strftime'):
|
||
latest_str = latest.strftime('%Y-%m-%d')
|
||
else:
|
||
latest_str = str(latest)[:10]
|
||
|
||
cloud_wh_names = _get_warehouse_names_by_property('云仓')
|
||
if not cloud_wh_names:
|
||
return {}
|
||
|
||
# 清洗仓库名中的Excel公式格式(如="1蝶雅广州新仓(蝶雅自营)" → 1蝶雅广州新仓(蝶雅自营))
|
||
rows = query(f"""
|
||
SELECT 货品编号, SUM(货品数量) AS total_qty
|
||
FROM daily_outbound
|
||
WHERE 数据来源 = 'wdt'
|
||
AND 日期 = %s
|
||
AND REPLACE(REPLACE(仓库, '="', ''), '"', '') IN ({','.join(['%s']*len(cloud_wh_names))})
|
||
GROUP BY 货品编号
|
||
""", (latest_str,) + tuple(cloud_wh_names))
|
||
|
||
result = {}
|
||
for r in rows:
|
||
result[r['货品编号']] = int(r['total_qty']) if r['total_qty'] else 0
|
||
return result
|
||
|
||
|
||
def _compute_product_sales_external_yesterday():
|
||
"""计算每个产品的外仓昨日销量:
|
||
- WDT来源且仓库属性含外仓
|
||
- tmgj/jdjwt/jdgj/jdbz所有来源(全部算外仓)
|
||
"""
|
||
latest = query("SELECT MAX(日期) AS d FROM daily_outbound")[0]['d']
|
||
if hasattr(latest, 'strftime'):
|
||
latest_str = latest.strftime('%Y-%m-%d')
|
||
else:
|
||
latest_str = str(latest)[:10]
|
||
|
||
ext_wh_names = _get_warehouse_names_by_property('外仓')
|
||
ext_wh_ph = ','.join(['%s'] * len(ext_wh_names)) if ext_wh_names else ''
|
||
|
||
# 外仓条件:平台来源全部算外仓 + WDT外仓(仓库名含Excel公式格式需清洗)
|
||
where_parts = ["数据来源 IN ('tmgj','jdjwt','jdgj','jdbz','dymuto','dymtuo','jdsmj')"]
|
||
wdt_params = []
|
||
if ext_wh_names:
|
||
clean_sql = "REPLACE(REPLACE(仓库, '=\"', ''), '\"', '')"
|
||
where_parts.append(f"(数据来源 = 'wdt' AND {clean_sql} IN ({ext_wh_ph}))")
|
||
wdt_params = list(ext_wh_names)
|
||
|
||
where_sql = '(' + ' OR '.join(where_parts) + ')'
|
||
params = [latest_str] + wdt_params
|
||
|
||
rows = query(f"""
|
||
SELECT 货品编号, SUM(货品数量) AS total_qty
|
||
FROM daily_outbound
|
||
WHERE 日期 = %s AND {where_sql}
|
||
GROUP BY 货品编号
|
||
""", params)
|
||
|
||
result = {}
|
||
for r in rows:
|
||
result[r['货品编号']] = int(r['total_qty']) if r['total_qty'] else 0
|
||
return result
|
||
|
||
|
||
# 可刷新的缓存实例
|
||
sales_cache = RefreshableCache(maxsize=1)
|
||
register_cache(sales_cache)
|
||
|
||
@sales_cache.wrap
|
||
def compute_product_sales_cached():
|
||
"""缓存版销量计算(支持主动刷新)"""
|
||
return _compute_product_sales()
|
||
|
||
|
||
# 云仓/外仓昨日销量缓存
|
||
cloud_sales_cache = RefreshableCache(maxsize=1)
|
||
register_cache(cloud_sales_cache)
|
||
ext_sales_cache = RefreshableCache(maxsize=1)
|
||
register_cache(ext_sales_cache)
|
||
|
||
@cloud_sales_cache.wrap
|
||
def compute_product_sales_cloud_yesterday_cached():
|
||
"""缓存版云仓昨日销量"""
|
||
return _compute_product_sales_cloud_yesterday()
|
||
|
||
@ext_sales_cache.wrap
|
||
def compute_product_sales_external_yesterday_cached():
|
||
"""缓存版外仓昨日销量"""
|
||
return _compute_product_sales_external_yesterday()
|
||
|
||
|
||
def _get_wh_names_by_property(prop):
|
||
"""获取指定属性的仓库名称列表(含清洗)"""
|
||
return _get_warehouse_names_by_property(prop)
|
||
|
||
|
||
def _compute_product_sales_extended():
|
||
"""计算总销量 + 云仓销量 + 外仓销量的完整数据结构(含3/7/14天平均等)"""
|
||
latest = query("SELECT MAX(日期) AS d FROM daily_outbound")[0]['d']
|
||
if hasattr(latest, 'strftime'):
|
||
latest_str = latest.strftime('%Y-%m-%d')
|
||
else:
|
||
latest_str = str(latest)[:10]
|
||
latest_dt = datetime.strptime(latest_str, '%Y-%m-%d')
|
||
|
||
# 云仓/外仓仓库名
|
||
cloud_wh = _get_warehouse_names_by_property('云仓')
|
||
ext_wh = _get_warehouse_names_by_property('外仓')
|
||
|
||
dates_3 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(3)]
|
||
dates_7 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
|
||
dates_14 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(14)]
|
||
dates_30 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(30)]
|
||
dates_60 = [(latest_dt - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(60)]
|
||
|
||
# 一次查询所有数据,带来源和仓库信息
|
||
rows = query("""
|
||
SELECT 货品编号, 日期, 数据来源, REPLACE(REPLACE(仓库, '="', ''), '"', '') AS 仓库名, 货品数量
|
||
FROM daily_outbound
|
||
WHERE 日期 >= %s
|
||
""", (dates_60[-1],))
|
||
|
||
# 构建三套数据结构
|
||
from collections import defaultdict
|
||
total_daily = defaultdict(lambda: defaultdict(int))
|
||
cloud_daily = defaultdict(lambda: defaultdict(int))
|
||
ext_daily = defaultdict(lambda: defaultdict(int))
|
||
|
||
cloud_wh_set = set(cloud_wh)
|
||
ext_wh_set = set(ext_wh)
|
||
ext_sources = {'tmgj', 'jdjwt', 'jdgj', 'jdbz', 'dymuto', 'dymtuo', 'jdsmj'}
|
||
|
||
for r in rows:
|
||
pc = r['货品编号']
|
||
d = r['日期'].strftime('%Y-%m-%d') if hasattr(r['日期'], 'strftime') else str(r['日期'])[:10]
|
||
qty = int(r['货品数量']) if r['货品数量'] else 0
|
||
src = r['数据来源']
|
||
wh = r['仓库名'] or ''
|
||
|
||
# 总销量
|
||
total_daily[pc][d] += qty
|
||
|
||
# 云仓销量:WDT + 云仓仓库
|
||
if src == 'wdt' and wh in cloud_wh_set:
|
||
cloud_daily[pc][d] += qty
|
||
|
||
# 外仓销量:WDT+外仓仓库 或 4个外仓来源
|
||
if (src == 'wdt' and wh in ext_wh_set) or src in ext_sources:
|
||
ext_daily[pc][d] += qty
|
||
|
||
def _compute_from_daily(daily_map):
|
||
"""从daily map计算销量统计"""
|
||
result = {}
|
||
for pc, dmap in daily_map.items():
|
||
qty_3 = [dmap.get(d, 0) for d in dates_3]
|
||
qty_7 = [dmap.get(d, 0) for d in dates_7]
|
||
qty_14 = [dmap.get(d, 0) for d in dates_14]
|
||
qty_30 = [dmap.get(d, 0) for d in dates_30]
|
||
qty_60 = [dmap.get(d, 0) for d in dates_60]
|
||
|
||
result[pc] = {
|
||
'昨日销量': dmap.get(latest_str, 0),
|
||
'3天平均销量': int(round(sum(qty_3)/3)),
|
||
'7天平均销量': int(round(sum(qty_7)/7)),
|
||
'14天平均销量': int(round(sum(qty_14)/14)),
|
||
'30天平均销量': int(round(sum(qty_30)/30)),
|
||
'60天平均销量': int(round(sum(qty_60)/60)),
|
||
'3天最高销量': max(qty_3),
|
||
'7天最高销量': max(qty_7),
|
||
'14天最高销量': max(qty_14),
|
||
'30天最高销量': max(qty_30),
|
||
}
|
||
return result
|
||
|
||
return {
|
||
'total': _compute_from_daily(total_daily),
|
||
'cloud': _compute_from_daily(cloud_daily),
|
||
'external': _compute_from_daily(ext_daily),
|
||
}
|
||
|
||
|
||
# 总+云仓+外仓完整销量缓存
|
||
extended_sales_cache = RefreshableCache(maxsize=1)
|
||
register_cache(extended_sales_cache)
|
||
|
||
@extended_sales_cache.wrap
|
||
def compute_product_sales_extended_cached():
|
||
"""缓存版总销量+云仓+外仓完整数据结构"""
|
||
return _compute_product_sales_extended()
|