diff --git a/routes/planning.py b/routes/planning.py new file mode 100644 index 0000000..b4d87db --- /dev/null +++ b/routes/planning.py @@ -0,0 +1,1461 @@ +"""计划预测路由(最肥模块)""" +from datetime import datetime +from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template +from services.db import query +from services.product_type import get_product_type_cached +from services.sales_calc import compute_product_sales_cached, compute_product_sales_extended_cached +from services.inventory_calc import (compute_product_inventory_cached, + compute_product_inventory_15, + compute_product_in_transit_cached, + compute_cloud_inventory_cached, + compute_external_inventory_cached, + _determine_trend) +from services.forecast import (is_new_product, get_product_grade, + compute_daily_forecast, + sales_predict_config_cached, + get_global_config) +from routes.auth import login_required, role_required, operation_required, _ensure_personnel_table +from services.field_perms import filter_rows as _filter_field_rows +import json + +planning_bp = Blueprint('planning', __name__) + + +def _personnel_group_for_value(raw_value, groups): + """Infer a group's label from a stored person list without cross-group leakage.""" + names = [name.strip().casefold() for name in str(raw_value or '').split(',') if name.strip()] + if not names: + return '' + normalized_groups = { + str(group_name).casefold(): {str(member).strip().casefold() for member in members} + for group_name, members in groups.items() + } + if len(names) == 1 and names[0] in normalized_groups: + return names[0] + counts = {group_name: sum(1 for name in names if name in members) + for group_name, members in normalized_groups.items()} + counts = {group_name: count for group_name, count in counts.items() if count} + if not counts: + return '' + best = max(counts.values()) + winners = [name for name, count in counts.items() if count == best] + return winners[0] if len(winners) == 1 and best * 2 >= len(names) else '' + + +def _personnel_group_members(): + """Read the editable personnel groups used by Personnel Management.""" + _ensure_personnel_table() + rows = query("SELECT group_name, members_json FROM personnel_groups ORDER BY group_order, group_name") + groups = {} + for row in rows: + try: + members = json.loads(row.get('members_json') or '[]') + except (TypeError, ValueError): + members = [] + groups[row['group_name']] = [str(member).strip() for member in members if str(member).strip()] + return groups + +def _personnel_value_matches_group(raw_value, selected_value, groups): + """Match a product's stored follower value against a group or person.""" + raw_names = {name.strip().casefold() for name in str(raw_value or '').split(',') if name.strip()} + selected = str(selected_value or '').strip().casefold() + if not selected: + return True + for group_name, members in groups.items(): + if group_name.casefold() == selected: + member_names = {str(member).strip().casefold() for member in members} + return bool(raw_names & member_names) or selected in raw_names + return selected in raw_names + + +def _ensure_global_config(): + """确保全局配置已加载""" + gc = get_global_config() + return gc + + +def _enrich_row_with_planning_data(row, sales, inv_map, inv15_map, transit_map, sp_map, gc, + cloud_inv_map=None, ext_inv_map=None, + cloud_sales_ext=None, ext_sales_ext=None, + extended_sales=None): + """给一行产品数据补充计划预测相关的所有计算字段""" + pc = row.get('product_code', '') + inv_val = inv_map.get(pc, 0) + inv15_val = inv15_map.get(pc, 0) + t = transit_map.get(pc, {}) + prod_transit = t.get('生产在途', 0) + purch_transit = t.get('采购在途', 0) + + row['总库存'] = inv_val + inv15_val + prod_transit + purch_transit + row['库存'] = inv_val + row['总在途库存'] = inv15_val + prod_transit + purch_transit + row['云仓库存'] = cloud_inv_map.get(pc, 0) if cloud_inv_map else 0 + row['外仓库存'] = ext_inv_map.get(pc, 0) if ext_inv_map else 0 + + # 云仓/外仓销量平均值 + def _safe_get(d, pc, key): + return (d.get(pc) or {}).get(key, 0) if d else 0 + + ext_data = extended_sales or {} + cs = ext_data.get('cloud', {}) + es = ext_data.get('external', {}) + + row['云仓昨日销量'] = _safe_get(cs, pc, '昨日销量') + row['外仓昨日销量'] = _safe_get(es, pc, '昨日销量') + row['云仓3天平均销量'] = _safe_get(cs, pc, '3天平均销量') + row['外仓3天平均销量'] = _safe_get(es, pc, '3天平均销量') + row['云仓7天平均销量'] = _safe_get(cs, pc, '7天平均销量') + row['外仓7天平均销量'] = _safe_get(es, pc, '7天平均销量') + row['云仓14天平均销量'] = _safe_get(cs, pc, '14天平均销量') + row['外仓14天平均销量'] = _safe_get(es, pc, '14天平均销量') + row['加工厂库存'] = inv15_map.get(pc, 0) + t = transit_map.get(pc, {}) + row['生产在途'] = t.get('生产在途', 0) + row['采购在途'] = t.get('采购在途', 0) + + if pc in sales: + s = sales[pc] + for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', + '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', + '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', + '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: + row[k] = s.get(k, 0) + row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量']) + else: + for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', + '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', + '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', + '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: + row[k] = 0 + row['销售趋势'] = _determine_trend(0, 0, 0) + + # 云仓/外仓可分配库存:按「云仓外仓权重参数」计算 + # X = 云仓销量/昨日销量,Y = 外仓销量/昨日销量(昨日销量为0时权重按0计) + # 云仓可分配库存 = 总库存 * X - 云仓库存;外仓可分配库存 = 总库存 * Y - 外仓库存 + total_inv_val = float(row.get('总库存', 0) or 0) + yesterday_val = float(row.get('昨日销量', 0) or 0) + cloud_yesterday_val = float(row.get('云仓昨日销量', 0) or 0) + ext_yesterday_val = float(row.get('外仓昨日销量', 0) or 0) + weight_x = (cloud_yesterday_val / yesterday_val) if yesterday_val > 0 else 0 + weight_y = (ext_yesterday_val / yesterday_val) if yesterday_val > 0 else 0 + row['云仓可分配库存'] = round(total_inv_val * weight_x - float(row.get('云仓库存', 0) or 0), 1) + row['外仓可分配库存'] = round(total_inv_val * weight_y - float(row.get('外仓库存', 0) or 0), 1) + + # 是否调拨:按可分配库存自动判定(两边都不大于0时留空) + cloud_alloc_ok = row['云仓可分配库存'] > 0 + ext_alloc_ok = row['外仓可分配库存'] > 0 + if cloud_alloc_ok and ext_alloc_ok: + row['是否调拨'] = '云仓和外仓' + elif cloud_alloc_ok: + row['是否调拨'] = '云仓' + elif ext_alloc_ok: + row['是否调拨'] = '外仓' + else: + row['是否调拨'] = '' + + row['产品分级'] = get_product_grade(row.get('30天平均销量', 0) * 30, + row.get('product_name', ''), + row.get('产品种类', row.get('product_type', ''))) + + # 是否新品 + row['是否新品'] = is_new_product(row.get('创建时间', '')) + + # 日销量预测 + 各种支撑天数计算 + row['日销量预测'] = compute_daily_forecast(row, sp_map) + + # 云仓/外仓日销量预测(构建临时row对象传入) + def _cloud_forecast(val_3, val_7, val_14, val_30, trend_val): + tmp = { + '3天平均销量': val_3, '7天平均销量': val_7, + '14天平均销量': val_14, '30天最高销量': val_30, + '销售趋势': trend_val + } + f = compute_daily_forecast(tmp, sp_map) + return f + + row['云仓日销量预测'] = _cloud_forecast( + row.get('云仓3天平均销量', 0), row.get('云仓7天平均销量', 0), + row.get('云仓14天平均销量', 0), _safe_get(cs, pc, '30天最高销量'), + _determine_trend(row.get('云仓3天平均销量', 0), row.get('云仓7天平均销量', 0), row.get('云仓14天平均销量', 0)) + ) + row['外仓日销量预测'] = _cloud_forecast( + row.get('外仓3天平均销量', 0), row.get('外仓7天平均销量', 0), + row.get('外仓14天平均销量', 0), _safe_get(es, pc, '30天最高销量'), + _determine_trend(row.get('外仓3天平均销量', 0), row.get('外仓7天平均销量', 0), row.get('外仓14天平均销量', 0)) + ) + + _calc_support_days(row, gc) + + # 云仓/外仓支撑天数 =(本仓库存 + 本仓可分配库存)/ 本仓日销量预测 + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + cloud_forecast = _num(row.get('云仓日销量预测', 0)) + ext_forecast = _num(row.get('外仓日销量预测', 0)) + cloud_days_base = _num(row.get('云仓库存', 0)) + _num(row.get('云仓可分配库存', 0)) + ext_days_base = _num(row.get('外仓库存', 0)) + _num(row.get('外仓可分配库存', 0)) + row['云仓支撑天数'] = round(cloud_days_base / cloud_forecast, 1) if cloud_forecast > 0 else 999 + row['外仓支撑天数'] = round(ext_days_base / ext_forecast, 1) if ext_forecast > 0 else 999 + + _calc_suggested_qty(row, gc) + + return row + + +def _calc_support_days(row, gc): + """计算库存支撑天数、健康指数、建议下单等""" + safety_days = int(gc.get('safety_stock_days', '7')) + return_days = int(gc.get('suggested_return_days', '15')) + forecast_val = row.get('日销量预测', 0) + fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0 + + inv_val = float(row.get('库存', 0) or 0) + inv15_val = float(row.get('加工厂库存', 0) or 0) + prod_transit = float(row.get('生产在途', 0) or 0) + purch_transit = float(row.get('采购在途', 0) or 0) + + # 库存可支撑天数 + if fcast == 0: + row['库存可支撑天数'] = 999 + row['库存工厂可支撑天数'] = 999 + row['库存工厂待生产可支撑天数'] = 999 + row['加工厂库存支撑天数'] = 999 + row['总支撑天数'] = 999 + else: + row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999 + row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999 + row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999 + row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999 + total_inv = inv_val + inv15_val + prod_transit + purch_transit + row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999 + + # 库存健康指数 + sup_days = row.get('库存可支撑天数', 999) + s1 = sup_days if isinstance(sup_days, (int, float)) and sup_days != 999 else None + lead_h = float(row.get('生产货期', 0) or 0) + logis_h = float(row.get('物流时间', 0) or 0) + if s1 is not None and (lead_h + logis_h) > 0: + health = s1 / (lead_h + logis_h) + row['库存健康指数'] = round(min(health, 999), 1) + else: + row['库存健康指数'] = 999 + + # 风险把控 + created = row.get('创建时间', '') + sup_days_val = row.get('库存可支撑天数', 999) + try: + sup_days_val = float(sup_days_val) + except: + sup_days_val = 999 + + days_since_created = 999 + try: + if created: + if isinstance(created, str): + created_dt = datetime.strptime(created[:10], '%Y-%m-%d') + else: + created_dt = created + days_since_created = (datetime.now() - created_dt).days + except: + pass + + if days_since_created < 90: + row['风险把控'] = '新品' + elif days_since_created > 180 and sup_days_val > 240: + row['风险把控'] = '需启动清库存' + elif days_since_created > 90 and sup_days_val > 90: + row['风险把控'] = '呆滞品' + else: + row['风险把控'] = '正常' + + # 建议下单数量 / 实际下单数量:需等云仓、外仓支撑天数算完后由 _calc_suggested_qty 计算 + + # 建议回货数量 + total_transit = prod_transit + purch_transit + if fcast > 0 and total_transit > 0: + calc_val = fcast * return_days - inv_val + round_up = int(calc_val * 10 + 0.99999) / 10 + if round_up < 0: + round_up = 0 + row['建议回货数量'] = round_up if round_up <= total_transit else total_transit + if row['建议回货数量'] < 0: + row['建议回货数量'] = 0 + else: + row['建议回货数量'] = 0 + + +def _calc_suggested_qty(row, gc): + """按日销量预测、总支撑天数和产品类型计算建议下单数量。""" + safety_days = int(gc.get('safety_stock_days', '7')) + + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return 0.0 + + lead_days = _num(row.get('生产货期', 0)) + logistics_days = _num(row.get('物流时间', 0)) + threshold = lead_days + logistics_days + safety_days + + forecast_val = row.get('日销量预测', 0) + daily_forecast = 0.0 if forecast_val in ('近期无销售', 0, '0', None) else _num(forecast_val) + total_support_days = _num(row.get('总支撑天数', 999)) + product_type = str(row.get('product_type') or '').strip() + + if daily_forecast == 0 or total_support_days >= threshold: + qty = 0 + elif product_type == '现货': + qty = int(daily_forecast * (lead_days + logistics_days)) + else: + qty = int(daily_forecast * threshold) + + row['建议下单数量'] = qty + # 等同 Excel MROUND(建议下单数量, 500):四舍五入到最接近的 500 倍数 + row['实际下单数量'] = ((qty + 250) // 500) * 500 if qty > 0 else 0 + return row + + +def _merge_pending_formal_qty(rows): + """从 pending_orders 表合并正式下单数量到规划数据行""" + if not rows: + return + codes = [r.get('product_code', '') for r in rows if r.get('product_code')] + if not codes: + return + placeholders = ','.join(['%s'] * len(codes)) + pending = query(f"SELECT product_code, formal_order_qty FROM pending_orders WHERE status IN ('pending','draft') AND product_code IN ({placeholders})", codes) + qty_map = {r['product_code']: int(r['formal_order_qty'] or 0) for r in pending} + for row in rows: + pc = row.get('product_code', '') + if pc in qty_map and qty_map[pc] > 0: + row['正式下单数量'] = qty_map[pc] + + +# ─── 列宽与列方案管理 ─── +ORDER_WIDTH_PAGES = {'planning', 'purchase', 'purchase_ready', 'orders_finished', 'orders_domestic', 'orders_overseas', 'orders_packaging', 'new_products'} + + +@planning_bp.route('/api/planning/column-widths') +@login_required +def planning_column_widths(): + page = request.args.get('page', '') + if page not in ORDER_WIDTH_PAGES: + return jsonify({"code": 400, "msg": "页面参数错误"}) + rows = query( + "SELECT field_name, width FROM user_column_widths WHERE user_id=%s AND page=%s", + (session['user_id'], page) + ) + widths = {row['field_name']: int(row['width']) for row in rows} + return jsonify({"code": 0, "data": widths}) + + +@planning_bp.route('/api/planning/column-widths/save', methods=['POST']) +@login_required +def planning_column_widths_save(): + data = request.get_json(silent=True) or {} + page = data.get('page', '') + widths = data.get('widths') + if page not in ORDER_WIDTH_PAGES: + return jsonify({"code": 400, "msg": "页面参数错误"}) + if not isinstance(widths, dict) or not widths: + return jsonify({"code": 400, "msg": "列宽数据不能为空"}) + if len(widths) > 100: + return jsonify({"code": 400, "msg": "列宽字段数量超出限制"}) + + cleaned = [] + for field_name, width in widths.items(): + field_name = str(field_name).strip() + try: + width = int(round(float(width))) + except (TypeError, ValueError): + return jsonify({"code": 400, "msg": "列宽格式错误"}) + if not field_name or len(field_name) > 100 or width < 60 or width > 2000: + return jsonify({"code": 400, "msg": "列宽数据超出允许范围"}) + cleaned.append((session['user_id'], page, field_name, width)) + + placeholders = ','.join(['(%s,%s,%s,%s)'] * len(cleaned)) + params = tuple(value for row in cleaned for value in row) + query( + "INSERT INTO user_column_widths (user_id, page, field_name, width) VALUES " + + placeholders + + " ON DUPLICATE KEY UPDATE width=VALUES(width), updated_at=CURRENT_TIMESTAMP", + params + ) + return jsonify({"code": 0, "msg": "保存成功"}) + + +@planning_bp.route('/api/planning/schemes') +@login_required +def planning_schemes(): + user_id = session['user_id'] + page = request.args.get('page') + if page: + schemes = query( + "SELECT id, user_id, page, scheme_name, columns_json, is_default, created_at, updated_at " + "FROM user_column_schemes WHERE user_id=%s AND page=%s ORDER BY is_default DESC, id ASC", + (user_id, page) + ) + else: + schemes = query( + "SELECT id, user_id, page, scheme_name, columns_json, is_default, created_at, updated_at " + "FROM user_column_schemes WHERE user_id=%s ORDER BY is_default DESC, id ASC", + (user_id,) + ) + return jsonify({"code": 0, "data": schemes}) + + +@planning_bp.route('/api/planning/scheme/save', methods=['POST']) +@login_required +def planning_scheme_save(): + user_id = session['user_id'] + data = request.json + scheme_name = data.get('scheme_name', '').strip() + columns_json = data.get('columns_json', '') + page = data.get('page', 'planning') + is_default = int(data.get('is_default', 0)) + + if not scheme_name: + return jsonify({"code": 400, "msg": "方案名称不能为空"}) + if not columns_json: + return jsonify({"code": 400, "msg": "字段配置不能为空"}) + + try: + existing = query( + "SELECT id FROM user_column_schemes WHERE user_id=%s AND page=%s AND scheme_name=%s", + (user_id, page, scheme_name) + ) + if existing: + query( + "UPDATE user_column_schemes SET columns_json=%s, is_default=%s WHERE id=%s", + (columns_json, is_default, existing[0]['id']) + ) + scheme_id = existing[0]['id'] + else: + scheme_id = query( + "INSERT INTO user_column_schemes (user_id, page, scheme_name, columns_json, is_default) " + "VALUES (%s, %s, %s, %s, %s)", + (user_id, page, scheme_name, columns_json, is_default) + ) + return jsonify({"code": 0, "msg": "保存成功", "data": {"id": scheme_id}}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +@planning_bp.route('/api/planning/scheme/delete', methods=['POST']) +@login_required +def planning_scheme_delete(): + user_id = session['user_id'] + data = request.json + scheme_id = data.get('id') + if not scheme_id: + return jsonify({"code": 400, "msg": "参数错误"}) + query("DELETE FROM user_column_schemes WHERE id=%s AND user_id=%s", (scheme_id, user_id)) + return jsonify({"code": 0, "msg": "删除成功"}) + + +@planning_bp.route('/api/planning/scheme/set_default', methods=['POST']) +@login_required +def planning_scheme_set_default(): + user_id = session['user_id'] + data = request.json + scheme_id = data.get('id') + page = data.get('page', 'planning') + if not scheme_id: + return jsonify({"code": 400, "msg": "参数错误"}) + try: + query("UPDATE user_column_schemes SET is_default=0 WHERE user_id=%s AND page=%s", (user_id, page)) + query("UPDATE user_column_schemes SET is_default=1 WHERE id=%s AND user_id=%s", (scheme_id, user_id)) + return jsonify({"code": 0, "msg": "设置成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}) + + +# ─── API: 计划预测主列表(492行核心) ─── +@planning_bp.route('/api/planning/list') +@login_required +def planning_list(): + # 加载全局配置 + gc = _ensure_global_config() + + page = int(request.args.get('page', 1)) + size = int(request.args.get('size', 50)) + offset = (page - 1) * size + + search = request.args.get('search', '') + prod_code = request.args.get('prod_code', '') + prod_name = request.args.get('prod_name', '') + brand_filter = request.args.get('brand', '') + type_filter = request.args.get('product_type', '') + category_filter = request.args.get('category', '') + trend_filter = request.args.get('trend', '') + supplier_filter = request.args.get('supplier', '') + operator_filter = request.args.get('operator', '') + platform_filter = request.args.get('platform', '') + planner_filter = request.args.get('procurement_planner', '') + follower_filter = request.args.get('procurement_follower', '') + # The planning page uses personnel groups as the display/filter vocabulary. + GROUP_TO_MEMBERS = _personnel_group_members() + view_filter = request.args.get('view', 'default') + grade_filter = request.args.get('product_grade', '') + custom_field = request.args.get('custom_field', '') + custom_value = request.args.get('custom_value', '') + date_cond = request.args.get('date_cond', '') + date_val = request.args.get('date_val', '') + sort_by = request.args.get('sort_by', '') + sort_order = request.args.get('sort_order', 'asc') + + where = [] + params = [] + + if search: + where.append("(p.product_name LIKE %s OR p.product_code LIKE %s)") + params.extend([f"%{search}%", f"%{search}%"]) + if prod_code: + where.append("p.product_code LIKE %s") + params.append(f"%{prod_code}%") + if prod_name: + where.append("p.product_name LIKE %s") + params.append(f"%{prod_name}%") + if brand_filter: + where.append("p.brand=%s") + params.append(brand_filter) + + # 解析多选筛选条件,将数据库字段的筛选转成 SQL WHERE 条件 + if custom_field == '__multi__' and custom_value and isinstance(custom_value, str): + json_str = custom_value + if json_str.startswith('__multi__:'): + json_str = json_str[len('__multi__:'):] + try: + import json + multi_filters = json.loads(json_str) + for mf in multi_filters: + ff = mf.get('field', '') + ft = mf.get('type', '') + values = mf.get('values', []) + if not ff: + continue + if ft != 'num' and not values: + continue + # 人员分组从人员信息管理表实时读取,避免前端/后端维护多套名单 + GROUP_TO_MEMBERS = _personnel_group_members() + PERSONNEL_GROUPS = list(GROUP_TO_MEMBERS.items()) + db_col_map = { + 'product_type': 'p.product_type', + 'product_code': 'p.product_code', + 'product_name': 'p.product_name', + 'brand': 'p.brand', + 'procurement_follower': 'p.procurement_follower', + '采购跟单': 'p.procurement_follower', + 'procurement_planner': 'p.procurement_planner', + '采购计划': 'p.procurement_planner', + '成本价': 'p.cost_price', + '生产货期': 'p.lead_time', + '物流时间': 'p.logistics_time', + '最低起订量': 'p.min_order_qty', + } + db_col = db_col_map.get(ff) + if ft == 'search': + val = mf.get('value', '') + if val and db_col: + like_conds = [] + for v in [val]: + like_conds.append(f"{db_col} LIKE %s") + params.append(f"%{v}%") + if like_conds: + where.append("(" + " OR ".join(like_conds) + ")") + elif ft == 'num': + # 数字列筛选:生成 SQL 条件(全量过滤) + _cond_op = mf.get('cond', '') + _cond_val = mf.get('val', '') + _op_map = {'eq':'=','neq':'!=','gt':'>','gte':'>=','lt':'<','lte':'<='} + if _cond_op == 'range' and _cond_val: + _parts = [x.strip() for x in _cond_val.split(',') if x.strip()] + if len(_parts) == 2 and db_col: + where.append(f"{db_col} >= %s AND {db_col} <= %s") + params.extend([float(_parts[0]), float(_parts[1])]) + elif _cond_op in _op_map and db_col: + try: + _nv = float(_cond_val) + where.append(f"{db_col} {_op_map[_cond_op]} %s") + params.append(_nv) + except ValueError: + pass + elif db_col and ft in ('list', 'text'): + if ff == 'product_name': + like_conds = [] + for v in values: + like_conds.append(f"{db_col} LIKE %s") + params.append(f"%{v}%") + if like_conds: + where.append("(" + " OR ".join(like_conds) + ")") + elif ff in ('procurement_follower', '采购跟单'): + # 人员字段:将组名展开为具体人名做 LIKE 匹配 + expanded = [] + for v in values: + # Products may store either the current group label or legacy member names. + # Match both so the SQL pre-filter does not discard valid group rows. + expanded.append(v) + if v in GROUP_TO_MEMBERS: + expanded.extend(GROUP_TO_MEMBERS[v]) + like_conds = [] + for name in expanded: + like_conds.append(f"{db_col} LIKE %s") + params.append('%' + name + '%') + if like_conds: + where.append("(" + " OR ".join(like_conds) + ")") + else: + placeholders = ', '.join(['%s'] * len(values)) + where.append(f"{db_col} IN ({placeholders})") + params.extend(values) + except (json.JSONDecodeError, TypeError): + pass + + # 与成品基础信息表保持一致:排除包材和其他类 + where.append("p.product_type NOT IN ('包材', '其他')") + where.append("p.product_code NOT IN (SELECT product_code FROM plan_hidden)") + where.append("p.deleted_at IS NULL") + # 国内品/海外品视图过滤(按品牌区分) + OVERSEAS_BRANDS = ['Qsimelr', 'PUNOMO', 'Aromat Herbs', 'MUTO', 'sedate', 'SEDATE-WOPUKA', 'WOPUKA', 'UOUONE', 'AVAA', 'MOONOTE', 'CIKITI'] + if view_filter == 'cloud': + # 国内品:品牌不在海外品列表中的 + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"(p.brand NOT IN ({placeholders}) OR p.brand IS NULL OR p.brand = '')") + params.extend(OVERSEAS_BRANDS) + elif view_filter == 'external': + # 海外品:品牌在海外品列表中的 + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"p.brand IN ({placeholders})") + params.extend(OVERSEAS_BRANDS) + where_clause = " AND ".join(where) if where else "1=1" + + total = query(f"SELECT COUNT(*) AS cnt FROM products p WHERE {where_clause}", params)[0]['cnt'] + + # Debug: check params count + if total == 2299 and view_filter == 'cloud': + # The brand filter might not be applied - check view_filter value + pass + + need_full_fetch = (type_filter or category_filter or trend_filter or + supplier_filter or operator_filter or platform_filter or + planner_filter or follower_filter or grade_filter or + custom_field or date_cond or bool(sort_by) or view_filter != 'default') + + if need_full_fetch: + # 全量捞取 + Python端筛选排序 + all_rows = query(f""" + SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, + p.cost_price, + p.lead_time AS 生产货期, p.logistics_time AS 物流时间, p.min_order_qty AS 最低起订量, p.created_at AS 创建时间, + COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, p.procurement_follower AS 采购跟单, + p.procurement_planner AS 采购计划, p.operator AS 运营专员, + p.developer AS 开发专员, + p.platform AS 归属平台, + p.category1 AS 一级类目, p.category2 AS 二级类目, p.category3 AS 三级类目, + p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, + p.remark AS 批注 + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} + ORDER BY p.product_code ASC + """, params) + + # 全量计算辅助数据 + sales = compute_product_sales_cached() + inv_map = compute_product_inventory_cached() + inv15_map = compute_product_inventory_15() + transit_map = compute_product_in_transit_cached() + cloud_inv_map = compute_cloud_inventory_cached() + ext_inv_map = compute_external_inventory_cached() + extended_sales = compute_product_sales_extended_cached() + sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} + + filtered = [] + for row in all_rows: + if '创建时间' in row and row['创建时间']: + row['创建时间'] = row['创建时间'].strftime('%Y-%m-%d') if hasattr(row['创建时间'], 'strftime') else str(row['创建时间'])[:10] + if row.get('生产货期') is None: + row['生产货期'] = 0 + if row.get('物流时间') is None: + row['物流时间'] = 0 + row['产品种类'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) + + row = _enrich_row_with_planning_data(row, sales, inv_map, inv15_map, transit_map, sp_map, gc, + cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map, + extended_sales=extended_sales) + + # 筛选 + match = True + if match and type_filter and type_filter.lower() not in (row.get('product_type') or '').lower(): + match = False + if match and category_filter and category_filter.lower() not in (row.get('产品种类') or '').lower(): + match = False + if match and trend_filter and trend_filter.lower() not in (row.get('销售趋势') or '').lower(): + match = False + if match and supplier_filter and supplier_filter.lower() not in (row.get('供应商名称') or '').lower(): + match = False + if match and operator_filter and operator_filter.lower() not in (row.get('运营专员') or '').lower(): + match = False + if match and platform_filter and platform_filter.lower() not in (row.get('归属平台') or '').lower(): + match = False + if match and planner_filter and planner_filter.lower() not in (row.get('采购计划') or '').lower(): + match = False + if match and follower_filter and not _personnel_value_matches_group( + row.get('采购跟单'), follower_filter, GROUP_TO_MEMBERS): + match = False + if match and grade_filter and grade_filter.lower() not in (row.get('产品分级') or '').lower(): + match = False + if match and custom_field and custom_value: + multi_filters = [] + if custom_field == '__multi__' and isinstance(custom_value, str): + # 支持 __multi__:JSON 格式 + json_str = custom_value + if json_str.startswith('__multi__:'): + json_str = json_str[len('__multi__:'):] + try: + import json + multi_filters = json.loads(json_str) + except (json.JSONDecodeError, TypeError): + pass + else: + multi_filters = [{'field': custom_field, 'type': 'list' if ',' in custom_value else 'text', 'values': [custom_value]}] + if isinstance(custom_value, str) and custom_value.startswith('__num_filter__'): + expr = custom_value[len('__num_filter__'):] + parts = expr.split(':', 1) + multi_filters = [{'field': custom_field, 'type': 'num', 'cond': parts[0], 'val': parts[1] if len(parts) > 1 else ''}] + + for f in multi_filters: + ft = f.get('type', '') + ff = f.get('field', custom_field) + if ft == 'num': + cond = f.get('cond', '') + val_str = f.get('val', '') + row_val = row.get(ff) + try: + row_num = float(row_val) if row_val not in (None, '', 0) else 0 + if cond == 'eq': + match = row_num == float(val_str) + elif cond == 'neq': + match = row_num != float(val_str) + elif cond == 'gt': + match = row_num > float(val_str) + elif cond == 'gte': + match = row_num >= float(val_str) + elif cond == 'lt': + match = row_num < float(val_str) + elif cond == 'lte': + match = row_num <= float(val_str) + elif cond == 'range': + vals = [float(v.strip()) for v in val_str.split(',') if v.strip()] + if len(vals) >= 2: + match = vals[0] <= row_num <= vals[1] + except (ValueError, TypeError): + match = False + elif ft == 'search': + val = f.get('value', '') + if val: + row_val = str(row.get(ff) or '').lower() + if val.lower() not in row_val: + match = False + elif ft == 'list': + values_list = [v.lower() for v in f.get('values', []) if v] + if values_list: + raw_row_val = row.get(ff) + row_val = '' if raw_row_val is None else str(raw_row_val).lower() + if '__empty__' in values_list and row_val == '': + continue + # 人员字段:将组名展开为具体人名做匹配 + if ff in ('procurement_follower', '采购跟单'): + selected_groups = [ + (group_name.casefold(), members) + for group_name, members in PERSONNEL_GROUPS + if group_name.casefold() in values_list + ] + if selected_groups: + row_group = _personnel_group_for_value(raw_row_val, GROUP_TO_MEMBERS) + if not any(row_group == group_name for group_name, _ in selected_groups): + match = False + else: + names = [name.strip().casefold() for name in str(raw_row_val or '').split(',') if name.strip()] + if not any(v in names for v in values_list): + match = False + elif ff in ( + '昨日销量','云仓昨日销量','外仓昨日销量','库存','云仓库存','外仓库存','加工厂库存', + '总库存','总在途库存','生产在途','采购在途','云仓可分配库存','外仓可分配库存', + '云仓支撑天数','外仓支撑天数', + '日销量预测','云仓日销量预测','外仓日销量预测', + '建议下单数量','实际下单数量','正式下单数量','建议回货数量','库存健康指数','成本价', + '生产货期','物流时间','最低起订量','3天平均销量','云仓3天平均销量','外仓3天平均销量', + '7天平均销量','云仓7天平均销量','外仓7天平均销量','14天平均销量','云仓14天平均销量', + '外仓14天平均销量','30天平均销量','60天平均销量','3天最高销量','7天最高销量', + '14天最高销量','30天最高销量','14天最低销量','14天标准差','库存可支撑天数', + '库存工厂可支撑天数','加工厂库存支撑天数','库存工厂待生产可支撑天数','总支撑天数', + '最低销量偏差率','最高销量偏差率','14天波动率' + ): + # 数字列内容筛选必须按数值精确匹配,不能用字符串包含(0 不应命中 30) + try: + row_num = float(raw_row_val) if raw_row_val not in (None, '') else 0.0 + selected_nums = [float(v) for v in values_list] + if row_num not in selected_nums: + match = False + except (ValueError, TypeError): + match = False + else: + if row_val == '': + # 空值不得命中任何具体选项(勾选「(空)」的情况已在上面 continue 处理) + match = False + elif not any(v == row_val or v in row_val for v in values_list): + # 只允许「完全相等」或「单元格含该选项」,不再反向用单元格去包含选项 + match = False + if not match: + break + if match and date_cond and date_val: + create_date = row.get('创建时间', '') + if not create_date: + match = False + else: + if isinstance(create_date, str): + create_date = create_date[:10] + else: + create_date = str(create_date)[:10] + if date_cond == 'lt': + if create_date >= date_val: + match = False + elif date_cond == 'gt': + if create_date <= date_val: + match = False + elif date_cond == 'eq': + if create_date != date_val: + match = False + + if match: + filtered.append(row) + + if sort_by: + rev = sort_order.lower() != 'asc' + grade_order = {'S级': 0, 'A级': 1, 'B级': 2, 'C级': 3, '不参与评级': 4} + def sort_key(row): + val = row.get(sort_by) + if sort_by == '产品分级': + return grade_order.get(val, 99) + if val is None or val == '': + return float('-inf') if rev else float('inf') + try: + return float(val) + except (ValueError, TypeError): + return str(val).lower() + filtered.sort(key=sort_key, reverse=rev) + + filtered_total = len(filtered) + page_rows = filtered[offset:offset + size] + print(f'[PLAN_DEBUG] view={view_filter} filtered_len={filtered_total} page_rows={len(page_rows)} all_rows={len(all_rows)}', flush=True) + + all_codes = [r['product_code'] for r in filtered] + _merge_pending_formal_qty(page_rows) + _filter_field_rows('planning', page_rows, session.get('user_id'), session.get('role'), + page_view=bool(role_required('planning')), + page_edit=operation_required('planning', 'edit')) + return jsonify({"code": 0, "data": {"total": filtered_total, "page": page, "size": size, "rows": page_rows, "all_codes": all_codes}}) + else: + # SQL分页 + 少量Python计算 + rows = query(f""" + SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, + p.cost_price, + p.lead_time AS 生产货期, p.logistics_time AS 物流时间, p.min_order_qty AS 最低起订量, p.created_at AS 创建时间, + COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, p.procurement_follower AS 采购跟单, + p.procurement_planner AS 采购计划, p.operator AS 运营专员, + p.developer AS 开发专员, + p.platform AS 归属平台, + p.category1 AS 一级类目, p.category2 AS 二级类目, p.category3 AS 三级类目, + p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, + p.remark AS 批注 + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} + ORDER BY p.id DESC + LIMIT {size} OFFSET {offset} + """, params) + + for row in rows: + if '创建时间' in row and row['创建时间']: + row['创建时间'] = row['创建时间'].strftime('%Y-%m-%d') if hasattr(row['创建时间'], 'strftime') else str(row['创建时间'])[:10] + if row.get('生产货期') is None: + row['生产货期'] = 0 + if row.get('物流时间') is None: + row['物流时间'] = 0 + row['产品种类'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) + + # 批量计算辅助数据 + sales = compute_product_sales_cached() + inv_map = compute_product_inventory_cached() + inv15_map = compute_product_inventory_15() + transit_map = compute_product_in_transit_cached() + cloud_inv_map = compute_cloud_inventory_cached() + ext_inv_map = compute_external_inventory_cached() + extended_sales = compute_product_sales_extended_cached() + sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} + + for row in rows: + row = _enrich_row_with_planning_data(row, sales, inv_map, inv15_map, transit_map, sp_map, gc, + cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map, + extended_sales=extended_sales) + + # 获取所有匹配的产品编号(用于全选) + all_codes_rows = query(f"SELECT product_code FROM products p WHERE {where_clause}", params) + all_codes = [r['product_code'] for r in all_codes_rows] + _merge_pending_formal_qty(rows) + _filter_field_rows('planning', rows, session.get('user_id'), session.get('role'), + page_view=bool(role_required('planning')), + page_edit=operation_required('planning', 'edit')) + return jsonify({"code": 0, "data": {"total": total, "page": page, "size": size, "rows": rows, "all_codes": all_codes}}) + + +@planning_bp.route('/api/planning/export', methods=['POST']) +@login_required +def planning_export(): + """导出计划预测全部数据(Excel) + 请求体: {view, sort_by, sort_order, columns: [{field,label}]} + """ + import io + import openpyxl + from openpyxl.styles import Font, Alignment, Border, Side, PatternFill + from openpyxl.utils import get_column_letter + + body = request.get_json(silent=True) or {} + gc = _ensure_global_config() + + view_filter = body.get('view', 'default') + sort_by = body.get('sort_by', '') + sort_order = body.get('sort_order', 'asc') + columns = body.get('columns') or [] + # 防止旧客户端/脚本未传字段方案时回退为全字段导出;空列请求直接拒绝。 + if not columns: + return jsonify({"code": 400, "msg": "请先在字段设置中选择要导出的字段"}), 400 + # 只允许计划预测字段,排除操作列及未知字段。 + allowed_fields = { + 'product_type','产品种类','product_code','product_name','brand','批注','产品分级', + '昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存', + '云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨', + '生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量', + '实际下单数量','正式下单数量','库存健康指数','风险把控','供应商名称','采购跟单', + '采购计划','开发专员','归属平台','运营专员','cost_price','3天平均销量','云仓3天平均销量', + '外仓3天平均销量','7天平均销量','云仓7天平均销量','外仓7天平均销量','14天平均销量', + '云仓14天平均销量','外仓14天平均销量','库存可支撑天数','库存工厂可支撑天数', + '加工厂库存支撑天数','库存工厂待生产可支撑天数','总支撑天数','云仓支撑天数','外仓支撑天数', + '建议回货数量','specification', + '30天平均销量','60天平均销量','3天最高销量','7天最高销量','14天最高销量','30天最高销量', + '14天最低销量','14天标准差','最低销量偏差率','最高销量偏差率','极值判断','14天波动率', + '波动风险','生产货期','物流时间','最低起订量','创建时间','是否新品','一级类目','二级类目','三级类目' + } + columns = [c for c in columns if isinstance(c, dict) and c.get('field') in allowed_fields] + if not columns: + return jsonify({"code": 400, "msg": "没有可导出的有效字段"}), 400 + + # ── 基础 where(与列表一致)── + where = [] + params = [] + where.append("p.product_type NOT IN ('包材', '其他')") + where.append("p.product_code NOT IN (SELECT product_code FROM plan_hidden)") + where.append("p.deleted_at IS NULL") + OVERSEAS_BRANDS = ['Qsimelr', 'PUNOMO', 'Aromat Herbs', 'MUTO', 'sedate', 'SEDATE-WOPUKA', 'WOPUKA', 'UOUONE', 'AVAA', 'MOONOTE', 'CIKITI'] + if view_filter == 'cloud': + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"(p.brand NOT IN ({placeholders}) OR p.brand IS NULL OR p.brand = '')") + params.extend(OVERSEAS_BRANDS) + elif view_filter == 'external': + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"p.brand IN ({placeholders})") + params.extend(OVERSEAS_BRANDS) + where_clause = " AND ".join(where) if where else "1=1" + + # ── 全量拉取(与列表全量分支一致)── + all_rows = query(f""" + SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, + p.cost_price, + p.lead_time AS 生产货期, p.logistics_time AS 物流时间, p.min_order_qty AS 最低起订量, p.created_at AS 创建时间, + COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, p.procurement_follower AS 采购跟单, + p.procurement_planner AS 采购计划, p.operator AS 运营专员, + p.developer AS 开发专员, + p.platform AS 归属平台, + p.category1 AS 一级类目, p.category2 AS 二级类目, p.category3 AS 三级类目, + p.custom_field1, p.custom_field2, p.custom_field3, p.custom_field4, p.custom_field5, + p.remark AS 批注 + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} + ORDER BY p.product_code ASC + """, params) + + sales = compute_product_sales_cached() + inv_map = compute_product_inventory_cached() + inv15_map = compute_product_inventory_15() + transit_map = compute_product_in_transit_cached() + cloud_inv_map = compute_cloud_inventory_cached() + ext_inv_map = compute_external_inventory_cached() + extended_sales = compute_product_sales_extended_cached() + sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} + + rows = [] + for row in all_rows: + if '创建时间' in row and row['创建时间']: + row['创建时间'] = row['创建时间'].strftime('%Y-%m-%d') if hasattr(row['创建时间'], 'strftime') else str(row['创建时间'])[:10] + if row.get('生产货期') is None: + row['生产货期'] = 0 + if row.get('物流时间') is None: + row['物流时间'] = 0 + row['产品种类'] = get_product_type_cached(row.get('product_name', ''), row.get('brand', ''), row.get('product_type')) + row = _enrich_row_with_planning_data(row, sales, inv_map, inv15_map, transit_map, sp_map, gc, + cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map, + extended_sales=extended_sales) + rows.append(row) + + if sort_by: + rev = sort_order.lower() != 'asc' + grade_order = {'S级': 0, 'A级': 1, 'B级': 2, 'C级': 3, '不参与评级': 4} + def sort_key(row): + val = row.get(sort_by) + if sort_by == '产品分级': + return grade_order.get(val, 99) + if val is None or val == '': + return float('-inf') if rev else float('inf') + try: + return float(val) + except (ValueError, TypeError): + return str(val).lower() + rows.sort(key=sort_key, reverse=rev) + + _merge_pending_formal_qty(rows) + + # ── 生成 Excel ── + wb = openpyxl.Workbook() + ws = wb.active + ws.title = '计划预测' + + header_font = Font(bold=True, color='FFFFFF') + header_fill = PatternFill('solid', fgColor='1890FF') + thin = Side(style='thin', color='D9D9D9') + border = Border(left=thin, right=thin, top=thin, bottom=thin) + + ws.append([c.get('label', c.get('field', '')) for c in columns]) + for cell in ws[1]: + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal='center', vertical='center') + cell.border = border + + for row in rows: + vals = [] + for c in columns: + v = row.get(c.get('field', '')) + if v is None: + v = '' + vals.append(v) + ws.append(vals) + + for i, c in enumerate(columns, 1): + max_len = len(str(c.get('label', ''))) + for row in ws.iter_rows(min_row=2, min_col=i, max_col=i): + for cell in row: + if cell.value is not None: + max_len = max(max_len, len(str(cell.value))) + ws.column_dimensions[get_column_letter(i)].width = min(max(max_len * 1.6, 10), 40) + + for row in ws.iter_rows(min_row=2): + for cell in row: + cell.border = border + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + from flask import send_file + filename = f'计划预测_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx' + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + +# ─── 页面路由 ─── +@planning_bp.route('/planning') +@login_required +def planning_page(): + if not role_required('planning'): + return redirect(url_for('auth.index')) + return render_template('base.html', page='planning') + + +@planning_bp.route('/api/planning/hide', methods=['POST']) +@login_required +def planning_hide_product(): + """将产品从计划预测中隐藏""" + data = request.json + product_code = data.get('product_code', '') + if not product_code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + query("INSERT IGNORE INTO plan_hidden (product_code) VALUES (%s)", (product_code,)) + return jsonify({"code": 0, "msg": "已从计划预测中移除"}) + + +@planning_bp.route('/api/planning/unhide', methods=['POST']) +@login_required +def planning_unhide_product(): + """将产品恢复显示到计划预测""" + data = request.json + product_code = data.get('product_code', '') + if not product_code: + return jsonify({"code": 400, "msg": "缺少货品编号"}) + query("DELETE FROM plan_hidden WHERE product_code=%s", (product_code,)) + return jsonify({"code": 0, "msg": "已恢复显示"}) + + +@planning_bp.route('/api/product-types') +def product_type_list(): + """返回所有产品类型列表""" + from services.db import query + rows = query("SELECT DISTINCT product_type FROM products WHERE deleted_at IS NULL AND product_type IS NOT NULL AND product_type != '' ORDER BY product_type") + types = [r['product_type'] for r in rows] + return jsonify({"code": 0, "data": types}) + + +@planning_bp.route('/api/planning/brands') +@login_required +def planning_brands(): + """获取计划预测页面的品牌列表(按当前视图过滤,支持联动筛选)""" + view_filter = request.args.get('view', 'default') + plan_filter_str = request.args.get('plan_filter', '') or '' + where = ["product_type NOT IN ('包材', '其他')"] + where.append("product_code NOT IN (SELECT product_code FROM plan_hidden)") + where.append("p.deleted_at IS NULL") + OVERSEAS_BRANDS = ['Qsimelr', 'PUNOMO', 'Aromat Herbs', 'MUTO', 'sedate', 'SEDATE-WOPUKA', 'WOPUKA', 'UOUONE', 'AVAA', 'MOONOTE', 'CIKITI'] + if view_filter == 'cloud': + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"(brand NOT IN ({placeholders}) OR brand IS NULL OR brand = '')") + params = list(OVERSEAS_BRANDS) + elif view_filter == 'external': + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"brand IN ({placeholders})") + params = list(OVERSEAS_BRANDS) + else: + params = [] + # 联动筛选 + if plan_filter_str: + try: + import json + _pfs = json.loads(plan_filter_str) + _pms = { + 'product_code': 'p.product_code', 'product_name': 'p.product_name', + 'product_type': 'p.product_type', 'specification': 'p.specification', + '供应商名称': "COALESCE(s.supplier_name, p.supplier_name_match)", + '采购跟单': 'p.procurement_follower', '采购计划': 'p.procurement_planner', + '运营专员': 'p.operator', '开发专员': 'p.developer', + '归属平台': 'p.platform', '一级类目': 'p.category1', + '二级类目': 'p.category2', '三级类目': 'p.category3', + '成本价': 'p.cost_price', '生产货期': 'p.lead_time', + '物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty', + } + for _k, _v in _pfs.items(): + if _k == 'brand': + continue + if not isinstance(_v, dict): + continue + _dbc = _pms.get(_k) + if not _dbc: + continue + _t = _v.get('type', '') + if _t == 'search': + _val = _v.get('value', '') + if _val: + where.append(_dbc + ' LIKE %s') + params.append('%' + _val + '%') + elif _t == 'list': + _vals = _v.get('values', []) + if _vals: + _non_empty = [x for x in _vals if x != '__EMPTY__'] + if _non_empty: + _phs = ', '.join(['%s'] * len(_non_empty)) + where.append(_dbc + ' IN (' + _phs + ')') + params.extend(_non_empty) + if '__EMPTY__' in _vals: + where.append('(' + _dbc + ' IS NULL OR ' + _dbc + " = '')") + except: + pass + where_clause = " AND ".join(where) + from services.db import query + if params: + rows = query(f"SELECT DISTINCT brand FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND brand IS NOT NULL AND brand != '' ORDER BY brand", params) + else: + rows = query(f"SELECT DISTINCT brand FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND brand IS NOT NULL AND brand != '' ORDER BY brand") + if params: + rows = query(f"SELECT DISTINCT brand FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND brand IS NOT NULL AND brand != '' ORDER BY brand", params) + else: + rows = query(f"SELECT DISTINCT brand FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND brand IS NOT NULL AND brand != '' ORDER BY brand") + vals = [r['brand'] for r in rows] + emptySql = "SELECT COUNT(*) AS cnt FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE " + where_clause + " AND (brand IS NULL OR brand = '')" + emptyRows = query(emptySql, params) if params else query(emptySql) + if emptyRows and emptyRows[0]['cnt'] > 0: + vals.append('__EMPTY__') + return jsonify({"code": 0, "data": vals}) + + +@planning_bp.route('/api/planning/column-values') +@login_required +def planning_column_values(): + """获取计划预测页面指定列的所有唯一值(全量,支持联动筛选)""" + field = request.args.get('field', '') + view_filter = request.args.get('view', 'default') + plan_filter_str = request.args.get('plan_filter', '') or '' + if not field: + return jsonify({"code": 400, "msg": ":缺少field参数"}) + + OVERSEAS_BRANDS = ['Qsimelr', 'PUNOMO', 'Aromat Herbs', 'MUTO', 'sedate', 'SEDATE-WOPUKA', 'WOPUKA', 'UOUONE', 'AVAA', 'MOONOTE', 'CIKITI'] + where = ["p.product_type NOT IN ('包材', '其他')"] + where.append("p.product_code NOT IN (SELECT product_code FROM plan_hidden)") + where.append("p.deleted_at IS NULL") + if view_filter == 'cloud': + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"(p.brand NOT IN ({placeholders}) OR p.brand IS NULL OR p.brand = '')") + params = list(OVERSEAS_BRANDS) + elif view_filter == 'external': + placeholders = ','.join(['%s'] * len(OVERSEAS_BRANDS)) + where.append(f"p.brand IN ({placeholders})") + params = list(OVERSEAS_BRANDS) + else: + params = [] + + # 联动筛选:解析 plan_filter 并追加条件 + if plan_filter_str: + try: + import json + _pfs = json.loads(plan_filter_str) + _pms = db_col_map = { + 'product_code': 'p.product_code', + 'product_name': 'p.product_name', + 'product_type': 'p.product_type', + 'specification': 'p.specification', + '供应商名称': "COALESCE(s.supplier_name, p.supplier_name_match)", + 'procurement_follower': 'p.procurement_follower', + '采购跟单': 'p.procurement_follower', + 'procurement_planner': 'p.procurement_planner', + '采购计划': 'p.procurement_planner', + '运营专员': 'p.operator', + '开发专员': 'p.developer', + '归属平台': 'p.platform', + '一级类目': 'p.category1', + '二级类目': 'p.category2', + '三级类目': 'p.category3', + '成本价': 'p.cost_price', + '生产货期': 'p.lead_time', + '物流时间': 'p.logistics_time', + '最低起订量': 'p.min_order_qty', + } + for _k, _v in _pfs.items(): + if _k == field: + continue + if not isinstance(_v, dict): + continue + _dbc = _pms.get(_k) + if not _dbc: + continue + _t = _v.get('type', '') + if _t == 'search': + _val = _v.get('value', '') + if _val: + where.append(_dbc + ' LIKE %s') + params.append('%' + _val + '%') + elif _t == 'list': + _vals = _v.get('values', []) + if _vals: + _non_empty = [x for x in _vals if x != '__EMPTY__'] + _parts = [] + if _non_empty: + if _k in ('procurement_follower', '采购跟单'): + _groups = _personnel_group_members() + _names = [] + for _selected in _non_empty: + _names.extend(_groups.get(_selected, [_selected])) + if _selected in _groups: + _names.append(_selected) + for _name in _names: + _parts.append(_dbc + ' LIKE %s') + params.append('%' + _name + '%') + else: + _phs = ', '.join(['%s'] * len(_non_empty)) + _parts.append(_dbc + ' IN (' + _phs + ')') + params.extend(_non_empty) + if '__EMPTY__' in _vals: + _parts.append('(' + _dbc + ' IS NULL OR ' + _dbc + " = '')") + if _parts: + where.append('(' + ' OR '.join(_parts) + ')') + except: + pass + + where_clause = " AND ".join(where) + + db_col_map = { + 'product_code': 'p.product_code', + 'product_name': 'p.product_name', + 'product_type': 'p.product_type', + 'specification': 'p.specification', + '供应商名称': "COALESCE(s.supplier_name, p.supplier_name_match)", + '采购跟单': 'p.procurement_follower', + '采购计划': 'p.procurement_planner', + '运营专员': 'p.operator', + '开发专员': 'p.developer', + '归属平台': 'p.platform', + '一级类目': 'p.category1', + '二级类目': 'p.category2', + '三级类目': 'p.category3', + '成本价': 'p.cost_price', + '生产货期': 'p.lead_time', + '物流时间': 'p.logistics_time', + '最低起订量': 'p.min_order_qty', + } + db_col = db_col_map.get(field) + + # 如果是计算字段(不在数据库表中),走完整查询+计算后提取 + COMPUTED_NUMERIC_FIELDS = { + '昨日销量','云仓昨日销量','外仓昨日销量', + '库存','云仓库存','外仓库存','加工厂库存','总库存','总在途库存', + '云仓可分配库存','外仓可分配库存', + '云仓支撑天数','外仓支撑天数', + '生产在途','采购在途', + '日销量预测','云仓日销量预测','外仓日销量预测', + '建议下单数量','实际下单数量','正式下单数量','建议回货数量', + '库存健康指数', + '3天平均销量','云仓3天平均销量','外仓3天平均销量', + '7天平均销量','云仓7天平均销量','外仓7天平均销量', + '14天平均销量','云仓14天平均销量','外仓14天平均销量', + '30天平均销量','60天平均销量', + '3天最高销量','7天最高销量','14天最高销量','30天最高销量', + '14天最低销量','14天标准差', + '库存可支撑天数','库存工厂可支撑天数','加工厂库存支撑天数', + '库存工厂待生产可支撑天数','总支撑天数', + '最低销量偏差率','最高销量偏差率','14天波动率', + } + if field in COMPUTED_NUMERIC_FIELDS: + # 计算字段:走全量查询+enrichment 后提取唯一值 + need_full_fetch = True + + if not db_col and field not in COMPUTED_NUMERIC_FIELDS: + return jsonify({"code": 0, "data": []}) + + from services.db import query + + if field in COMPUTED_NUMERIC_FIELDS: + # 计算字段:获取全量产品,走 enrichment 提取唯一值 + try: + all_rows = query(f""" + SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, + p.cost_price, + p.lead_time AS 生产货期, p.logistics_time AS 物流时间, + p.min_order_qty AS 最低起订量, p.created_at AS 创建时间, + COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, + p.procurement_follower AS 采购跟单, + p.procurement_planner AS 采购计划, p.operator AS 运营专员, + p.developer AS 开发专员, p.platform AS 归属平台, + p.category1 AS 一级类目, p.category2 AS 二级类目, p.category3 AS 三级类目 + FROM products p + LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code + WHERE {where_clause} + """, params) + except Exception as _e: + import logging + logging.error(f"column-values computed field query failed: {_e}") + return jsonify({"code": 0, "data": []}) + # 使用已导入的 cached 函数 + sales = compute_product_sales_cached() + inv_map = compute_product_inventory_cached() + inv15_map = compute_product_inventory_15() + transit_map = compute_product_in_transit_cached() + cloud_inv_map = compute_cloud_inventory_cached() + ext_inv_map = compute_external_inventory_cached() + extended_sales = compute_product_sales_extended_cached() + sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} + gc = _ensure_global_config() + + seen = {} + values = [] + for row in all_rows: + if row.get('创建时间'): + created = row['创建时间'] + row['创建时间'] = created.strftime('%Y-%m-%d') if hasattr(created, 'strftime') else str(created)[:10] + row['生产货期'] = row.get('生产货期') or 0 + row['物流时间'] = row.get('物流时间') or 0 + row['产品种类'] = get_product_type_cached( + row.get('product_name', ''), row.get('brand', ''), row.get('product_type') + ) + enriched = _enrich_row_with_planning_data( + row, sales, inv_map, inv15_map, transit_map, sp_map, gc, + cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map, + extended_sales=extended_sales + ) + # 联动筛选:有 plan_filter 时过滤其他字段的条件 + if plan_filter_str: + try: + _pf = json.loads(plan_filter_str) + _skip = False + for _k, _v in _pf.items(): + if _k == field: + continue + if not isinstance(_v, dict): + continue + # 数据库字段已经在上方 SQL WHERE 中完成联动过滤, + # 此处只重复校验计算字段,避免中文显示字段取不到值而被误判为空。 + if _k in _pms: + continue + _vt = _v.get('type', '') + _raw = enriched.get(_k) + _rv = '' if _raw is None else str(_raw) + if _vt == 'list': + _vls = [str(x) for x in _v.get('values', [])] + if '__EMPTY__' in _vls and _rv == '': + pass + elif _vls and _rv not in _vls: + _skip = True; break + elif _vt == 'search': + _sv = _v.get('value', '') + if _sv and _sv not in _rv: + _skip = True; break + elif _vt == 'num': + try: + _rn = float(_rv) if _rv not in ('', 'None') else 0 + _cn = float(_v.get('val', 0)) + _co = _v.get('cond', '') + if _co == 'gt' and _rn <= _cn: _skip = True; break + if _co == 'gte' and _rn < _cn: _skip = True; break + if _co == 'lt' and _rn >= _cn: _skip = True; break + if _co == 'lte' and _rn > _cn: _skip = True; break + if _co == 'eq' and _rn != _cn: _skip = True; break + except: pass + if _skip: + continue + except: pass + val = enriched.get(field) + if val is not None and val != '' and str(val) != '-': + txt = str(val).strip() + if txt and txt not in seen: + seen[txt] = 1 + values.append(txt) + + # 数值排序 + try: + values.sort(key=lambda x: float(x)) + except: + values.sort() + return jsonify({"code": 0, "data": values}) + + sql = f"SELECT DISTINCT {db_col} AS val FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND {db_col} IS NOT NULL AND {db_col} != '' ORDER BY val" + rows = query(sql, params) if params else query(sql) + values = [r['val'] for r in rows] + # 空值检测 + emptySql = f"SELECT COUNT(*) AS cnt FROM products p LEFT JOIN suppliers s ON p.supplier_code = s.supplier_code WHERE {where_clause} AND ({db_col} IS NULL OR {db_col} = '')" + emptyRows = query(emptySql, params) if params else query(emptySql) + if emptyRows and emptyRows[0]['cnt'] > 0: + values.append('__EMPTY__') + return jsonify({"code": 0, "data": values}) diff --git a/routes/products.py b/routes/products.py index 3b195f9..3758612 100644 --- a/routes/products.py +++ b/routes/products.py @@ -851,126 +851,39 @@ def product_planning_detail(): product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type')) product['批注'] = product.get('remark') or '' - # 计算计划预测字段 + # 复用计划预测列表/导出的唯一计算逻辑,避免详情弹窗另维护一套公式。 + from routes.planning import _enrich_row_with_planning_data + sales = compute_product_sales_cached() inv_map = compute_product_inventory_cached() inv15_map = compute_product_inventory_15() transit_map = compute_product_in_transit_cached() + cloud_inv_map = compute_cloud_inventory_cached() + ext_inv_map = compute_external_inventory_cached() + extended_sales = compute_product_sales_extended_cached() sp_map = {r['trend_type']: r for r in sales_predict_config_cached()} + from services.forecast import get_global_config + gc = get_global_config() row = product pc = row.get('product_code', '') - row['库存'] = inv_map.get(pc, 0) - row['加工厂库存'] = inv15_map.get(pc, 0) - t = transit_map.get(pc, {}) - row['生产在途'] = t.get('生产在途', 0) - row['采购在途'] = t.get('采购在途', 0) - - if pc in sales: - s = sales[pc] - for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', - '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', - '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', - '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: - row[k] = s.get(k, 0) - row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量']) - else: - for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量', - '30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量', - '14天最高销量', '30天最高销量', '14天最低销量', '14天标准差', - '最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']: - row[k] = 0 - row['销售趋势'] = _determine_trend(0, 0, 0) - - row['产品分级'] = get_product_grade(row.get('30天平均销量', 0) * 30, - row.get('product_name', ''), - row.get('产品种类', '')) - row['是否新品'] = is_new_product(row.get('created_at', '')) - row['日销量预测'] = compute_daily_forecast(row, sp_map) - - # 支撑天数 - from services.forecast import get_global_config - gc = get_global_config() - safety_days = int(gc.get('safety_stock_days', '7')) - return_days = int(gc.get('suggested_return_days', '15')) - forecast_val = row.get('日销量预测', 0) - fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0 - inv_val = float(row.get('库存', 0) or 0) + row = _enrich_row_with_planning_data( + row, sales, inv_map, inv15_map, transit_map, sp_map, gc, + cloud_inv_map=cloud_inv_map, + ext_inv_map=ext_inv_map, + extended_sales=extended_sales, + ) inv15_val = float(row.get('加工厂库存', 0) or 0) - prod_transit = float(row.get('生产在途', 0) or 0) - purch_transit = float(row.get('采购在途', 0) or 0) - - if fcast == 0: - row['库存可支撑天数'] = 999 - row['库存工厂可支撑天数'] = 999 - row['库存工厂待生产可支撑天数'] = 999 - row['加工厂库存支撑天数'] = 999 - row['总支撑天数'] = 999 - else: - row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999 - row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999 - row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999 - row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999 - total_inv = prod_transit + inv15_val - row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999 - - lead_h = float(row.get('lead_time', 0) or 0) - logis_h = float(row.get('logistics_time', 0) or 0) - sup_days = row.get('库存可支撑天数', 999) - sup_days_val = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else None - if sup_days_val is not None and (lead_h + logis_h) > 0: - row['库存健康指数'] = round(min(sup_days_val / (lead_h + logis_h), 999), 1) - else: - row['库存健康指数'] = 999 - - created = row.get('created_at', '') - days_since_created = 999 - try: - if created: - created_dt = datetime.strptime(created[:10], '%Y-%m-%d') if isinstance(created, str) else created - days_since_created = (datetime.now() - created_dt).days - except: - pass - sup_days_val_f = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else 999 - if days_since_created < 90: - row['风险把控'] = '新品' - elif days_since_created > 180 and sup_days_val_f > 240: - row['风险把控'] = '需启动清库存' - elif days_since_created > 90 and sup_days_val_f > 90: - row['风险把控'] = '呆滞品' - else: - row['风险把控'] = '正常' - - if fcast == 0 and inv_val > 0: - row['建议下单数量'] = 0 - else: - row['建议下单数量'] = int(fcast * (safety_days + lead_h + logis_h)) - row['实际下单数量'] = ((row['建议下单数量'] + 999) // 1000) * 1000 if row['建议下单数量'] > 0 else 0 - - total_transit = prod_transit + purch_transit - if fcast > 0 and total_transit > 0: - calc_val = fcast * return_days - inv_val - round_up = max(0, int(calc_val * 10 + 0.99999) / 10) - row['建议回货数量'] = round_up if round_up <= total_transit else total_transit - else: - row['建议回货数量'] = 0 # 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。 - cloud_sales = compute_product_sales_extended_cached().get('cloud', {}).get(pc, {}) - cloud_inventory = compute_cloud_inventory_cached().get(pc, 0) + cloud_sales = extended_sales.get('cloud', {}).get(pc, {}) + cloud_inventory = row.get('云仓库存', 0) cloud_3 = cloud_sales.get('3天平均销量', 0) cloud_7 = cloud_sales.get('7天平均销量', 0) cloud_14 = cloud_sales.get('14天平均销量', 0) cloud_30 = cloud_sales.get('30天平均销量', 0) cloud_trend = _determine_trend(cloud_3, cloud_7, cloud_14) - cloud_forecast_row = { - '3天平均销量': cloud_3, - '7天平均销量': cloud_7, - '14天平均销量': cloud_14, - '30天最高销量': cloud_sales.get('30天最高销量', 0), - '销售趋势': cloud_trend, - } - cloud_forecast = compute_daily_forecast(cloud_forecast_row, sp_map) + cloud_forecast = row.get('云仓日销量预测', 0) cloud_fcast = float(cloud_forecast) if cloud_forecast not in ('近期无销售', 0, '0') else 0 row['云仓数据'] = { '产品分级': row.get('产品分级', ''), @@ -986,26 +899,19 @@ def product_planning_detail(): '14天平均销量': cloud_14, '库存可支撑天数': round(float(cloud_inventory or 0) / cloud_fcast, 1) if cloud_fcast else 999, '加工厂库存支撑天数': round(inv15_val / cloud_fcast, 1) if cloud_fcast else 999, - '总支撑天数': round((prod_transit + inv15_val) / cloud_fcast, 1) if cloud_fcast else 999, + '总支撑天数': row.get('云仓支撑天数', 999), '30天平均销量': cloud_30, '极值判断': '-', } - external_sales = compute_product_sales_extended_cached().get('external', {}).get(pc, {}) - external_inventory = compute_external_inventory_cached().get(pc, 0) + external_sales = extended_sales.get('external', {}).get(pc, {}) + external_inventory = row.get('外仓库存', 0) external_3 = external_sales.get('3天平均销量', 0) external_7 = external_sales.get('7天平均销量', 0) external_14 = external_sales.get('14天平均销量', 0) external_30 = external_sales.get('30天平均销量', 0) external_trend = _determine_trend(external_3, external_7, external_14) - external_forecast_row = { - '3天平均销量': external_3, - '7天平均销量': external_7, - '14天平均销量': external_14, - '30天最高销量': external_sales.get('30天最高销量', 0), - '销售趋势': external_trend, - } - external_forecast = compute_daily_forecast(external_forecast_row, sp_map) + external_forecast = row.get('外仓日销量预测', 0) external_fcast = float(external_forecast) if external_forecast not in ('近期无销售', 0, '0') else 0 row['外仓数据'] = { '产品分级': row.get('产品分级', ''), @@ -1021,7 +927,7 @@ def product_planning_detail(): '14天平均销量': external_14, '库存可支撑天数': round(float(external_inventory or 0) / external_fcast, 1) if external_fcast else 999, '加工厂库存支撑天数': round(inv15_val / external_fcast, 1) if external_fcast else 999, - '总支撑天数': round((prod_transit + inv15_val) / external_fcast, 1) if external_fcast else 999, + '总支撑天数': row.get('外仓支撑天数', 999), '30天平均销量': external_30, '极值判断': '-', } diff --git a/tests/test_planning_support_days_consistency.py b/tests/test_planning_support_days_consistency.py new file mode 100644 index 0000000..0e5650d --- /dev/null +++ b/tests/test_planning_support_days_consistency.py @@ -0,0 +1,41 @@ +"""计划预测支撑天数统一口径回归测试。""" + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PRODUCTS_PY = (ROOT / "routes" / "products.py").read_text(encoding="utf-8") +PLANNING_PY = (ROOT / "routes" / "planning.py").read_text(encoding="utf-8") + + +class PlanningSupportDaysConsistencyTest(unittest.TestCase): + def test_detail_reuses_the_list_and_export_enrichment(self): + detail_source = PRODUCTS_PY.split("def product_planning_detail():", 1)[1].split( + "@products_bp.route('/api/bom/list')", 1 + )[0] + self.assertIn("from routes.planning import _enrich_row_with_planning_data", detail_source) + self.assertIn("row = _enrich_row_with_planning_data(", detail_source) + self.assertNotIn("total_inv = prod_transit + inv15_val", detail_source) + + def test_cloud_and_external_detail_use_shared_support_days(self): + detail_source = PRODUCTS_PY.split("def product_planning_detail():", 1)[1].split( + "@products_bp.route('/api/bom/list')", 1 + )[0] + self.assertIn("'总支撑天数': row.get('云仓支撑天数', 999)", detail_source) + self.assertIn("'总支撑天数': row.get('外仓支撑天数', 999)", detail_source) + + def test_export_accepts_real_support_day_field_names(self): + export_source = PLANNING_PY.split("def planning_export():", 1)[1].split( + "def planning_page():", 1 + )[0] + for field in ( + "库存工厂可支撑天数", + "加工厂库存支撑天数", + "库存工厂待生产可支撑天数", + ): + self.assertIn(field, export_source) + + +if __name__ == "__main__": + unittest.main()