"""仪表盘路由""" 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(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": "已保存"})