dy-dashboard/routes/dashboard.py
2026-10-29 16:33:09 +08:00

1389 lines
74 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 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('/organization')
@login_required
def organization_page():
if not role_required('organization'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='organization')
@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": "已保存"})