1381 lines
73 KiB
Python
1381 lines
73 KiB
Python
"""仪表盘路由"""
|
||
import copy
|
||
import json
|
||
from datetime import datetime, timedelta
|
||
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for, send_file
|
||
from services.db import query
|
||
from routes.auth import login_required, role_required, operation_required
|
||
from services.simple_cache import get as cache_get, set as cache_set, delete as cache_del
|
||
from services.inventory_calc import (
|
||
active_warehouse_condition,
|
||
compute_inventory_cost_summary,
|
||
resolve_cost_price,
|
||
)
|
||
from services.field_perms import filter_rows as _filter_field_rows
|
||
from services.audit_schema import ensure_audit_schema
|
||
import config as app_config
|
||
|
||
dashboard_bp = Blueprint('dashboard', __name__)
|
||
|
||
|
||
def _ensure_inventory_board_external_schema(month=None):
|
||
"""为旧版全局外部指标表补充月份维度,旧数据归入当前所选月份。"""
|
||
table_exists = query("SELECT COUNT(*) AS cnt FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=%s", ('inventory_board_external',))[0]['cnt']
|
||
if not table_exists:
|
||
query("""CREATE TABLE IF NOT EXISTS inventory_board_external (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
metric_key VARCHAR(64) NOT NULL,
|
||
metric_value DECIMAL(20,4) DEFAULT NULL,
|
||
updated_by VARCHAR(100) DEFAULT '',
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY metric_key (metric_key)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
|
||
columns = query("""SELECT COLUMN_NAME AS Field FROM information_schema.columns
|
||
WHERE table_schema=DATABASE() AND table_name='inventory_board_external'""")
|
||
|
||
if any(row.get('Field') == 'month_key' for row in columns):
|
||
return
|
||
target_month = month or datetime.now().strftime('%Y-%m')
|
||
query("ALTER TABLE inventory_board_external ADD COLUMN month_key CHAR(7) NULL AFTER id")
|
||
query("UPDATE inventory_board_external SET month_key=%s WHERE month_key IS NULL", (target_month,))
|
||
query("ALTER TABLE inventory_board_external MODIFY COLUMN month_key CHAR(7) NOT NULL")
|
||
query("ALTER TABLE inventory_board_external DROP INDEX metric_key")
|
||
query("ALTER TABLE inventory_board_external ADD UNIQUE KEY uk_month_metric (month_key, metric_key)")
|
||
|
||
|
||
@dashboard_bp.route('/settings')
|
||
@login_required
|
||
def settings_page():
|
||
return render_template('base.html', page='settings')
|
||
|
||
|
||
@dashboard_bp.route('/favicon.ico')
|
||
def favicon():
|
||
from flask import redirect as _rd
|
||
return _rd(url_for('static', filename='logo-small.png'))
|
||
|
||
|
||
@dashboard_bp.route('/config')
|
||
@login_required
|
||
def config_page():
|
||
if not role_required('config'):
|
||
return redirect(url_for('auth.index'))
|
||
return render_template('base.html', page='config')
|
||
|
||
|
||
@dashboard_bp.route('/logs')
|
||
@login_required
|
||
def logs_page():
|
||
if not role_required('logs'):
|
||
return redirect(url_for('auth.index'))
|
||
return render_template('base.html', page='logs')
|
||
|
||
|
||
_HIGH_RISK_LOG_ACTIONS = (
|
||
'权限变更', '产品彻底删除', '删除订单', '下单回退', '回退订单',
|
||
'修改成本价', '价格审核', '批量删除', '数据修复', '批量数据修复',
|
||
)
|
||
_MEDIUM_RISK_LOG_ACTIONS = (
|
||
'产品批量编辑', '产品导入', '修改下单数量', '订单字段编辑',
|
||
'到货批次及数量', '品牌分类', '新增供应商',
|
||
)
|
||
|
||
|
||
def _log_risk_case(alias='l'):
|
||
high = ','.join(['%s'] * len(_HIGH_RISK_LOG_ACTIONS))
|
||
medium = ','.join(['%s'] * len(_MEDIUM_RISK_LOG_ACTIONS))
|
||
sql = (f"COALESCE(NULLIF({alias}.risk_level,''),CASE WHEN {alias}.action IN ({high}) OR COALESCE({alias}.field,'') REGEXP '成本价|单价|权限|删除|回退' "
|
||
f"THEN 'high' WHEN {alias}.action IN ({medium}) THEN 'medium' ELSE 'normal' END)")
|
||
return sql, list(_HIGH_RISK_LOG_ACTIONS) + list(_MEDIUM_RISK_LOG_ACTIONS)
|
||
|
||
|
||
def _build_log_filters(args):
|
||
ensure_audit_schema()
|
||
where = ["l.action != '登录'"]
|
||
params = []
|
||
user = (args.get('user') or '').strip()
|
||
page_name = (args.get('page_name') or '').strip()
|
||
action = (args.get('action') or '').strip()
|
||
field = (args.get('field') or '').strip()
|
||
keyword = (args.get('keyword') or '').strip()
|
||
ip = (args.get('ip') or '').strip()
|
||
date_from = (args.get('date_from') or '').strip()
|
||
date_to = (args.get('date_to') or '').strip()
|
||
risk = (args.get('risk') or '').strip()
|
||
result = (args.get('result') or '').strip()
|
||
if user:
|
||
where.append("(u.real_name LIKE %s OR u.username LIKE %s)")
|
||
params.extend([f'%{user}%', f'%{user}%'])
|
||
if page_name:
|
||
where.append("COALESCE(NULLIF(l.page,''),l.action) LIKE %s")
|
||
params.append(f'%{page_name}%')
|
||
if action:
|
||
where.append("l.action LIKE %s")
|
||
params.append(f'%{action}%')
|
||
if field:
|
||
where.append("COALESCE(l.field,'') LIKE %s")
|
||
params.append(f'%{field}%')
|
||
if keyword:
|
||
where.append("COALESCE(l.detail,'') LIKE %s")
|
||
params.append(f'%{keyword}%')
|
||
if ip:
|
||
where.append("COALESCE(l.ip_address,'') LIKE %s")
|
||
params.append(f'%{ip}%')
|
||
if date_from:
|
||
where.append("l.created_at >= %s")
|
||
params.append(date_from + ' 00:00:00')
|
||
if date_to:
|
||
where.append("l.created_at < DATE_ADD(%s, INTERVAL 1 DAY)")
|
||
params.append(date_to)
|
||
if result in ('success','failed','denied'):
|
||
where.append('l.result_status=%s'); params.append(result)
|
||
if risk in ('high', 'medium', 'normal'):
|
||
risk_case, risk_params = _log_risk_case('l')
|
||
where.append(f"({risk_case})=%s")
|
||
params.extend(risk_params + [risk])
|
||
return ' AND '.join(where), params
|
||
|
||
|
||
@dashboard_bp.route('/api/logs/list')
|
||
@login_required
|
||
def logs_list():
|
||
page = max(request.args.get('page', 1, type=int), 1)
|
||
size = min(max(request.args.get('size', 30, type=int), 10), 100)
|
||
offset = (page - 1) * size
|
||
where_clause, params = _build_log_filters(request.args)
|
||
total = query(f"SELECT COUNT(*) AS cnt FROM dashboard_logs l LEFT JOIN dashboard_users u ON l.user_id=u.id WHERE {where_clause}", params)[0]['cnt']
|
||
risk_case, risk_params = _log_risk_case('l')
|
||
rows = query(f"""SELECT l.id,l.user_id,l.action,l.detail,l.ip_address,l.created_at,l.page,l.field,l.result_status,l.object_type,l.object_id,l.request_method,l.request_path,l.request_id,l.new_values,
|
||
u.real_name,u.username,u.role,u.position,p.position_name,
|
||
{risk_case} AS risk_level
|
||
FROM dashboard_logs l
|
||
LEFT JOIN dashboard_users u ON l.user_id=u.id
|
||
LEFT JOIN user_positions p ON BINARY p.position_code=BINARY u.position
|
||
WHERE {where_clause}
|
||
ORDER BY l.created_at DESC,l.id DESC LIMIT %s OFFSET %s""",
|
||
risk_params + params + [size, offset])
|
||
for row in rows:
|
||
if row.get('created_at') and hasattr(row['created_at'], 'strftime'):
|
||
row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||
return jsonify({'code': 0, 'data': {'total': total, 'page': page, 'size': size, 'rows': rows}})
|
||
|
||
|
||
@dashboard_bp.route('/api/logs/summary')
|
||
@login_required
|
||
def logs_summary():
|
||
risk_case, risk_params = _log_risk_case('l')
|
||
row = query(f"""SELECT
|
||
SUM(DATE(l.created_at)=CURDATE() AND l.action!='登录') AS today_count,
|
||
SUM(l.created_at>=DATE_SUB(CURDATE(),INTERVAL 6 DAY) AND l.action!='登录') AS seven_day_count,
|
||
SUM(({risk_case})='high' AND l.action!='登录') AS high_risk_count,
|
||
COUNT(DISTINCT CASE WHEN DATE(l.created_at)=CURDATE() AND l.action!='登录' THEN l.user_id END) AS active_users,
|
||
SUM(l.result_status IN ('failed','denied') AND l.action!='登录') AS failed_count
|
||
FROM dashboard_logs l""", risk_params)[0]
|
||
return jsonify({'code': 0, 'data': {k: int(v or 0) for k, v in row.items()}})
|
||
|
||
|
||
@dashboard_bp.route('/api/logs/options')
|
||
@login_required
|
||
def logs_options():
|
||
pages = query("SELECT DISTINCT COALESCE(NULLIF(page,''),action) value FROM dashboard_logs WHERE action!='登录' ORDER BY value")
|
||
actions = query("SELECT DISTINCT action value FROM dashboard_logs WHERE action!='登录' ORDER BY action")
|
||
fields = query("SELECT DISTINCT field value FROM dashboard_logs WHERE field IS NOT NULL AND field<>'' ORDER BY field")
|
||
return jsonify({'code': 0, 'data': {'pages': [x['value'] for x in pages], 'actions': [x['value'] for x in actions], 'fields': [x['value'] for x in fields]}})
|
||
|
||
|
||
@dashboard_bp.route('/api/logs/detail')
|
||
@login_required
|
||
def logs_detail():
|
||
log_id = request.args.get('id', type=int)
|
||
risk_case, risk_params = _log_risk_case('l')
|
||
rows = query(f"""SELECT l.*,u.real_name,u.username,u.role,u.position,p.position_name,{risk_case} risk_level
|
||
FROM dashboard_logs l LEFT JOIN dashboard_users u ON l.user_id=u.id
|
||
LEFT JOIN user_positions p ON BINARY p.position_code=BINARY u.position WHERE l.id=%s LIMIT 1""", risk_params + [log_id]) if log_id else []
|
||
if not rows:
|
||
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
|
||
row = rows[0]
|
||
if row.get('created_at') and hasattr(row['created_at'], 'strftime'):
|
||
row['created_at'] = row['created_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||
return jsonify({'code': 0, 'data': row})
|
||
|
||
|
||
@dashboard_bp.route('/api/logs/export')
|
||
@login_required
|
||
def logs_export():
|
||
if not operation_required('logs', 'export'):
|
||
return jsonify({'code': 403, 'msg': '没有操作日志导出权限'}), 403
|
||
import io, openpyxl
|
||
from openpyxl.styles import Font, PatternFill, Alignment
|
||
where_clause, params = _build_log_filters(request.args)
|
||
risk_case, risk_params = _log_risk_case('l')
|
||
rows = query(f"""SELECT l.created_at,u.username,u.real_name,u.role,p.position_name,
|
||
COALESCE(NULLIF(l.page,''),l.action) page_name,l.action,l.field,l.detail,l.ip_address,l.result_status,l.object_type,l.object_id,l.request_id,
|
||
{risk_case} risk_level
|
||
FROM dashboard_logs l LEFT JOIN dashboard_users u ON l.user_id=u.id
|
||
LEFT JOIN user_positions p ON BINARY p.position_code=BINARY u.position
|
||
WHERE {where_clause} ORDER BY l.created_at DESC,l.id DESC""", risk_params + params)
|
||
wb = openpyxl.Workbook(); ws = wb.active; ws.title = '操作日志'
|
||
headers = ['操作时间','账号','操作人','角色','岗位','操作页面','操作类型','操作字段','业务对象','操作结果','风险等级','IP地址','请求编号','操作内容']
|
||
ws.append(headers)
|
||
for cell in ws[1]:
|
||
cell.font=Font(bold=True,color='FFFFFF'); cell.fill=PatternFill('solid',fgColor='1677FF'); cell.alignment=Alignment(horizontal='center')
|
||
risk_names={'high':'高风险','medium':'中风险','normal':'普通'}
|
||
for row in rows:
|
||
created=row.get('created_at'); created=created.strftime('%Y-%m-%d %H:%M:%S') if hasattr(created,'strftime') else str(created or '')
|
||
ws.append([created,row.get('username') or '',row.get('real_name') or '',row.get('role') or '',row.get('position_name') or '',row.get('page_name') or '',row.get('action') or '',row.get('field') or '',('/'.join(filter(None,[row.get('object_type'),row.get('object_id')]))),row.get('result_status') or 'success',risk_names.get(row.get('risk_level'),'普通'),row.get('ip_address') or '',row.get('request_id') or '',row.get('detail') or ''])
|
||
widths=[20,16,14,14,16,20,20,18,22,12,12,18,34,60]
|
||
for i,width in enumerate(widths,1): ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width=width
|
||
output=io.BytesIO(); wb.save(output); output.seek(0)
|
||
return send_file(output,as_attachment=True,download_name=f'操作日志_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx',mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||
|
||
|
||
|
||
@dashboard_bp.route('/api/logs/governance/summary')
|
||
@login_required
|
||
def logs_governance_summary():
|
||
from services.audit_governance import ensure_governance_schema
|
||
ensure_governance_schema()
|
||
row=query("""SELECT (SELECT COUNT(*) FROM audit_alerts WHERE status='open') open_alerts,(SELECT COUNT(*) FROM dashboard_logs WHERE risk_level='high' AND created_at>=DATE_SUB(NOW(),INTERVAL 7 DAY)) high_7d,(SELECT COUNT(*) FROM dashboard_logs WHERE result_status IN ('failed','denied') AND created_at>=DATE_SUB(NOW(),INTERVAL 7 DAY)) failed_7d,(SELECT COUNT(DISTINCT user_id) FROM dashboard_logs WHERE created_at>=DATE_SUB(NOW(),INTERVAL 7 DAY)) active_users""")[0]
|
||
return jsonify({'code':0,'data':{k:int(v or 0) for k,v in row.items()}})
|
||
|
||
@dashboard_bp.route('/api/logs/alerts')
|
||
@login_required
|
||
def logs_alerts():
|
||
from services.audit_governance import ensure_governance_schema
|
||
ensure_governance_schema(); rows=query("""SELECT a.*,u.real_name FROM audit_alerts a LEFT JOIN dashboard_users u ON u.id=a.user_id ORDER BY a.id DESC LIMIT 100""")
|
||
for r in rows:
|
||
if hasattr(r.get('created_at'),'strftime'):r['created_at']=r['created_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||
return jsonify({'code':0,'data':rows})
|
||
|
||
@dashboard_bp.route('/api/logs/anomalies')
|
||
@login_required
|
||
def logs_anomalies():
|
||
rapid=query("""SELECT user_id,COUNT(*) cnt,MIN(created_at) first_at,MAX(created_at) last_at FROM dashboard_logs WHERE created_at>=DATE_SUB(NOW(),INTERVAL 1 DAY) GROUP BY user_id,FLOOR(UNIX_TIMESTAMP(created_at)/300) HAVING cnt>=10 ORDER BY cnt DESC LIMIT 30""")
|
||
denied=query("""SELECT user_id,COUNT(*) cnt FROM dashboard_logs WHERE result_status='denied' AND created_at>=DATE_SUB(NOW(),INTERVAL 1 DAY) GROUP BY user_id HAVING cnt>=3 ORDER BY cnt DESC""")
|
||
offhours=query("""SELECT id,user_id,action,created_at FROM dashboard_logs WHERE risk_level='high' AND created_at>=DATE_SUB(NOW(),INTERVAL 7 DAY) AND (HOUR(created_at)<7 OR HOUR(created_at)>=22) ORDER BY id DESC LIMIT 50""")
|
||
for rows in (rapid,offhours):
|
||
for r in rows:
|
||
for k in ('first_at','last_at','created_at'):
|
||
if hasattr(r.get(k),'strftime'):r[k]=r[k].strftime('%Y-%m-%d %H:%M:%S')
|
||
return jsonify({'code':0,'data':{'rapid_operations':rapid,'repeated_denied':denied,'off_hours_high_risk':offhours}})
|
||
|
||
@dashboard_bp.route('/api/logs/reports')
|
||
@login_required
|
||
def logs_reports():
|
||
by_action=query("SELECT action label,COUNT(*) value FROM dashboard_logs WHERE action!='登录' AND created_at>=DATE_SUB(CURDATE(),INTERVAL 29 DAY) GROUP BY action ORDER BY value DESC LIMIT 15")
|
||
by_user=query("SELECT COALESCE(u.real_name,'未知用户') label,COUNT(*) value FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id WHERE l.action!='登录' AND l.created_at>=DATE_SUB(CURDATE(),INTERVAL 29 DAY) GROUP BY l.user_id,u.real_name ORDER BY value DESC LIMIT 15")
|
||
by_day=query("SELECT DATE(created_at) label,COUNT(*) value FROM dashboard_logs WHERE action!='登录' AND created_at>=DATE_SUB(CURDATE(),INTERVAL 29 DAY) GROUP BY DATE(created_at) ORDER BY label")
|
||
for r in by_day:r['label']=str(r['label'])
|
||
return jsonify({'code':0,'data':{'by_action':by_action,'by_user':by_user,'by_day':by_day}})
|
||
|
||
@dashboard_bp.route('/api/logs/integrity/verify',methods=['POST'])
|
||
@login_required
|
||
def logs_integrity_verify():
|
||
if not operation_required('logs','verify'):return jsonify({'code':403,'msg':'没有日志完整性校验权限'}),403
|
||
from services.audit_governance import verify_logs
|
||
return jsonify({'code':0,'data':verify_logs()})
|
||
|
||
@dashboard_bp.route('/api/logs/retention',methods=['GET','POST'])
|
||
@login_required
|
||
def logs_retention():
|
||
from services.audit_governance import ensure_governance_schema
|
||
ensure_governance_schema()
|
||
if request.method=='POST':
|
||
if not operation_required('logs','manage'):return jsonify({'code':403,'msg':'没有日志治理权限'}),403
|
||
data=request.get_json(silent=True) or {};online=max(int(data.get('online_days') or 180),30);archive=max(int(data.get('archive_days') or 730),online)
|
||
query("UPDATE audit_retention_config SET online_days=%s,archive_days=%s,auto_archive=%s,updated_by=%s WHERE id=1",(online,archive,int(bool(data.get('auto_archive'))),session.get('user_id')))
|
||
row=query("SELECT * FROM audit_retention_config WHERE id=1")[0]
|
||
preview=query("SELECT SUM(created_at<DATE_SUB(NOW(),INTERVAL %s DAY)) archive_candidates,SUM(created_at<DATE_SUB(NOW(),INTERVAL %s DAY)) delete_candidates FROM dashboard_logs",(row['online_days'],row['archive_days']))[0]
|
||
return jsonify({'code':0,'data':{'config':row,'preview':{k:int(v or 0) for k,v in preview.items()}}})
|
||
|
||
@dashboard_bp.route('/api/logs/login_list')
|
||
@login_required
|
||
def login_list():
|
||
page = int(request.args.get('page', 1))
|
||
size = int(request.args.get('size', 30))
|
||
offset = (page - 1) * size
|
||
user_filter = request.args.get('user', '')
|
||
|
||
where = ["l.action='登录'"]
|
||
params = []
|
||
|
||
if user_filter:
|
||
where.append("(u.real_name LIKE %s OR u.username LIKE %s)")
|
||
params.extend([f"%{user_filter}%", f"%{user_filter}%"])
|
||
|
||
where_clause = " AND ".join(where)
|
||
|
||
total = query(f"SELECT COUNT(*) AS cnt FROM dashboard_logs l LEFT JOIN dashboard_users u ON l.user_id = u.id WHERE {where_clause}", params)[0]['cnt']
|
||
rows = query(f"""
|
||
SELECT l.*, u.real_name, u.username, u.role
|
||
FROM dashboard_logs l
|
||
LEFT JOIN dashboard_users u ON l.user_id = u.id
|
||
WHERE {where_clause}
|
||
ORDER BY l.created_at DESC
|
||
LIMIT {size} OFFSET {offset}
|
||
""", params)
|
||
|
||
for r in rows:
|
||
if 'created_at' in r and r['created_at']:
|
||
r['created_at'] = r['created_at'].strftime('%Y-%m-%d %H:%M:%S') if hasattr(r['created_at'], 'strftime') else str(r['created_at'])[:19]
|
||
|
||
return jsonify({"code": 0, "data": {"total": total, "page": page, "size": size, "rows": rows}})
|
||
|
||
|
||
# ─── API: 仪表盘数据 ───
|
||
def _filter_dashboard_cost_fields(result):
|
||
"""Apply data-summary cost field permission to both cards and chart rows."""
|
||
if not isinstance(result, dict):
|
||
return result
|
||
# cache_get 返回的是 worker 内存中的共享对象;权限裁剪必须作用于副本,
|
||
# 否则首个无成本权限的请求会永久从该 worker 的缓存里删掉成本指标。
|
||
result = copy.deepcopy(result)
|
||
user_id = session.get('user_id')
|
||
role = session.get('role')
|
||
# The sensitive field registry maps data_summary 总成本 to total_cost.
|
||
# Card breakdown values are derived from the same sensitive metric.
|
||
rows = []
|
||
for key in ('daily_stats', 'brand_stats', 'shop_stats', 'product_stats'):
|
||
value = result.get(key)
|
||
if isinstance(value, list):
|
||
_filter_field_rows('data_summary', value, user_id, role,
|
||
page_view=bool(role_required('data_summary')),
|
||
page_edit=operation_required('data_summary', 'edit'))
|
||
if not operation_required('data_summary', 'view'):
|
||
return result
|
||
from services.field_perms import field_perms_for_page
|
||
perms = field_perms_for_page(user_id, role, 'data_summary',
|
||
page_view=True, page_edit=False)
|
||
if not perms.get('总成本', {}).get('view', False):
|
||
for key in ('total_cost', 'finished_cost', 'packaging_cost',
|
||
'finished_inventory_cost', 'finished_in_transit_cost',
|
||
'packaging_inventory_cost', 'packaging_in_transit_cost'):
|
||
result.pop(key, None)
|
||
result.pop('cost_data_warning', None)
|
||
return result
|
||
|
||
|
||
@dashboard_bp.route('/api/dashboard/stats')
|
||
@login_required
|
||
def dashboard_stats():
|
||
# 概览包含多次历史聚合,冷启动约数秒;优先返回共享缓存。
|
||
# stale 缓存用于数据导入/过期重算期间快速响应,后台刷新完成后自动替换。
|
||
cached = cache_get('dashboard_stats')
|
||
if cached is not None:
|
||
return jsonify({"code": 0, "data": _filter_dashboard_cost_fields(cached), "meta": {"cache": "fresh"}})
|
||
|
||
stale = cache_get('dashboard_stats_stale')
|
||
if stale is not None:
|
||
_start_dashboard_stats_refresh()
|
||
return jsonify({"code": 0, "data": _filter_dashboard_cost_fields(stale), "meta": {"cache": "stale_refreshing"}})
|
||
|
||
try:
|
||
result = _compute_dashboard_stats()
|
||
_publish_dashboard_stats(result)
|
||
return jsonify({"code": 0, "data": _filter_dashboard_cost_fields(result), "meta": {"cache": "cold"}})
|
||
except Exception as exc:
|
||
# 首次登录时多个 worker 可能同时冷启动;返回可识别的 JSON,
|
||
# 让前端短暂重试,而不是留下空白的半渲染页面。
|
||
return jsonify({"code": 1, "msg": "数据概览暂时加载失败,请稍后重试", "meta": {"cache": "error"}}), 503
|
||
|
||
|
||
def _publish_dashboard_stats(result):
|
||
"""发布概览缓存:短期新鲜缓存 + 一天兜底缓存。"""
|
||
cache_set('dashboard_stats', result, ttl=3600)
|
||
cache_set('dashboard_stats_stale', result, ttl=86400)
|
||
|
||
|
||
def _start_dashboard_stats_refresh():
|
||
"""启动后台重算,避免缓存过期时阻塞用户首屏。"""
|
||
import os
|
||
import threading
|
||
import fcntl
|
||
lock_path = '/tmp/dy-dashboard-stats-refresh.lock'
|
||
lock_file = None
|
||
try:
|
||
lock_file = open(lock_path, 'w')
|
||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except (OSError, IOError):
|
||
try:
|
||
if lock_file:
|
||
lock_file.close()
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _worker():
|
||
try:
|
||
result = _compute_dashboard_stats()
|
||
_publish_dashboard_stats(result)
|
||
except Exception:
|
||
# 保留 stale 缓存,下一次请求可再次尝试。
|
||
pass
|
||
finally:
|
||
try:
|
||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||
lock_file.close()
|
||
os.unlink(lock_path)
|
||
except OSError:
|
||
pass
|
||
|
||
threading.Thread(target=_worker, name='dashboard-stats-refresh', daemon=True).start()
|
||
return True
|
||
|
||
|
||
def _compute_dashboard_stats():
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
|
||
totals = query("SELECT COUNT(*) AS cnt, COALESCE(SUM(货品数量),0) AS total FROM daily_outbound")[0]
|
||
total_records = totals['cnt']
|
||
total_qty = totals['total']
|
||
|
||
# 按日期统计
|
||
daily_stats = query("""
|
||
SELECT DATE_FORMAT(日期, '%%Y-%%m-%%d') AS date_str, COUNT(*) AS 记录数, SUM(货品数量) AS 总数量,
|
||
ROUND(SUM(货品成本),2) AS 总成本
|
||
FROM daily_outbound GROUP BY 日期 ORDER BY 日期 DESC LIMIT 10
|
||
""")
|
||
|
||
# 按品牌统计(排除空值/NA脏数据)
|
||
brand_stats = query("""
|
||
SELECT 品牌, SUM(货品数量) AS 总数量, ROUND(SUM(货品成本),2) AS 总成本,
|
||
COUNT(DISTINCT 货品编号) AS SKU数
|
||
FROM daily_outbound
|
||
WHERE 品牌 IS NOT NULL AND 品牌 != '' AND 品牌 != 'NA'
|
||
GROUP BY 品牌 ORDER BY 总数量 DESC LIMIT 10
|
||
""")
|
||
|
||
# 按店铺统计(排除空值/NA脏数据)
|
||
shop_stats = query("""
|
||
SELECT 店铺, SUM(货品数量) AS 总数量, ROUND(SUM(货品成本),2) AS 总成本
|
||
FROM daily_outbound
|
||
WHERE 店铺 IS NOT NULL AND 店铺 != '' AND 店铺 != 'NA'
|
||
GROUP BY 店铺 ORDER BY 总数量 DESC LIMIT 10
|
||
""")
|
||
|
||
# 按单品统计出库量Top10(去除赠品、小样:名称含赠品/小样,或产品类型为赠品)
|
||
product_stats = query("""
|
||
SELECT a.货品编号, a.货品名称, a.总数量, a.总成本
|
||
FROM (
|
||
SELECT d.货品编号, MAX(d.货品名称) AS 货品名称, SUM(d.货品数量) AS 总数量,
|
||
ROUND(SUM(d.货品成本),2) AS 总成本
|
||
FROM daily_outbound d
|
||
WHERE d.货品编号 IS NOT NULL AND d.货品编号 != '' AND d.货品编号 != 'NA'
|
||
AND d.货品编号 NOT LIKE %s
|
||
AND d.货品名称 NOT LIKE %s
|
||
AND d.货品名称 NOT LIKE %s
|
||
GROUP BY d.货品编号
|
||
) a
|
||
LEFT JOIN products p ON a.货品编号 = p.product_code
|
||
WHERE COALESCE(p.product_type, '') != '赠品'
|
||
ORDER BY a.总数量 DESC LIMIT 10
|
||
""", ('%合计%', '%赠品%', '%小样%'))
|
||
|
||
# 产品统计口径与计划预测保持一致:排除包材、其他、手工隐藏和回收站产品。
|
||
planning_product_where = "p.product_type NOT IN ('包材', '其他') AND p.product_code NOT IN (SELECT product_code FROM plan_hidden) AND p.deleted_at IS NULL"
|
||
total_products = query(f"SELECT COUNT(*) AS cnt FROM products p WHERE {planning_product_where}")[0]['cnt']
|
||
# SPU = 计划预测 SKU 绑定的去重物料数,内料不计入。
|
||
spu_total = query(f"""
|
||
SELECT COUNT(DISTINCT b.material_code) AS cnt
|
||
FROM bom b
|
||
JOIN products p ON p.product_code = b.product_code
|
||
LEFT JOIN products m ON m.product_code = b.material_code
|
||
WHERE {planning_product_where}
|
||
AND COALESCE(m.product_name, '') NOT LIKE %s
|
||
""", ('%内料%',))[0]['cnt']
|
||
# 若已确认某日源文件缺少“采购在途”列,则在途成本无法计算;
|
||
# 不能把导入时的默认零当成业务上的零,更不能用前一天的数量冒充当日数量。
|
||
# 仅标记已核对过源文件的 2026-10-08;重新导入完整文件后会自动恢复。
|
||
# 成本按确认的成品/包材口径统计,使用指定核对日期(未指定时自动使用最新库存日期)。
|
||
latest_date = getattr(app_config, 'DASHBOARD_INVENTORY_DATE', None)
|
||
if not latest_date:
|
||
latest_date = query("SELECT MAX(data_date) AS d FROM inventory_detail")[0]['d']
|
||
missing_purchase_transit = False
|
||
if str(latest_date)[:10] == '2026-10-08':
|
||
transit_check = query(
|
||
"SELECT COUNT(*) AS rows_count, COALESCE(SUM(purchase_in_transit), 0) AS qty "
|
||
"FROM inventory_detail WHERE data_date=%s", (latest_date,)
|
||
)[0]
|
||
missing_purchase_transit = bool(transit_check['rows_count']) and not transit_check['qty']
|
||
# 成本价必须与库存数量使用同一个核对日期,避免某个产品的旧日期成本价
|
||
# 混入当前最新库存日,导致概览与明细核对出现跨日期差异。
|
||
_latest_cost = query("""
|
||
SELECT product_code, ANY_VALUE(cost_price) AS pr
|
||
FROM inventory_detail
|
||
WHERE data_date = %s
|
||
GROUP BY product_code
|
||
""", (latest_date,))
|
||
_cost_map = {r['product_code']: resolve_cost_price(r['product_code'], float(r['pr']) if r['pr'] else 0) for r in _latest_cost}
|
||
# 库存成本独立按 inventory_detail 计算;未匹配编码按每条记录所在仓库分类:20号归包材,其余归成品。
|
||
_inventory_rows = query("""
|
||
SELECT id.product_code,
|
||
SUM(CASE WHEN
|
||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND (LPAD(id.warehouse_code, 2, '0') != '20'
|
||
OR id.product_code IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385')))
|
||
THEN id.available_qty ELSE 0 END) AS finished_available_qty,
|
||
SUM(CASE WHEN
|
||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND (LPAD(id.warehouse_code, 2, '0') != '20'
|
||
OR id.product_code IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385')))
|
||
THEN id.production_in_transit ELSE 0 END) AS finished_production_in_transit,
|
||
SUM(CASE WHEN
|
||
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND (LPAD(id.warehouse_code, 2, '0') != '20'
|
||
OR id.product_code IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385')))
|
||
THEN id.purchase_in_transit ELSE 0 END) AS finished_purchase_in_transit,
|
||
SUM(CASE WHEN
|
||
(p.product_type = '包材' AND p.product_name NOT LIKE %s AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND LPAD(id.warehouse_code, 2, '0') = '20'
|
||
AND id.product_code NOT IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385'))
|
||
THEN id.available_qty ELSE 0 END) AS packaging_available_qty,
|
||
SUM(CASE WHEN
|
||
(p.product_type = '包材' AND p.product_name NOT LIKE %s AND p.deleted_at IS NULL)
|
||
OR (p.product_code IS NULL AND LPAD(id.warehouse_code, 2, '0') = '20'
|
||
AND id.product_code NOT IN ('POZ-WPH-0137', '(AVA-YS-3252)', '(UO-AJS-3362)', '(WPK-CBZT-9707)', '【UOUONE】LHJ0385'))
|
||
THEN id.purchase_in_transit ELSE 0 END) AS packaging_purchase_in_transit
|
||
FROM inventory_detail id
|
||
LEFT JOIN products p ON id.product_code = p.product_code
|
||
WHERE id.data_date = %s
|
||
AND id.product_code NOT LIKE %s
|
||
AND id.product_name NOT LIKE %s
|
||
AND NOT (id.product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')
|
||
AND LPAD(id.warehouse_code, 2, '0') = '20')
|
||
GROUP BY id.product_code
|
||
""", ('%内料%', '%内料%', latest_date, '%-NL%', '%内料%'))
|
||
# 生产在途按成品BOM中“内料”这一项的库存成本价计算:生产在途量 × 内料成本价。
|
||
# 内料成本价以核对日期库存明细为准,未取成品BOM中其他物料,也不乘BOM数量。
|
||
_inner_cost_rows = query("""
|
||
SELECT b.product_code, b.material_code,
|
||
COALESCE(mi.cost_price, m.cost_price, 0) AS inner_cost
|
||
FROM bom b
|
||
LEFT JOIN (
|
||
SELECT product_code, ANY_VALUE(cost_price) AS cost_price
|
||
FROM inventory_detail
|
||
WHERE data_date = %s
|
||
GROUP BY product_code
|
||
) mi ON mi.product_code = b.material_code
|
||
LEFT JOIN products m ON m.product_code = b.material_code
|
||
WHERE (b.material_name LIKE %s OR b.material_code LIKE %s)
|
||
""", (latest_date, '%内料%', '%-NL'))
|
||
_inner_cost_map = {
|
||
r['product_code']: resolve_cost_price(
|
||
r['material_code'], float(r['inner_cost'] or 0)
|
||
) if r['material_code'] else 0
|
||
for r in _inner_cost_rows
|
||
}
|
||
finished_inventory_cost = 0.0
|
||
finished_production_transit_cost = 0.0
|
||
finished_purchase_transit_cost = 0.0
|
||
packaging_inventory_cost = 0.0
|
||
packaging_purchase_transit_cost = 0.0
|
||
for r in _inventory_rows:
|
||
price = _cost_map.get(r['product_code'], 0)
|
||
finished_available = float(r['finished_available_qty'] or 0)
|
||
finished_production_transit = float(r['finished_production_in_transit'] or 0)
|
||
finished_purchase_transit = float(r['finished_purchase_in_transit'] or 0)
|
||
packaging_available = float(r['packaging_available_qty'] or 0)
|
||
packaging_purchase_transit = float(r['packaging_purchase_in_transit'] or 0)
|
||
finished_inventory_cost += finished_available * price
|
||
finished_production_transit_cost += finished_production_transit * _inner_cost_map.get(r['product_code'], 0)
|
||
finished_purchase_transit_cost += finished_purchase_transit * price
|
||
packaging_inventory_cost += packaging_available * price
|
||
packaging_purchase_transit_cost += packaging_purchase_transit * price
|
||
|
||
finished_inventory_cost = round(finished_inventory_cost, 2)
|
||
finished_production_transit_cost = round(finished_production_transit_cost, 2)
|
||
finished_purchase_transit_cost = round(finished_purchase_transit_cost, 2)
|
||
packaging_inventory_cost = round(packaging_inventory_cost, 2)
|
||
packaging_purchase_transit_cost = round(packaging_purchase_transit_cost, 2)
|
||
finished_in_transit_cost = round(finished_production_transit_cost + finished_purchase_transit_cost, 2)
|
||
finished_cost = round(finished_inventory_cost + finished_in_transit_cost, 2)
|
||
packaging_in_transit_cost = round(packaging_purchase_transit_cost, 2)
|
||
packaging_cost = round(packaging_inventory_cost + packaging_in_transit_cost, 2)
|
||
total_cost = round(finished_cost + packaging_cost, 2)
|
||
|
||
# 低库存预警 = 统计成品+现货(按产品汇总库存后判断)
|
||
low_stock_count = query("""
|
||
SELECT COUNT(*) AS cnt FROM (
|
||
SELECT id.product_code, SUM(id.available_qty) AS total_qty
|
||
FROM inventory_detail id
|
||
JOIN products p ON id.product_code = p.product_code
|
||
WHERE id.data_date = %s AND p.product_type IN ('成品', '现货')
|
||
GROUP BY id.product_code
|
||
HAVING COALESCE(SUM(id.available_qty), 0) < 100
|
||
) sub
|
||
""", (latest_date,))[0]['cnt']
|
||
|
||
# 近60日出库趋势
|
||
recent_trend = query("""
|
||
SELECT DATE_FORMAT(日期, '%%Y-%%m-%%d') AS date_str,
|
||
SUM(货品数量) AS qty
|
||
FROM daily_outbound
|
||
WHERE 日期 >= DATE_SUB(CURDATE(), INTERVAL 120 DAY)
|
||
GROUP BY 日期 ORDER BY 日期
|
||
""")
|
||
|
||
if missing_purchase_transit:
|
||
# 包材在途=采购在途成本;成品在途也包含采购在途,三个总额均不完整。
|
||
finished_in_transit_cost = None
|
||
packaging_in_transit_cost = None
|
||
finished_cost = None
|
||
packaging_cost = None
|
||
total_cost = None
|
||
|
||
result = {
|
||
"total_records": total_records,
|
||
"total_qty": float(total_qty),
|
||
"total_cost": total_cost,
|
||
"total_products": total_products,
|
||
"spu_total": spu_total,
|
||
"finished_cost": finished_cost,
|
||
"packaging_cost": packaging_cost,
|
||
"finished_inventory_cost": finished_inventory_cost,
|
||
"finished_in_transit_cost": finished_in_transit_cost,
|
||
"packaging_inventory_cost": packaging_inventory_cost,
|
||
"packaging_in_transit_cost": packaging_in_transit_cost,
|
||
"cost_data_warning": (
|
||
"2026-10-08 库存源文件缺少“采购在途”列:在途成本及其汇总暂不可核算,"
|
||
"请上传包含该列的完整库存文件;未采用 10 月 7 日数值代替。"
|
||
if missing_purchase_transit else None
|
||
),
|
||
"low_stock_count": low_stock_count,
|
||
"daily_stats": daily_stats,
|
||
"brand_stats": brand_stats,
|
||
"shop_stats": shop_stats,
|
||
"product_stats": product_stats,
|
||
"recent_trend": recent_trend
|
||
}
|
||
return result
|
||
|
||
|
||
@dashboard_bp.route('/api/dashboard/refresh-cache')
|
||
@login_required
|
||
def refresh_dashboard_cache():
|
||
"""数据更新后同步重建概览缓存;重建期间保留 stale 兜底数据。"""
|
||
cache_del('dashboard_stats')
|
||
try:
|
||
result = _compute_dashboard_stats()
|
||
_publish_dashboard_stats(result)
|
||
return jsonify({"code": 0, "message": "概览缓存已刷新", "meta": {"cache": "fresh"}})
|
||
except Exception as exc:
|
||
_start_dashboard_stats_refresh()
|
||
return jsonify({"code": 0, "message": "概览缓存刷新任务已启动", "meta": {"cache": "refreshing", "detail": str(exc)[:120]}})
|
||
|
||
|
||
def _inventory_board_core(month, force_system_refresh=False):
|
||
"""库存数据看板核心计算:按月份返回四大板块全部指标(含人工/外部数据源覆盖后的最终展示值)。
|
||
月份参数由调用方传入(当月或上月),供环比对比复用。
|
||
|
||
板块1 库存金额:
|
||
- 蝶雅仓成品(1号仓)/ 加工厂仓成品(15号仓)/ 云仓(云仓属性排除1号仓)
|
||
口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料
|
||
板块2 经营效率:
|
||
- 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径)
|
||
- 月销货成本:daily_outbound 所选自然月 SUM(货品成本)
|
||
- 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位
|
||
- 月采购入库:arrival_batches 所选自然月 SUM(arrival_qty)
|
||
- 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率
|
||
- 动销SKU(≥30/≥100):所选月累计出库达到 30/100 件的成品口径 SKU(排除内料)
|
||
- 总SKU:月末启用仓库库存分别达到 1/200 件的成品口径去重 SKU
|
||
板块3 呆滞治理:
|
||
- 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天
|
||
首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量
|
||
(无销量则视为 ∞);金额 = 呆滞SKU 库存金额(成本价口径)
|
||
板块4 缺货监控:
|
||
- 缺货SKU:近 30 天有出库但最新库存日 available_qty=0 的 SKU 数
|
||
- 国内/国际按店铺名区分(含国际/海外/保税 → 国际,其余国内)
|
||
- 缺货率 = 缺货SKU / 总SKU;TOP100缺货率 = 近30天销量TOP100中缺货占比
|
||
- 新品/老品:创建时间 5 个月以内为新品(复用 is_new_product 口径)
|
||
"""
|
||
month = str(month or datetime.now().strftime('%Y-%m')).strip()
|
||
try:
|
||
month_start = datetime.strptime(month, '%Y-%m').date().replace(day=1)
|
||
except ValueError:
|
||
return None
|
||
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||
# 此看板包含按月份人工保存的数据。Gunicorn 多 worker 下进程内缓存无法同步,
|
||
# 会导致保存后请求落到其他 worker 时回显旧值,因此该接口始终读取数据库最新值。
|
||
# 所选月内最后一个库存快照。该月没有快照时不借用上月/未来月份数据,避免月份与数据日期错位。
|
||
latest_date = query(
|
||
"SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date >= %s AND data_date < %s",
|
||
(month_start, next_month),
|
||
)[0]['d']
|
||
if not latest_date:
|
||
result = {
|
||
'all_amount': 0, 'wh01_amount': 0, 'wh15_amount': 0, 'cloud_amount': 0, 'overseas_amount': 0, 'domestic_self_amount': 0,
|
||
'inventory_total_amount': 0, 'monthly_ship_cost': 0, 'monthly_return_cost': None, 'monthly_refund_amount': None,
|
||
'return_rate': None, 'monthly_purchase_inbound': 0, 'turnover_rate': 0, 'turnover_days': None,
|
||
'active_sku_30': 0, 'active_sku_100': 0, 'active_sku': 0, 'total_sku': 0, 'total_sku_200': 0,
|
||
'active_rate': 0, 'active_rate_100': 0,
|
||
'slow_amount': 0, 'slow_ratio': 0, 'stockout_sku_domestic': 0, 'stockout_sku_international': 0,
|
||
'stockout_sku_snowwolf': 0, 'stockout_sku_lingdong': 0, 'stockout_sku_chunyue': 0,
|
||
'stockout_sku': 0, 'stockout_rate': 0, 'top100_stockout_rate': 0, 'new_stockout_rate': None,
|
||
'old_stockout_rate': None, 'latest_date': None, 'month': month, '_external_keys': [],
|
||
}
|
||
query("""CREATE TABLE IF NOT EXISTS inventory_board_manual (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY, month_key CHAR(7) NOT NULL, metric_key VARCHAR(64) NOT NULL,
|
||
metric_value DECIMAL(20,4) DEFAULT NULL, updated_by VARCHAR(100) DEFAULT '',
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uk_month_metric (month_key, metric_key)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
|
||
for row in query("SELECT metric_key, metric_value FROM inventory_board_manual WHERE month_key=%s", (month,)):
|
||
if row['metric_key'] in result and row['metric_value'] is not None:
|
||
result[row['metric_key']] = float(row['metric_value'])
|
||
return result
|
||
|
||
# 复制系统计算结果后再叠加人工/外部值,避免修改进程内共享缓存对象。
|
||
result = dict(_inventory_board_system(month, latest_date, force_refresh=force_system_refresh))
|
||
# 月份维度人工录入表:按板块保存历史月份的卡片值。
|
||
query("""CREATE TABLE IF NOT EXISTS inventory_board_manual (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
month_key CHAR(7) NOT NULL,
|
||
metric_key VARCHAR(64) NOT NULL,
|
||
metric_value DECIMAL(20,4) DEFAULT NULL,
|
||
updated_by VARCHAR(100) DEFAULT '',
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uk_month_metric (month_key, metric_key)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
|
||
manual_map = {r['metric_key']: r['metric_value'] for r in query(
|
||
"SELECT metric_key, metric_value FROM inventory_board_manual WHERE month_key=%s", (month,))}
|
||
for key, value in manual_map.items():
|
||
if key in result and value is not None:
|
||
result[key] = float(value)
|
||
if 'monthly_ship_cost' in manual_map or 'monthly_purchase_inbound' in manual_map or 'active_sku' in manual_map or 'slow_amount' in manual_map:
|
||
result['active_sku_30'] = result.get('active_sku', result.get('active_sku_30'))
|
||
|
||
_ensure_inventory_board_external_schema(month)
|
||
# 外部数据源指标也按月份读取,避免旧版全局值覆盖月份绑定值。
|
||
external_map = {r['metric_key']: r['metric_value'] for r in query(
|
||
"SELECT metric_key, metric_value FROM inventory_board_external WHERE month_key=%s", (month,))}
|
||
external_fields = {
|
||
'monthly_ship_cost': '月销货成本',
|
||
'monthly_return_cost': '月退货成本',
|
||
'monthly_refund_amount': '月退款金额',
|
||
'monthly_purchase_inbound': '月采购入库',
|
||
'active_sku': '动销SKU数量(大于30)',
|
||
'active_sku_100': '动销SKU数量(>100天)',
|
||
'total_sku': '总SKU数量(库存>=1)',
|
||
'total_sku_200': '总SKU数量(库存>=200)',
|
||
'slow_amount': '呆滞库存金额',
|
||
'stockout_sku_domestic': '缺货SKU次数(国内)',
|
||
'stockout_sku_snowwolf': '缺货SKU次数(国际-雪狼)',
|
||
'stockout_sku_lingdong': '缺货SKU次数(国际-灵动)',
|
||
'stockout_sku_chunyue': '缺货SKU次数(国际-春跃)',
|
||
}
|
||
# 有外部录入时覆盖系统值;未录入或清空时回退系统计算值。
|
||
for key in external_fields:
|
||
if key not in external_map or external_map.get(key) in ('', None):
|
||
continue
|
||
value = external_map[key]
|
||
try:
|
||
result[key] = float(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
# 根据当月手工值实时计算派生指标。
|
||
result['active_sku_30'] = result['active_sku']
|
||
result['return_rate'] = (
|
||
round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2)
|
||
if result.get('monthly_return_cost') is not None and result.get('monthly_ship_cost')
|
||
else None
|
||
)
|
||
result['turnover_rate'] = round(result['monthly_ship_cost'] / result['inventory_total_amount'], 4) if result['inventory_total_amount'] else 0
|
||
month_days = (next_month - month_start).days
|
||
result['turnover_days'] = round(month_days / result['turnover_rate'], 1) if result['turnover_rate'] else 0
|
||
result['active_rate'] = round(result['active_sku'] / result['total_sku'] * 100, 2) if result['total_sku'] else 0
|
||
result['active_rate_100'] = round(result['active_sku_100'] / result['total_sku'] * 100, 2) if result['total_sku'] else 0
|
||
result['_external_keys'] = list(external_fields.keys())
|
||
return result
|
||
|
||
|
||
def _start_inventory_board_system_refresh(month, latest_date, cache_key):
|
||
"""后台刷新库存看板系统指标;跨 Gunicorn worker 同一缓存键只运行一个任务。"""
|
||
import fcntl
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import threading
|
||
|
||
digest = hashlib.sha1(cache_key.encode('utf-8')).hexdigest()[:20]
|
||
lock_path = f'/tmp/dy-inventory-board-{digest}.lock'
|
||
lock_file = None
|
||
try:
|
||
lock_file = open(lock_path, 'w')
|
||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except (OSError, IOError):
|
||
try:
|
||
if lock_file:
|
||
lock_file.close()
|
||
except OSError:
|
||
pass
|
||
return False
|
||
|
||
def _worker():
|
||
try:
|
||
_inventory_board_system(month, latest_date, force_refresh=True)
|
||
except Exception:
|
||
logging.exception('[INVENTORY_BOARD] 后台刷新失败 month=%s latest_date=%s', month, latest_date)
|
||
finally:
|
||
try:
|
||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||
lock_file.close()
|
||
os.unlink(lock_path)
|
||
except OSError:
|
||
pass
|
||
|
||
threading.Thread(
|
||
target=_worker,
|
||
name=f'inventory-board-refresh-{month}',
|
||
daemon=True,
|
||
).start()
|
||
return True
|
||
|
||
|
||
def _inventory_board_system(month, latest_date, force_refresh=False):
|
||
"""系统计算部分(慢查询):按月份计算四大板块系统指标,带进程内缓存。
|
||
缓存 key 含最新库存日期,数据更新后自动失效;人工/外部数据源覆盖由调用方实时重放。"""
|
||
month = str(month or '').strip()
|
||
try:
|
||
month_start = datetime.strptime(month, '%Y-%m').date().replace(day=1)
|
||
except ValueError:
|
||
return {}
|
||
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||
_inventory_signature = query(
|
||
"SELECT COUNT(*) AS row_count, COALESCE(SUM(available_qty), 0) AS qty_sum, "
|
||
"DATE_FORMAT(MAX(updated_at), '%%Y%%m%%d%%H%%i%%s%%f') AS updated_signature "
|
||
f"FROM inventory_detail WHERE data_date=%s AND {active_warehouse_condition('inventory_detail')}",
|
||
(latest_date,),
|
||
)[0]
|
||
# 仓库启停/属性变化也必须使缓存失效;使用短摘要避免 cache_key 超出表字段长度。
|
||
import hashlib
|
||
_warehouse_signature = query(
|
||
"SELECT warehouse_code, warehouse_property, disabled FROM warehouses ORDER BY warehouse_code"
|
||
)
|
||
_signature_text = json.dumps({
|
||
'inventory': _inventory_signature,
|
||
'warehouses': _warehouse_signature,
|
||
}, ensure_ascii=False, default=str, sort_keys=True)
|
||
_cache_digest = hashlib.sha1(_signature_text.encode('utf-8')).hexdigest()[:20]
|
||
_cache_key = f'inventory_board_system:{month}:{latest_date}:{_cache_digest}'
|
||
if not force_refresh:
|
||
_cached = cache_get(_cache_key)
|
||
if _cached is not None:
|
||
# 兼容升级前已生成、尚未包含状态元数据的进程/文件缓存。
|
||
if not _cached.get('system_cache_state'):
|
||
_cached = dict(_cached)
|
||
_cached['system_cache_state'] = 'fresh'
|
||
if not _cached.get('data_updated_at'):
|
||
try:
|
||
_meta = query(
|
||
"SELECT updated_at FROM inventory_board_system_cache "
|
||
"WHERE cache_key=%s LIMIT 1", (_cache_key,))
|
||
_updated_at = _meta[0].get('updated_at') if _meta else None
|
||
_cached['data_updated_at'] = (
|
||
_updated_at.strftime('%Y-%m-%d %H:%M:%S')
|
||
if _updated_at and hasattr(_updated_at, 'strftime') else ''
|
||
)
|
||
except Exception:
|
||
_cached['data_updated_at'] = ''
|
||
if _cached.get('system_cache_state') == 'stale_refreshing':
|
||
_start_inventory_board_system_refresh(month, latest_date, _cache_key)
|
||
return _cached
|
||
# 跨 worker 共享缓存(MySQL 表;key 含最新库存日期,数据更新后自动失效)
|
||
query("""CREATE TABLE IF NOT EXISTS inventory_board_system_cache (
|
||
cache_key VARCHAR(80) PRIMARY KEY,
|
||
payload MEDIUMTEXT NOT NULL,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
|
||
_row = []
|
||
_row_is_exact = False
|
||
if not force_refresh:
|
||
_row = query(
|
||
"SELECT payload, updated_at FROM inventory_board_system_cache "
|
||
"WHERE cache_key=%s LIMIT 1", (_cache_key,))
|
||
_row_is_exact = bool(_row)
|
||
# 数据导入会改变签名并生成新 key。新 key 尚未算完时,先使用该月份最近一次
|
||
# 成功结果,避免用户等待完整冷计算;后台会写入当前签名的新缓存。
|
||
if not _row:
|
||
_row = query(
|
||
"SELECT payload, updated_at FROM inventory_board_system_cache "
|
||
"WHERE cache_key LIKE %s ORDER BY updated_at DESC LIMIT 1",
|
||
(f'inventory_board_system:{month}:%',)
|
||
)
|
||
if _row and not force_refresh:
|
||
try:
|
||
_cached = json.loads(_row[0]['payload'])
|
||
updated_at = _row[0].get('updated_at')
|
||
age_seconds = ((datetime.now() - updated_at).total_seconds()
|
||
if updated_at and hasattr(updated_at, 'year') else 3601)
|
||
is_stale = (not _row_is_exact) or age_seconds > 3600
|
||
_cached['system_cache_state'] = 'stale_refreshing' if is_stale else 'fresh'
|
||
_cached['data_updated_at'] = (
|
||
updated_at.strftime('%Y-%m-%d %H:%M:%S')
|
||
if updated_at and hasattr(updated_at, 'strftime') else str(updated_at or '')
|
||
)
|
||
cache_set(_cache_key, _cached, ttl=60 if is_stale else 3600)
|
||
if is_stale:
|
||
_start_inventory_board_system_refresh(month, latest_date, _cache_key)
|
||
return _cached
|
||
except Exception:
|
||
pass
|
||
|
||
# 1. 库存总金额(仅库存、不含在途)
|
||
cost_summary = compute_inventory_cost_summary(latest_date)
|
||
inventory_total_amount = round(
|
||
cost_summary['finished_inventory_cost'] + cost_summary['packaging_inventory_cost'], 2)
|
||
|
||
# 板块1 库存金额:全部仓库 / 1号仓 / 15号仓 / 云仓 / 海外仓 / 国内平台自营仓(成品口径、排除内料、成本价规则)
|
||
# 呆滞金额成本价(最新库存日口径,与板块1金额同源)
|
||
_cost_map = {r['product_code']: resolve_cost_price(
|
||
r['product_code'], float(r['pr']) if r['pr'] else 0) for r in query(
|
||
"SELECT product_code, ANY_VALUE(cost_price) AS pr FROM inventory_detail "
|
||
"WHERE data_date = %s GROUP BY product_code", (latest_date,))}
|
||
|
||
def _calc_inventory_amounts(target_date):
|
||
"""计算指定库存日的 6 个库存金额卡片值(元,成品口径、排除内料、成本价规则),不含人工录入覆盖。"""
|
||
_cost = query(
|
||
"SELECT product_code, ANY_VALUE(cost_price) AS pr FROM inventory_detail "
|
||
"WHERE data_date = %s GROUP BY product_code", (target_date,))
|
||
_cmap = {r['product_code']: resolve_cost_price(
|
||
r['product_code'], float(r['pr']) if r['pr'] else 0) for r in _cost}
|
||
|
||
def _wh_finished_amount(wh_cond, params):
|
||
rows = query(f"""
|
||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||
FROM inventory_detail id
|
||
WHERE id.data_date = %s
|
||
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
|
||
AND {active_warehouse_condition('id')} AND {wh_cond}
|
||
AND (
|
||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||
)
|
||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||
GROUP BY id.product_code
|
||
""", (target_date,) + tuple(params) + ('%-NL', '%内料%'))
|
||
return round(sum(float(r['qty'] or 0) * _cmap.get(r['code'], 0) for r in rows), 2)
|
||
|
||
def _warehouse_match(codes):
|
||
numeric = sorted({int(str(code)) for code in codes if str(code).isdigit()})
|
||
text = sorted({str(code) for code in codes if not str(code).isdigit()})
|
||
clauses, params = [], []
|
||
if numeric:
|
||
clauses.append("(id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) IN (%s))" %
|
||
','.join(['%s'] * len(numeric)))
|
||
params.extend(numeric)
|
||
if text:
|
||
clauses.append("id.warehouse_code IN (%s)" % ','.join(['%s'] * len(text)))
|
||
params.extend(text)
|
||
return ('(' + ' OR '.join(clauses) + ')', params) if clauses else ('0=1', [])
|
||
|
||
amounts = {
|
||
'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品
|
||
'wh01_amount': _wh_finished_amount(
|
||
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (1,)),
|
||
'wh15_amount': _wh_finished_amount(
|
||
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (15,)),
|
||
}
|
||
_cloud_codes = [r['warehouse_code'] for r in query(
|
||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('云仓', warehouse_property) > 0 "
|
||
"AND COALESCE(disabled, '否') != '是' "
|
||
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)")]
|
||
if _cloud_codes:
|
||
_cond, _params = _warehouse_match(_cloud_codes)
|
||
amounts['cloud_amount'] = _wh_finished_amount(_cond, _params)
|
||
else:
|
||
amounts['cloud_amount'] = 0.0
|
||
_overseas_codes = [r['warehouse_code'] for r in query(
|
||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0 "
|
||
"AND COALESCE(disabled, '否') != '是'")]
|
||
if _overseas_codes:
|
||
_cond, _params = _warehouse_match(_overseas_codes)
|
||
amounts['overseas_amount'] = _wh_finished_amount(_cond, _params)
|
||
else:
|
||
amounts['overseas_amount'] = 0.0
|
||
# 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓)
|
||
_self_codes = [r['warehouse_code'] for r in query(
|
||
"SELECT warehouse_code FROM warehouses WHERE (warehouse_name LIKE %s OR warehouse_code IN ('13', '14')) "
|
||
"AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND COALESCE(disabled, '否') != '是' "
|
||
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)",
|
||
('%自营%',))]
|
||
if _self_codes:
|
||
_cond, _params = _warehouse_match(_self_codes)
|
||
amounts['domestic_self_amount'] = _wh_finished_amount(_cond, _params)
|
||
else:
|
||
amounts['domestic_self_amount'] = 0.0
|
||
return amounts
|
||
|
||
_amount_map = _calc_inventory_amounts(latest_date)
|
||
all_amount = _amount_map['all_amount']
|
||
wh01_amount = _amount_map['wh01_amount']
|
||
wh15_amount = _amount_map['wh15_amount']
|
||
cloud_amount = _amount_map['cloud_amount']
|
||
overseas_amount = _amount_map['overseas_amount']
|
||
domestic_self_amount = _amount_map['domestic_self_amount']
|
||
|
||
# 2. 月销货成本(近30天)
|
||
monthly_ship_cost = float(query(
|
||
"SELECT COALESCE(SUM(货品成本),0) AS v FROM daily_outbound "
|
||
"WHERE 日期 >= %s AND 日期 < %s", (month_start, next_month)
|
||
)[0]['v'])
|
||
|
||
# 6. 月采购入库(到货批次近30天)
|
||
monthly_purchase_inbound = float(query(
|
||
"SELECT COALESCE(SUM(arrival_qty),0) AS v FROM arrival_batches "
|
||
"WHERE arrival_date >= %s AND arrival_date < %s", (month_start, next_month)
|
||
)[0]['v'])
|
||
|
||
# 7/8. 库存周转率/周转天数
|
||
turnover_rate = round(monthly_ship_cost / inventory_total_amount, 4) if inventory_total_amount else 0
|
||
turnover_days = round(30 / turnover_rate, 1) if turnover_rate else None
|
||
|
||
# 9. 动销SKU数量:所选自然月累计出库分别达到 30 / 100 件的 SKU 数。
|
||
_monthly_sales = query("""
|
||
SELECT d.货品编号 AS code, SUM(d.货品数量) AS qty
|
||
FROM daily_outbound d
|
||
WHERE d.日期 >= %s AND d.日期 < %s
|
||
AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s
|
||
AND (
|
||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=d.货品编号
|
||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=d.货品编号 AND p0.deleted_at IS NULL)
|
||
)
|
||
GROUP BY d.货品编号
|
||
""", (month_start, next_month, '%内料%', '%-NL'))
|
||
active_sku_30 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 30)
|
||
active_sku_100 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 100)
|
||
|
||
# 10. 总SKU数量:月末启用仓库的成品库存分别达到 1 / 200 件的去重 SKU 数。
|
||
_month_end_sku_rows = query(f"""
|
||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||
FROM inventory_detail id
|
||
WHERE id.data_date = %s AND {active_warehouse_condition('id')}
|
||
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
|
||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||
AND (
|
||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||
)
|
||
GROUP BY id.product_code
|
||
""", (latest_date, '%-NL', '%内料%'))
|
||
total_sku = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 1)
|
||
total_sku_200 = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 200)
|
||
|
||
# 11. 动销率(30天动销口径)
|
||
active_rate = round(active_sku_30 / total_sku * 100, 2) if total_sku else 0
|
||
|
||
# 板块3 呆滞治理:首次入库距今≥90天 且 可销天数≥90天
|
||
# 首次入库日期(最早库存记录日期)
|
||
# 注:不能加内料过滤 WHERE,否则无法走 idx_product_date 覆盖索引,全表扫描 180万行需 30s+
|
||
_first_date = {r['product_code']: r['first_date'] for r in query("""
|
||
SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail
|
||
WHERE """ + active_warehouse_condition('inventory_detail') + """
|
||
GROUP BY product_code
|
||
""")}
|
||
# 近30天销量(日均销量分母)
|
||
_sales_30 = {r['code']: float(r['qty'] or 0) for r in query("""
|
||
SELECT /*+ INDEX(daily_outbound idx_date_product) */ 货品编号 AS code, SUM(货品数量) AS qty FROM daily_outbound
|
||
WHERE 日期 >= %s AND 日期 < %s
|
||
AND 货品名称 NOT LIKE %s AND 货品编号 NOT LIKE %s
|
||
GROUP BY 货品编号
|
||
""", (max(month_start, latest_date - timedelta(days=29)), next_month, '%内料%', '%-NL'))}
|
||
# 最新库存日:按商品聚合可得库存数量(成品口径、排除内料)
|
||
_inv_by_code = query(f"""
|
||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||
FROM inventory_detail id
|
||
WHERE id.data_date = %s AND {active_warehouse_condition('id')}
|
||
AND (
|
||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||
)
|
||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||
GROUP BY id.product_code
|
||
""", (latest_date, '%-NL', '%内料%'))
|
||
slow_amount = 0.0
|
||
for r in _inv_by_code:
|
||
code = r['code']
|
||
if not code:
|
||
continue
|
||
qty = float(r['qty'] or 0)
|
||
if qty <= 0:
|
||
continue
|
||
first_date = _first_date.get(code)
|
||
if not first_date:
|
||
continue
|
||
# 首次入库距今天数
|
||
days_since_first = (latest_date - first_date).days
|
||
if days_since_first <= 90:
|
||
continue
|
||
# 可销天数 = 当前库存 / 近30天日均销量(无销量视为∞)
|
||
daily_sales = _sales_30.get(code, 0) / 30.0
|
||
saleable_days = qty / daily_sales if daily_sales > 0 else 999999.0
|
||
if saleable_days <= 90:
|
||
continue
|
||
slow_amount += qty * _cost_map.get(code, 0)
|
||
slow_amount = round(slow_amount, 2)
|
||
slow_ratio = round(slow_amount / inventory_total_amount * 100, 2) if inventory_total_amount else 0
|
||
|
||
# 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。
|
||
# 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。
|
||
_stock_rows = query(f"""
|
||
SELECT id.product_code AS code, id.data_date AS dt, SUM(id.available_qty) AS qty
|
||
FROM inventory_detail id
|
||
WHERE id.data_date >= %s AND id.data_date < %s
|
||
AND {active_warehouse_condition('id')}
|
||
AND (
|
||
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||
)
|
||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||
GROUP BY id.product_code, id.data_date
|
||
HAVING SUM(id.available_qty) <= 0
|
||
ORDER BY id.product_code, id.data_date
|
||
""", (month_start, next_month, '%-NL', '%内料%'))
|
||
_stockout_codes = set()
|
||
_stockout_by_code = {}
|
||
_prev_code, _prev_date, _run = None, None, 0
|
||
for row in _stock_rows + [{'code': None, 'dt': None, 'qty': 1}]:
|
||
code, dt = row['code'], row['dt']
|
||
is_zero = code is not None and float(row['qty'] or 0) <= 0
|
||
if code != _prev_code or (is_zero and _prev_date and (dt - _prev_date).days != 1):
|
||
if _prev_code and _run >= 2:
|
||
_stockout_codes.add(_prev_code)
|
||
_run = 0
|
||
if is_zero:
|
||
_run += 1
|
||
_stockout_by_code.setdefault(code, 0)
|
||
else:
|
||
if _prev_code and _run >= 2:
|
||
_stockout_codes.add(_prev_code)
|
||
_run = 0
|
||
_prev_code, _prev_date = code, dt
|
||
# 缺货监控只针对统计窗口内存在实际出库需求的 SKU,排除长期零库存且无销售的停销档案。
|
||
_stockout_codes.intersection_update(_sales_30.keys())
|
||
_prod_platform = {r['code']: (r['platform'] or '') for r in query("""
|
||
SELECT product_code AS code, platform FROM products
|
||
WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL
|
||
""") if r['code']}
|
||
_intl_platforms = {'雪狼': set(), '灵动': set(), '春跃': set()}
|
||
for code in _stockout_codes:
|
||
platform = _prod_platform.get(code, '')
|
||
for name in _intl_platforms:
|
||
aliases = ('战狼', '雪狼') if name == '雪狼' else (name,)
|
||
if any(alias in platform for alias in aliases):
|
||
_intl_platforms[name].add(code)
|
||
_intl_codes = set().union(*_intl_platforms.values()) if _intl_platforms else set()
|
||
stockout_sku_domestic = len(_stockout_codes - _intl_codes)
|
||
stockout_sku_international = len(_intl_codes)
|
||
stockout_sku_snowwolf = len(_intl_platforms['雪狼'])
|
||
stockout_sku_lingdong = len(_intl_platforms['灵动'])
|
||
stockout_sku_chunyue = len(_intl_platforms['春跃'])
|
||
stockout_sku = len(_stockout_codes)
|
||
stockout_rate = round(stockout_sku / total_sku * 100, 2) if total_sku else 0
|
||
|
||
# 销量TOP100缺货率:复用 _sales_30(同口径近30天销量),按销量取前100名。
|
||
_top100 = [{'code': k, 'qty': v} for k, v in sorted(_sales_30.items(), key=lambda x: -x[1])[:100]]
|
||
top100_stockout = sum(1 for r in _top100 if r['code'] and r['code'] in _stockout_codes)
|
||
top100_stockout_rate = round(top100_stockout / len(_top100) * 100, 2) if _top100 else 0
|
||
|
||
# 新品/老品缺货率:按首次入库日期划分,首次入库90天内=新品,超过90天=老品。
|
||
new_codes = {code for code, first_date in _first_date.items()
|
||
if first_date and 0 <= (latest_date - first_date).days <= 90}
|
||
old_codes = {code for code, first_date in _first_date.items()
|
||
if first_date and (latest_date - first_date).days > 90}
|
||
_stockout_demand = _stockout_codes
|
||
new_stockout = len(_stockout_demand & new_codes)
|
||
old_stockout = len(_stockout_demand & old_codes)
|
||
new_stockout_rate = round(new_stockout / len(new_codes) * 100, 2) if new_codes else None
|
||
old_stockout_rate = round(old_stockout / len(old_codes) * 100, 2) if old_codes else None
|
||
|
||
calculated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
result = {
|
||
# 板块1 库存金额
|
||
'all_amount': all_amount, # 全部仓库成品
|
||
'wh01_amount': wh01_amount, # 蝶雅仓成品
|
||
'wh15_amount': wh15_amount, # 加工厂仓成品
|
||
'cloud_amount': cloud_amount, # 云仓成品
|
||
'overseas_amount': overseas_amount, # 海外仓成品
|
||
'domestic_self_amount': domestic_self_amount, # 国内平台自营仓成品
|
||
# 板块2 经营效率
|
||
'inventory_total_amount': inventory_total_amount,
|
||
'monthly_ship_cost': round(monthly_ship_cost, 2),
|
||
'monthly_return_cost': None,
|
||
'monthly_refund_amount': None,
|
||
'return_rate': None,
|
||
'monthly_purchase_inbound': monthly_purchase_inbound,
|
||
'turnover_rate': turnover_rate,
|
||
'turnover_days': turnover_days,
|
||
'active_sku_30': active_sku_30, # 动销SKU数量(>30天)
|
||
'active_sku_100': active_sku_100, # 动销SKU数量(>100天)
|
||
'active_sku': active_sku_30, # 兼容旧字段(外部录入覆盖)
|
||
'total_sku': total_sku,
|
||
'total_sku_200': total_sku_200,
|
||
'active_rate': active_rate,
|
||
'active_rate_100': 0,
|
||
# 板块3 呆滞治理
|
||
'slow_amount': slow_amount,
|
||
'slow_ratio': slow_ratio,
|
||
# 板块4 缺货监控
|
||
'stockout_sku_domestic': stockout_sku_domestic,
|
||
'stockout_sku_international': stockout_sku_international,
|
||
'stockout_sku_snowwolf': stockout_sku_snowwolf,
|
||
'stockout_sku_lingdong': stockout_sku_lingdong,
|
||
'stockout_sku_chunyue': stockout_sku_chunyue,
|
||
'stockout_sku': stockout_sku,
|
||
'stockout_rate': stockout_rate,
|
||
'top100_stockout_rate': top100_stockout_rate,
|
||
'new_stockout_rate': new_stockout_rate,
|
||
'old_stockout_rate': old_stockout_rate,
|
||
'latest_date': str(latest_date),
|
||
'month': month,
|
||
'system_cache_state': 'fresh',
|
||
'data_updated_at': calculated_at,
|
||
}
|
||
cache_set(_cache_key, result, ttl=3600)
|
||
query("""INSERT INTO inventory_board_system_cache (cache_key, payload) VALUES (%s, %s)
|
||
ON DUPLICATE KEY UPDATE payload=VALUES(payload), updated_at=CURRENT_TIMESTAMP""",
|
||
(_cache_key, json.dumps(result, ensure_ascii=False, default=str)))
|
||
return result
|
||
|
||
|
||
@dashboard_bp.route('/api/dashboard/inventory-board')
|
||
@login_required
|
||
def inventory_board_stats(force_system_refresh=False):
|
||
"""库存数据看板接口:当月指标 + 全部卡片较上月环比百分比。"""
|
||
month = (request.args.get('month') or (request.get_json(silent=True) or {}).get('month') or datetime.now().strftime('%Y-%m')).strip()
|
||
try:
|
||
month_start = datetime.strptime(month, '%Y-%m').date().replace(day=1)
|
||
except ValueError:
|
||
return jsonify({'code': 1, 'msg': '月份格式应为 YYYY-MM'}), 400
|
||
result = _inventory_board_core(month, force_system_refresh=force_system_refresh)
|
||
if result is None:
|
||
return jsonify({'code': 1, 'msg': '月份格式应为 YYYY-MM'}), 400
|
||
# 全部展示卡片:较上月环比百分比(上月值 = 上月 core 最终展示值,含人工/外部数据源覆盖)
|
||
mom_keys = [
|
||
'all_amount', 'wh01_amount', 'wh15_amount', 'cloud_amount', 'overseas_amount', 'domestic_self_amount',
|
||
'inventory_total_amount', 'monthly_ship_cost', 'monthly_return_cost', 'monthly_refund_amount',
|
||
'return_rate', 'monthly_purchase_inbound', 'turnover_rate', 'turnover_days',
|
||
'active_sku', 'active_sku_100', 'total_sku', 'total_sku_200', 'active_rate', 'active_rate_100',
|
||
'slow_amount', 'slow_ratio',
|
||
'stockout_sku_domestic', 'stockout_sku_snowwolf', 'stockout_sku_lingdong', 'stockout_sku_chunyue',
|
||
'stockout_rate', 'top100_stockout_rate', 'new_stockout_rate', 'old_stockout_rate',
|
||
]
|
||
prev_month = (month_start - timedelta(days=1)).strftime('%Y-%m')
|
||
prev = None
|
||
try:
|
||
prev = _inventory_board_core(prev_month, force_system_refresh=force_system_refresh)
|
||
except Exception:
|
||
prev = None
|
||
for _key in mom_keys:
|
||
_cur = result.get(_key)
|
||
_pv = prev.get(_key) if prev else None
|
||
result['prev_' + _key] = round(float(_pv), 2) if _pv is not None else None
|
||
result['mom_' + _key] = (round((float(_cur) - float(_pv)) / abs(float(_pv)) * 100, 2)
|
||
if _pv and _cur is not None else None)
|
||
refreshing = (
|
||
result.get('system_cache_state') == 'stale_refreshing'
|
||
or bool(prev and prev.get('system_cache_state') == 'stale_refreshing')
|
||
)
|
||
result['cache_state'] = 'stale_refreshing' if refreshing else 'fresh'
|
||
result['cache_message'] = ('正在后台更新最新统计' if refreshing else '统计数据已更新')
|
||
return jsonify({"code": 0, "data": result})
|
||
|
||
|
||
@dashboard_bp.route('/api/dashboard/inventory-board/recalculate', methods=['POST'])
|
||
@login_required
|
||
def inventory_board_recalculate():
|
||
data = request.get_json(silent=True) or {}
|
||
month = (data.get('month') or request.args.get('month') or datetime.now().strftime('%Y-%m')).strip()
|
||
cache_del(f'inventory_board_stats_v2:{month}')
|
||
# 清系统计算缓存(内存 + 跨 worker 共享表),强制下次重新计算
|
||
try:
|
||
_ms = datetime.strptime(month, '%Y-%m').date().replace(day=1)
|
||
_nm = (_ms.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||
_ld = query("SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date < %s", (_nm,))[0]['d']
|
||
if _ld:
|
||
_ck = 'inventory_board_system:%s:%s' % (month, _ld)
|
||
cache_del(_ck)
|
||
query("DELETE FROM inventory_board_system_cache WHERE cache_key=%s", (_ck,))
|
||
except Exception:
|
||
pass
|
||
return inventory_board_stats(force_system_refresh=True)
|
||
|
||
|
||
@dashboard_bp.route('/api/dashboard/inventory-board/manual', methods=['POST'])
|
||
@login_required
|
||
def inventory_board_manual_save():
|
||
data = request.get_json(silent=True) or {}
|
||
month = str(data.get('month') or '').strip()
|
||
values = data.get('values') or {}
|
||
try:
|
||
datetime.strptime(month, '%Y-%m')
|
||
except ValueError:
|
||
return jsonify({'code': 1, 'msg': '月份格式应为 YYYY-MM'}), 400
|
||
allowed = {'all_amount','wh01_amount','wh15_amount','cloud_amount','overseas_amount','domestic_self_amount',
|
||
'inventory_total_amount','monthly_ship_cost','monthly_return_cost','monthly_refund_amount','return_rate',
|
||
'monthly_purchase_inbound','turnover_rate','turnover_days','active_sku','active_sku_100','total_sku','total_sku_200','active_rate','active_rate_100',
|
||
'slow_amount','slow_ratio','stockout_sku_domestic','stockout_sku_international','stockout_sku_snowwolf','stockout_sku_lingdong','stockout_sku_chunyue','stockout_rate',
|
||
'top100_stockout_rate','new_stockout_rate','old_stockout_rate'}
|
||
query("""CREATE TABLE IF NOT EXISTS inventory_board_manual (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
month_key CHAR(7) NOT NULL,
|
||
metric_key VARCHAR(64) NOT NULL,
|
||
metric_value DECIMAL(20,4) DEFAULT NULL,
|
||
updated_by VARCHAR(100) DEFAULT '',
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
UNIQUE KEY uk_month_metric (month_key, metric_key)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""")
|
||
for key, raw in values.items():
|
||
if key not in allowed or raw in ('', None):
|
||
continue
|
||
try:
|
||
value = float(raw)
|
||
except (TypeError, ValueError):
|
||
return jsonify({'code': 1, 'msg': f'{key} 请输入数字'}), 400
|
||
query("""INSERT INTO inventory_board_manual (month_key, metric_key, metric_value, updated_by)
|
||
VALUES (%s,%s,%s,%s) ON DUPLICATE KEY UPDATE metric_value=VALUES(metric_value), updated_by=VALUES(updated_by)""",
|
||
(month, key, value, session.get('real_name') or session.get('username') or ''))
|
||
cache_del(f'inventory_board_stats_v2:{month}')
|
||
return jsonify({'code': 0, 'msg': '人工数据已保存'})
|
||
|
||
|
||
@dashboard_bp.route('/api/dashboard/inventory-board/external', methods=['POST'])
|
||
@login_required
|
||
def inventory_board_external_save():
|
||
"""保存库存数据看板外部数据源指标(人工录入值)"""
|
||
data = request.get_json(silent=True) or {}
|
||
key = (data.get('key') or '').strip()
|
||
month = str(data.get('month') or '').strip()
|
||
value = str(data.get('value') if data.get('value') is not None else '').strip()
|
||
try:
|
||
datetime.strptime(month, '%Y-%m')
|
||
except ValueError:
|
||
return jsonify({'code': 1, 'msg': '月份格式应为 YYYY-MM'}), 400
|
||
|
||
allowed = {'monthly_ship_cost', 'monthly_return_cost', 'monthly_refund_amount',
|
||
'monthly_purchase_inbound', 'active_sku', 'active_sku_100', 'total_sku',
|
||
'total_sku_200', 'slow_amount', 'stockout_sku_domestic', 'stockout_sku_snowwolf',
|
||
'stockout_sku_lingdong', 'stockout_sku_chunyue'}
|
||
if key not in allowed:
|
||
return jsonify({"code": 1, "msg": "不支持的指标字段"})
|
||
_ensure_inventory_board_external_schema(month)
|
||
if value == '':
|
||
# 清空当前月份外部值 = 回退当前月份内部计算
|
||
query("DELETE FROM inventory_board_external WHERE month_key=%s AND metric_key=%s", (month, key))
|
||
else:
|
||
try:
|
||
float(value)
|
||
except ValueError:
|
||
return jsonify({"code": 1, "msg": "请输入数字"})
|
||
query("""
|
||
INSERT INTO inventory_board_external (month_key, metric_key, metric_value, updated_by)
|
||
VALUES (%s, %s, %s, %s)
|
||
ON DUPLICATE KEY UPDATE metric_value=VALUES(metric_value), updated_by=VALUES(updated_by)
|
||
""", (month, key, value, session.get('real_name') or session.get('username') or ''))
|
||
cache_del(f"inventory_board_stats_v2:{month}")
|
||
return jsonify({"code": 0, "msg": "已保存"})
|