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

340 lines
12 KiB
Python
Raw 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.

"""销量计算相关函数"""
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()