diff --git a/routes/planning.py b/routes/planning.py index b4d87db..a8911a3 100644 --- a/routes/planning.py +++ b/routes/planning.py @@ -1,7 +1,7 @@ """计划预测路由(最肥模块)""" from datetime import datetime from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template -from services.db import query +from services.db import query, add_operation_log 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, @@ -21,6 +21,28 @@ import json planning_bp = Blueprint('planning', __name__) +@planning_bp.route('/api/planning/new-old/save', methods=['POST']) +@login_required +def planning_new_old_save(): + from services.field_perms import field_perms_for_page + allowed = operation_required('planning', 'edit') + perms = field_perms_for_page(session.get('user_id'), session.get('role', ''), + 'planning', page_edit=allowed) + if not allowed or not perms.get('新旧品', {}).get('edit'): + return jsonify({'code': 403, 'msg': '没有编辑新旧品字段的权限'}), 403 + data = request.get_json(silent=True) or {} + code, value = data.get('product_code'), data.get('value') + if not code or value not in ('是', '否'): + return jsonify({'code': 400, 'msg': '新旧品只能选择是或否'}), 400 + rows = query('SELECT planning_new_old FROM products WHERE product_code=%s AND deleted_at IS NULL', (code,)) + if not rows: + return jsonify({'code': 404, 'msg': '未找到该产品'}), 404 + query('UPDATE products SET planning_new_old=%s WHERE product_code=%s', (value, code)) + add_operation_log('计划预测编辑', f'{code} 新旧品:{rows[0].get("planning_new_old") or "否"} → {value}', + page='planning', field='新旧品') + return jsonify({'code': 0, 'msg': '已保存'}) + + 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()] @@ -80,6 +102,7 @@ def _enrich_row_with_planning_data(row, sales, inv_map, inv15_map, transit_map, extended_sales=None): """给一行产品数据补充计划预测相关的所有计算字段""" pc = row.get('product_code', '') + row['新旧品'] = row.get('planning_new_old') or '否' inv_val = inv_map.get(pc, 0) inv15_val = inv15_map.get(pc, 0) t = transit_map.get(pc, {}) @@ -556,6 +579,7 @@ def planning_list(): '生产货期': 'p.lead_time', '物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty', + '新旧品': 'p.planning_new_old', } db_col = db_col_map.get(ff) if ft == 'search': @@ -648,7 +672,7 @@ def planning_list(): # 全量捞取 + Python端筛选排序 all_rows = query(f""" SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, - p.cost_price, + p.cost_price, p.planning_new_old, 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 运营专员, @@ -863,7 +887,7 @@ def planning_list(): # SQL分页 + 少量Python计算 rows = query(f""" SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, - p.cost_price, + p.cost_price, p.planning_new_old, 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 运营专员, @@ -948,7 +972,7 @@ def planning_export(): '建议回货数量','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: @@ -974,7 +998,7 @@ def planning_export(): # ── 全量拉取(与列表全量分支一致)── all_rows = query(f""" SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, - p.cost_price, + p.cost_price, p.planning_new_old, 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 运营专员, @@ -1158,6 +1182,7 @@ def planning_brands(): '二级类目': 'p.category2', '三级类目': 'p.category3', '成本价': 'p.cost_price', '生产货期': 'p.lead_time', '物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty', + '新旧品': 'p.planning_new_old', } for _k, _v in _pfs.items(): if _k == 'brand': @@ -1253,6 +1278,7 @@ def planning_column_values(): '生产货期': 'p.lead_time', '物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty', + '新旧品': 'p.planning_new_old', } for _k, _v in _pfs.items(): if _k == field: @@ -1315,6 +1341,7 @@ def planning_column_values(): '生产货期': 'p.lead_time', '物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty', + '新旧品': 'p.planning_new_old', } db_col = db_col_map.get(field) @@ -1352,7 +1379,7 @@ def planning_column_values(): try: all_rows = query(f""" SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, - p.cost_price, + p.cost_price, p.planning_new_old, 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 供应商名称, diff --git a/routes/purchase.py b/routes/purchase.py index 259b029..ce6f0e3 100644 --- a/routes/purchase.py +++ b/routes/purchase.py @@ -957,7 +957,7 @@ def purchase_add_pending(): is_update = bool(keys & update_fields) # 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。 - existing_pending = query("SELECT id, bom_items FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,)) + existing_pending = query("SELECT id, bom_items, new_old_product FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,)) if existing_pending and not is_update: return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"}) @@ -1026,6 +1026,8 @@ def purchase_add_pending(): remark_val = data.get('remark', '') audit_price = float(data.get('audit_price', 0)) + from services.planning_order_flag import snapshot_new_old + new_old_product = snapshot_new_old(pc, existing_pending[0].get('new_old_product') if existing_pending else None) # 已存在待下单行时按 id 更新,避免依赖 product_code 唯一索引; # 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。 if existing_pending: @@ -1035,13 +1037,14 @@ def purchase_add_pending(): lead_time=%s, logistics_time=%s, total_support_days=%s, procurement_follower=%s, procurement_planner=%s, platform=%s, operator=%s, created_by=%s, audit_price=%s, formal_order_qty=%s, suggested_qty=%s, actual_order_qty=%s, bom_items=%s, remark=%s, - status='pending', updated_at=NOW() + new_old_product=%s, status='pending', updated_at=NOW() WHERE id=%s """, ( pn, pt, pg, sn, int(lt), int(lo), float(ts), pf, pp, pl, op, created_by, audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val, + new_old_product, existing_pending[0]['id'] )) else: @@ -1050,15 +1053,15 @@ def purchase_add_pending(): (product_code, product_name, product_type, product_grade, supplier_name, lead_time, logistics_time, total_support_days, procurement_follower, procurement_planner, platform, operator, created_by, - audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark, new_old_product) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( pc, pn, pt, pg, sn, int(lt), int(lo), float(ts), pf, pp, pl, op, created_by, - audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val + audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val, new_old_product )) if req_bom_items and bom_items_normalized: _sync_pending_pre_packaging(pc, bom_items_normalized) @@ -1294,10 +1297,13 @@ def purchase_confirm_order(): has_product_order = True if is_overseas and qty > 0: + from services.planning_order_flag import snapshot_new_old + new_old_product = snapshot_new_old(pc, row.get('new_old_product')) + query('UPDATE pending_orders SET new_old_product=%s WHERE id=%s', (new_old_product, row['id'])) # 写入海外订单表(含全部字段,日期留空由用户自行填写) query("""INSERT INTO overseas_orders - (日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + (日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号,新旧品) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( None, now_str, row.get('product_code'), '未完结', @@ -1321,7 +1327,8 @@ def purchase_confirm_order(): row.get('procurement_follower'), row.get('product_name', ''), row.get('remark', ''), - row.get('production_order_no', '') + row.get('production_order_no', ''), + new_old_product )) elif not is_overseas and qty > 0: previous_order = _latest_domestic_order_values(row.get('product_code')) @@ -1610,7 +1617,7 @@ def purchase_batch_add_pending(): products = query(f""" SELECT p.product_code, p.product_name, p.product_type, p.lead_time, p.logistics_time, p.supplier_name_match AS supplier_name, - p.procurement_follower, p.procurement_planner, p.platform, p.operator + p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.planning_new_old FROM products p WHERE p.product_code IN ({placeholders}) """, product_codes) @@ -1638,14 +1645,15 @@ def purchase_batch_add_pending(): (product_code, product_name, product_type, product_grade, supplier_name, lead_time, logistics_time, total_support_days, procurement_follower, procurement_planner, platform, operator, - audit_price, formal_order_qty, bom_items) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + audit_price, formal_order_qty, bom_items, new_old_product) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( pc, p.get('product_name', ''), p.get('product_type', ''), '', p.get('supplier_name', ''), int(p.get('lead_time', 0) or 0), int(p.get('logistics_time', 0) or 0), 0, p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '', - p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data + p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data, + p.get('planning_new_old') or '否' )) added += 1 @@ -3795,14 +3803,15 @@ def purchase_rollback_order(): lead_time, logistics_time, total_support_days, procurement_follower, procurement_planner, platform, operator, created_by, audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, - production_order_no, remark, status, created_at, updated_at, ordered_at) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending', NOW(), NOW(), NULL) + production_order_no, remark, new_old_product, status, created_at, updated_at, ordered_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending', NOW(), NOW(), NULL) """, ( row.get('product_code'), row.get('product_name'), row.get('product_type'), row.get('product_grade'), row.get('supplier_name'), row.get('lead_time'), row.get('logistics_time'), row.get('total_support_days'), row.get('procurement_follower'), row.get('procurement_planner'), row.get('platform'), row.get('operator'), row.get('created_by'), row.get('audit_price'), row.get('formal_order_qty'), row.get('suggested_qty'), - row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark') + row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark'), + row.get('new_old_product') )) pending_check = query( "SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1", diff --git a/scripts/migrate_planning_new_old.py b/scripts/migrate_planning_new_old.py new file mode 100644 index 0000000..77b9288 --- /dev/null +++ b/scripts/migrate_planning_new_old.py @@ -0,0 +1,28 @@ +"""Add the manual flag and pending snapshot; use --local on the test server.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +if '--local' in sys.argv: + import test_wsgi + +from services.db import query + + +def migrate(): + for table, column, definition in ( + ('products', 'planning_new_old', "VARCHAR(8) NOT NULL DEFAULT '否'"), + ('pending_orders', 'new_old_product', 'VARCHAR(8) NULL DEFAULT NULL'), + ): + exists = query( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' + 'WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME=%s', + (table, column), + ) + if not exists: + query(f'ALTER TABLE {table} ADD COLUMN {column} {definition}') + print(f'{table}.{column}: ready') + + +if __name__ == '__main__': + migrate() diff --git a/services/field_perms.py b/services/field_perms.py new file mode 100644 index 0000000..ec2e3a4 --- /dev/null +++ b/services/field_perms.py @@ -0,0 +1,717 @@ +"""字段级权限:字段清单单一来源 + 权限解析 + 后端强制过滤。 + +设计目标(对应「字段级权限」优化): +1. 字段清单单一来源:FIELD_REGISTRY 定义每个页面的字段(key=中文显示名,与前端列定义、 + 现有 user_permissions 存储保持一致),sensitive 标记敏感字段。 +2. 权限存储复用现有表结构,无需迁移: + - 角色级字段权限:role_operation_permissions,operation = 'field:<字段>:view' / 'field:<字段>:edit' + - 用户级字段权限:user_permissions,operation 同上 +3. 敏感字段默认隐藏:敏感字段未显式授权时,默认拒绝查看/编辑(安全优先)。 +4. 后端强制过滤:filter_rows / filter_row 按有效字段权限剔除无权查看的字段。 + +权限解析优先级:管理员全权限 → 用户级明确值 → 角色级明确值 → 敏感字段默认拒绝 / 非敏感默认继承页面权限。 +""" +from services.db import query +from services.permission_model import ensure_position_permissions, user_position_code + +# ─── 字段清单单一来源 ─── +# key 统一使用中文显示名(与前端列定义一致),sensitive=True 表示敏感字段(默认隐藏,需显式授权)。 +FIELD_REGISTRY = { + # 计划预测(与 PLAN_COLUMNS 对齐) + 'planning': [ + {'key': '产品类型', 'sensitive': False}, + {'key': '产品种类', 'sensitive': False}, + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '批注', 'sensitive': False}, + {'key': '新旧品', 'sensitive': False}, + {'key': '下单清单', 'sensitive': False}, + {'key': '产品分级', 'sensitive': False}, + {'key': '昨日销量', 'sensitive': False}, + {'key': '云仓昨日销量', 'sensitive': False}, + {'key': '外仓昨日销量', 'sensitive': False}, + {'key': '销售趋势', 'sensitive': False}, + {'key': '总库存', 'sensitive': False}, + {'key': '库存', 'sensitive': False}, + {'key': '总在途库存', 'sensitive': False}, + {'key': '云仓库存', 'sensitive': False}, + {'key': '外仓库存', 'sensitive': False}, + {'key': '加工厂库存', 'sensitive': False}, + {'key': '云仓可分配库存', 'sensitive': False}, + {'key': '外仓可分配库存', 'sensitive': False}, + {'key': '是否调拨', 'sensitive': False}, + {'key': '生产在途', 'sensitive': False}, + {'key': '采购在途', 'sensitive': False}, + {'key': '日销量预测', 'sensitive': False}, + {'key': '云仓日销量预测', 'sensitive': False}, + {'key': '外仓日销量预测', 'sensitive': False}, + {'key': '建议下单数量', 'sensitive': False}, + {'key': '实际下单数量', 'sensitive': False}, + {'key': '正式下单数量', 'sensitive': False}, + {'key': '库存健康指数', 'sensitive': False}, + {'key': '风险把控', 'sensitive': False}, + {'key': '供应商名称', 'sensitive': False}, + {'key': '采购跟单', 'sensitive': False}, + {'key': '采购计划', 'sensitive': False}, + {'key': '开发专员', 'sensitive': False}, + {'key': '归属平台', 'sensitive': False}, + {'key': '运营专员', 'sensitive': False}, + {'key': '成本价', 'sensitive': True}, + {'key': '3天平均销量', 'sensitive': False}, + {'key': '云仓3天平均销量', 'sensitive': False}, + {'key': '外仓3天平均销量', 'sensitive': False}, + {'key': '7天平均销量', 'sensitive': False}, + {'key': '云仓7天平均销量', 'sensitive': False}, + {'key': '外仓7天平均销量', 'sensitive': False}, + {'key': '14天平均销量', 'sensitive': False}, + {'key': '云仓14天平均销量', 'sensitive': False}, + {'key': '外仓14天平均销量', 'sensitive': False}, + {'key': '库存可支撑天数', 'sensitive': False}, + {'key': '现货采购在途', 'sensitive': False}, + {'key': '加工厂库存支撑', 'sensitive': False}, + {'key': '定制品在途', 'sensitive': False}, + {'key': '总支撑天数', 'sensitive': False}, + {'key': '云仓支撑天数', 'sensitive': False}, + {'key': '外仓支撑天数', 'sensitive': False}, + {'key': '建议回货数量', 'sensitive': False}, + {'key': '产品规格', 'sensitive': False}, + {'key': '30天平均销量', 'sensitive': False}, + {'key': '60天平均销量', 'sensitive': False}, + {'key': '生产货期', 'sensitive': False}, + {'key': '物流时间', 'sensitive': False}, + {'key': '最低起订量', 'sensitive': False}, + {'key': '创建时间', 'sensitive': False}, + {'key': '是否新品', 'sensitive': False}, + {'key': '一级类目', 'sensitive': False}, + {'key': '二级类目', 'sensitive': False}, + {'key': '三级类目', 'sensitive': False}, + ], + # 数据概览 + 'data_summary': [ + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '店铺', 'sensitive': False}, + {'key': '总数量', 'sensitive': False}, + {'key': '总成本', 'sensitive': True}, + {'key': '日期', 'sensitive': False}, + {'key': '数据来源', 'sensitive': False}, + ], + # 数据明细 + 'data_detail': [ + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '店铺', 'sensitive': False}, + {'key': '一级类目', 'sensitive': False}, + {'key': '二级类目', 'sensitive': False}, + {'key': '三级类目', 'sensitive': False}, + {'key': '货品数量', 'sensitive': False}, + {'key': '货品成本', 'sensitive': True}, + {'key': '日期', 'sensitive': False}, + {'key': '数据来源', 'sensitive': False}, + ], + # 库存-成品 + 'inventory_finished': [ + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '可用库存', 'sensitive': False}, + {'key': '生产在途', 'sensitive': False}, + {'key': '采购在途', 'sensitive': False}, + {'key': '供应商', 'sensitive': False}, + {'key': '库存日期', 'sensitive': False}, + {'key': '采购跟单', 'sensitive': False}, + {'key': '采购计划', 'sensitive': False}, + ], + # 库存-包材 + 'inventory_packaging': [ + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '可用库存', 'sensitive': False}, + {'key': '生产在途', 'sensitive': False}, + {'key': '采购在途', 'sensitive': False}, + {'key': '供应商', 'sensitive': False}, + {'key': '库存日期', 'sensitive': False}, + ], + # 采购待下单 + 'purchase_pending': [ + {'key': '产品类型', 'sensitive': False}, + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '确认下单', 'sensitive': False}, + {'key': '产品分级', 'sensitive': False}, + {'key': '供应商名称', 'sensitive': False}, + {'key': '建议下单数量', 'sensitive': False}, + {'key': '正式下单数量', 'sensitive': False}, + {'key': '下单备注', 'sensitive': False}, + {'key': '货期', 'sensitive': False}, + {'key': '物流', 'sensitive': False}, + {'key': '总支撑天数', 'sensitive': False}, + {'key': '平台', 'sensitive': False}, + {'key': '运营', 'sensitive': False}, + {'key': '审核价格', 'sensitive': True}, + {'key': '下单人员', 'sensitive': False}, + {'key': '下单时间', 'sensitive': False}, + {'key': '采购跟单', 'sensitive': False}, + {'key': '采购计划', 'sensitive': False}, + {'key': '加入时间', 'sensitive': False}, + ], + # 采购已下单 + 'purchase_backup': [ + {'key': '产品类型', 'sensitive': False}, + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '下单时间', 'sensitive': False}, + {'key': '下单人', 'sensitive': False}, + {'key': '生产单号', 'sensitive': False}, + {'key': '建议下单数量', 'sensitive': False}, + {'key': '正式下单数量', 'sensitive': False}, + {'key': '产品分级', 'sensitive': False}, + {'key': '供应商名称', 'sensitive': False}, + {'key': '下单备注', 'sensitive': False}, + {'key': '货期', 'sensitive': False}, + {'key': '物流', 'sensitive': False}, + {'key': '总支撑天数', 'sensitive': False}, + {'key': '采购跟单', 'sensitive': False}, + {'key': '采购计划', 'sensitive': False}, + {'key': '平台', 'sensitive': False}, + {'key': '运营', 'sensitive': False}, + {'key': '审核价格', 'sensitive': True}, + ], + # 国内订单 + 'order_domestic': [ + {'key': '日期', 'sensitive': False}, + {'key': '计划下单时间', 'sensitive': False}, + {'key': '新旧品', 'sensitive': False}, + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '货品类型', 'sensitive': False}, + {'key': '下单备注', 'sensitive': False}, + {'key': '单价', 'sensitive': True}, + {'key': '审核价格', 'sensitive': True}, + {'key': '数量', 'sensitive': False}, + {'key': '金额', 'sensitive': True}, + {'key': '供货商', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '金蝶采购单', 'sensitive': False}, + {'key': '采购员', 'sensitive': False}, + {'key': '下单人', 'sensitive': False}, + {'key': '进度', 'sensitive': False}, + {'key': '旺店通单号', 'sensitive': False}, + {'key': '采购开发', 'sensitive': False}, + {'key': '预计到货日期', 'sensitive': False}, + {'key': '金蝶入库', 'sensitive': False}, + ], + # 海外订单 + 'order_overseas': [ + {'key': '产品编码', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '货品类型', 'sensitive': False}, + {'key': '日期', 'sensitive': False}, + {'key': '计划下单时间', 'sensitive': False}, + {'key': '单价', 'sensitive': True}, + {'key': '审核价格', 'sensitive': True}, + {'key': '数量', 'sensitive': False}, + {'key': '金额', 'sensitive': True}, + {'key': '供应商', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '新旧品', 'sensitive': False}, + {'key': '下单备注', 'sensitive': False}, + {'key': '采购员', 'sensitive': False}, + {'key': '下单人', 'sensitive': False}, + {'key': '进度', 'sensitive': False}, + {'key': '旺店通单号', 'sensitive': False}, + {'key': '采购开发', 'sensitive': False}, + {'key': '预计到货日期', 'sensitive': False}, + ], + # 包材订单 + 'order_packaging': [ + {'key': '日期', 'sensitive': False}, + {'key': '计划下单时间', 'sensitive': False}, + {'key': '产品编码', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '货品类型', 'sensitive': False}, + {'key': '单价', 'sensitive': True}, + {'key': '审核价格', 'sensitive': True}, + {'key': '数量', 'sensitive': False}, + {'key': '金额', 'sensitive': True}, + {'key': '供应商', 'sensitive': False}, + {'key': '成品供应商', 'sensitive': False}, + {'key': '仓库', 'sensitive': False}, + {'key': '新旧品', 'sensitive': False}, + {'key': '采购员', 'sensitive': False}, + {'key': '下单人', 'sensitive': False}, + {'key': '进度', 'sensitive': False}, + {'key': '下单备注', 'sensitive': False}, + {'key': '采购开发', 'sensitive': False}, + {'key': '旺店通单号', 'sensitive': False}, + ], + # 成品订单 + 'order_finished': [ + {'key': '品类', 'sensitive': False}, + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '货品类型', 'sensitive': False}, + {'key': '供应商', 'sensitive': False}, + {'key': '可用库存', 'sensitive': False}, + {'key': '昨日销量', 'sensitive': False}, + {'key': '3天均销', 'sensitive': False}, + {'key': '7天均销', 'sensitive': False}, + {'key': '15天均销', 'sensitive': False}, + {'key': '支撑天数', 'sensitive': False}, + {'key': '采购总数量', 'sensitive': False}, + {'key': '生产在途', 'sensitive': False}, + {'key': '采购在途', 'sensitive': False}, + {'key': '产品进度', 'sensitive': False}, + {'key': '加工厂库存', 'sensitive': False}, + {'key': '跟单小组', 'sensitive': False}, + {'key': '采购计划', 'sensitive': False}, + {'key': '备注', 'sensitive': False}, + ], + # 产品基础信息 + 'products': [ + {'key': '货品编号', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '货品类型', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '供应商编码', 'sensitive': False}, + {'key': '供应商名称', 'sensitive': False}, + {'key': '成本价', 'sensitive': True}, + {'key': '货期', 'sensitive': False}, + {'key': '物流时间', 'sensitive': False}, + {'key': '最低起订量', 'sensitive': False}, + {'key': '采购跟单', 'sensitive': False}, + {'key': '采购计划', 'sensitive': False}, + {'key': '平台', 'sensitive': False}, + {'key': '运营', 'sensitive': False}, + {'key': '开发专员', 'sensitive': False}, + {'key': '创建时间', 'sensitive': False}, + {'key': '导入日期', 'sensitive': False}, + ], + # 新品管理 + 'product_new': [ + {'key': '产品编码', 'sensitive': False}, + {'key': '产品名称', 'sensitive': False}, + {'key': '品牌', 'sensitive': False}, + {'key': '条形码', 'sensitive': False}, + {'key': '容量', 'sensitive': False}, + {'key': '成品备注', 'sensitive': False}, + {'key': '产品总成本', 'sensitive': True}, + {'key': '产品总货款', 'sensitive': True}, + {'key': '是否含运', 'sensitive': False}, + {'key': '产品开发', 'sensitive': False}, + {'key': '包材开发', 'sensitive': False}, + {'key': '设计师', 'sensitive': False}, + {'key': '负责运营', 'sensitive': False}, + ], + # 新品包材管理(独立于新品成品管理;字段来自 new_product_bom) + 'product_packaging': [ + {'key': '成品编码', 'sensitive': False}, + {'key': '成品名称', 'sensitive': False}, + {'key': '物料编码', 'sensitive': False}, + {'key': '物料名称', 'sensitive': False}, + {'key': '用量', 'sensitive': False}, + {'key': '供应商编码', 'sensitive': False}, + {'key': '供应商', 'sensitive': False}, + {'key': '货期/天', 'sensitive': False}, + {'key': '税点', 'sensitive': True}, + {'key': '是否含税', 'sensitive': False}, + {'key': '单价/元', 'sensitive': True}, + {'key': '数量', 'sensitive': False}, + {'key': '货款/元', 'sensitive': True}, + {'key': '结款方式', 'sensitive': False}, + {'key': '是否可下单', 'sensitive': False}, + {'key': '下单备注', 'sensitive': False}, + {'key': '通用物料编码', 'sensitive': False}, + {'key': '通用物料名称', 'sensitive': False}, + {'key': '下单日期', 'sensitive': False}, + ], + # BOM + 'bom': [ + {'key': '成品编号', 'sensitive': False}, + {'key': '成品名称', 'sensitive': False}, + {'key': '物料编号', 'sensitive': False}, + {'key': '物料名称', 'sensitive': False}, + {'key': '用量', 'sensitive': False}, + {'key': '供应商', 'sensitive': False}, + {'key': '成本价', 'sensitive': True}, + {'key': '创建时间', 'sensitive': False}, + ], + # 仓库 + 'warehouse': [ + {'key': '仓库编号', 'sensitive': False}, + {'key': '仓库名称', 'sensitive': False}, + {'key': '仓库类型', 'sensitive': False}, + {'key': '地址', 'sensitive': False}, + {'key': '联系人', 'sensitive': False}, + {'key': '联系电话', 'sensitive': False}, + {'key': '创建时间', 'sensitive': False}, + ], + # 供应商 + 'suppliers': [ + {'key': '供应商编码', 'sensitive': False}, + {'key': '供应商名称', 'sensitive': False}, + {'key': '联系人', 'sensitive': False}, + {'key': '联系电话', 'sensitive': False}, + {'key': '地址', 'sensitive': False}, + {'key': '合作状态', 'sensitive': False}, + {'key': '创建时间', 'sensitive': False}, + ], + # 现货销售 + 'spot_sales': [ + {'key': '商家编码', 'sensitive': False}, + {'key': '货品名称', 'sensitive': False}, + {'key': '平台', 'sensitive': False}, + {'key': '类目', 'sensitive': False}, + {'key': '首批成本价', 'sensitive': True}, + ], + # AI 分析:金额类数据源单独控制(敏感,默认不开放) + 'ai_analysis': [ + {'key': '库存成本汇总', 'sensitive': True}, + {'key': '商品库存金额', 'sensitive': True}, + {'key': '品牌汇总', 'sensitive': True}, + ], +} + +# 运行页面ID → 标准权限页面ID 回退映射 +_FIELD_REGISTRY_ALIASES = { + 'purchase': 'purchase_pending', + 'purchase_ready': 'purchase_backup', + 'orders_domestic': 'order_domestic', + 'orders_overseas': 'order_overseas', + 'orders_packaging': 'order_packaging', + 'orders_finished': 'order_finished', +} + +# 标准页面ID → 存储/历史别名集合(与 routes/auth.py 的 PERMISSION_PAGE_ALIASES 保持一致) +_PAGE_ALIASES = { + 'purchase_pending': ['purchase_pending', 'purchase'], + 'purchase_backup': ['purchase_backup', 'purchase_ready', 'purchase'], + 'order_domestic': ['order_domestic', 'orders_domestic', 'orders'], + 'order_overseas': ['order_overseas', 'orders_overseas', 'orders'], + 'order_packaging': ['order_packaging', 'orders_packaging', 'orders'], + 'order_finished': ['order_finished', 'orders_finished', 'orders'], + 'data_summary': ['data_summary', 'data'], + 'data_detail': ['data_detail', 'data'], + 'inventory_finished': ['inventory_finished', 'inventory'], + 'inventory_packaging': ['inventory_packaging', 'inventory'], + 'product_new': ['product_new'], + 'product_packaging': ['product_packaging'], + 'spot_sales': ['spot_sales', 'spot'], +} + +# 标准页面ID → 前端运行页面ID(注入前端时使用) +_RUNTIME_PAGE = { + 'purchase_pending': 'purchase', + 'purchase_backup': 'purchase_ready', + 'order_domestic': 'orders_domestic', + 'order_overseas': 'orders_overseas', + 'order_packaging': 'orders_packaging', + 'order_finished': 'orders_finished', +} + +# 敏感字段的响应 key 映射(后端过滤时按实际返回字段名剔除)。 +# 未列出的字段默认按 key(中文显示名)匹配响应字段。 +FIELD_RESP_KEYS = { + # 采购待下单/已下单接口使用英文键名,统一映射到权限字段中文名。 + ('purchase_pending', '产品类型'): ['product_type'], + ('purchase_pending', '货品编号'): ['product_code'], + ('purchase_pending', '货品名称'): ['product_name'], + ('purchase_pending', '产品分级'): ['product_grade'], + ('purchase_pending', '供应商名称'): ['supplier_name'], + ('purchase_pending', '建议下单数量'): ['suggested_qty'], + ('purchase_pending', '正式下单数量'): ['formal_order_qty'], + ('purchase_pending', '下单备注'): ['remark'], + ('purchase_pending', '货期'): ['lead_time'], + ('purchase_pending', '物流'): ['logistics_time'], + ('purchase_pending', '总支撑天数'): ['total_support_days'], + ('purchase_pending', '平台'): ['platform'], + ('purchase_pending', '运营'): ['operator'], + ('purchase_pending', '审核价格'): ['audit_price'], + ('purchase_pending', '下单人员'): ['created_by'], + ('purchase_pending', '下单时间'): ['created_at'], + ('purchase_pending', '采购跟单'): ['procurement_follower'], + ('purchase_pending', '采购计划'): ['procurement_planner'], + ('purchase_pending', '加入时间'): ['created_at'], + ('purchase_backup', '产品类型'): ['product_type'], + ('purchase_backup', '货品编号'): ['product_code'], + ('purchase_backup', '货品名称'): ['product_name'], + ('purchase_backup', '供应商名称'): ['supplier_name'], + ('purchase_backup', '建议下单数量'): ['suggested_qty'], + ('purchase_backup', '正式下单数量'): ['formal_order_qty'], + ('purchase_backup', '下单备注'): ['remark'], + ('purchase_backup', '货期'): ['lead_time'], + ('purchase_backup', '物流'): ['logistics_time'], + ('purchase_backup', '总支撑天数'): ['total_support_days'], + ('purchase_backup', '平台'): ['platform'], + ('purchase_backup', '运营'): ['operator'], + ('purchase_backup', '审核价格'): ['audit_price'], + ('purchase_backup', '下单人员'): ['created_by'], + ('purchase_backup', '下单时间'): ['ordered_at'], + ('purchase_backup', '采购跟单'): ['procurement_follower'], + ('purchase_backup', '采购计划'): ['procurement_planner'], + ('purchase_backup', '生产单号'): ['production_order_no'], + # 包材订单接口字段与权限字段名称不同。 + ('order_packaging', '产品编码'): ['编码'], + ('order_packaging', '货品名称'): ['名称'], + ('order_packaging', '金额'): ['合计'], + ('order_packaging', '供应商'): ['供应商'], + ('order_packaging', '旺店通单号'): ['旺店通编号'], + ('product_packaging', '成品编码'): ['finished_product_code'], + ('product_packaging', '成品名称'): ['finished_product_name'], + ('product_packaging', '物料编码'): ['material_code'], + ('product_packaging', '物料名称'): ['material_name'], + ('product_packaging', '供应商编码'): ['supplier_code'], + ('product_packaging', '供应商'): ['supplier'], + ('product_packaging', '货期/天'): ['lead_time'], + ('product_packaging', '税点'): ['tax_rate'], + ('product_packaging', '是否含税'): ['tax_included'], + ('product_packaging', '单价/元'): ['unit_price'], + ('product_packaging', '数量'): ['quantity'], + ('product_packaging', '货款/元'): ['amount'], + ('product_packaging', '结款方式'): ['payment_method'], + ('product_packaging', '是否可下单'): ['can_order'], + ('product_packaging', '下单备注'): ['remark'], + ('product_packaging', '通用物料编码'): ['common_code'], + ('product_packaging', '通用物料名称'): ['common_name'], + ('product_packaging', '下单日期'): ['order_date'], + ('planning', '成本价'): ['cost_price'], + ('planning', '新旧品'): ['planning_new_old'], + ('data_summary', '总成本'): ['total_cost'], + ('products', '成本价'): ['cost_price'], + ('product_new', '产品总成本'): ['total_cost'], + ('product_new', '产品总货款'): ['total_payment'], + ('product_new', '是否含运'): ['freight_included'], + ('bom', '成本价'): ['cost_price'], +} + + +def _field_resp_keys(page, key): + """返回某字段应剔除的响应 key 列表(含 key 本身)。""" + canonical = _canonical_page(page) + resp = FIELD_RESP_KEYS.get((canonical, key), []) + keys = [key] + [r for r in resp if r != key] + return keys + + +def _canonical_page(page): + return _FIELD_REGISTRY_ALIASES.get(page, page) + + +def _page_aliases(page): + canonical = _canonical_page(page) + return _PAGE_ALIASES.get(canonical, [canonical]) + + +def _runtime_page(page): + canonical = _canonical_page(page) + return _RUNTIME_PAGE.get(canonical, canonical) + + +def get_field_list(page): + """返回页面字段清单(list of {key, sensitive}),未注册的页面返回空列表。""" + return FIELD_REGISTRY.get(_canonical_page(page), []) + + +def is_admin_role(role): + return role in ('admin', 'super_admin') + + +def _load_field_config(user_id, role, page): + """读取角色、岗位、用户三级字段权限配置。""" + aliases = _page_aliases(page) + placeholders = ','.join(['%s'] * len(aliases)) + role_perm, position_perm, user_perm = {}, {}, {} + explicit = set() + + role_rows = query( + f"SELECT operation, permission FROM role_operation_permissions " + f"WHERE role=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'", + tuple(['viewer' if role == '查看者' else role] + aliases) + ) + for row in role_rows: + parts = row['operation'].split(':', 2) + if len(parts) == 3: + role_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission']) + explicit.add(parts[1]) + + ensure_position_permissions() + position = user_position_code(user_id) + if position: + position_rows = query( + f"SELECT operation, permission FROM position_operation_permissions " + f"WHERE position_code=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'", + tuple([position] + aliases) + ) + for row in position_rows: + parts = row['operation'].split(':', 2) + if len(parts) == 3: + position_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission']) + explicit.add(parts[1]) + + user_rows = query( + f"SELECT operation, permission FROM user_permissions " + f"WHERE user_id=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'", + tuple([user_id] + aliases) + ) + for row in user_rows: + parts = row['operation'].split(':', 2) + if len(parts) == 3: + user_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission']) + explicit.add(parts[1]) + + return role_perm, position_perm, user_perm, explicit + + +def _resolve_field_value(user_perm, position_perm, role_perm, field_key, operation, default_allow): + """字段权限优先级:用户例外 > 岗位权限 > 角色权限 > 页面默认。""" + user_value = user_perm.get(field_key, {}).get(operation) + if user_value is not None: + return bool(user_value) + position_value = position_perm.get(field_key, {}).get(operation) + if position_value is not None: + return bool(position_value) + role_value = role_perm.get(field_key, {}).get(operation) + if role_value is not None: + return bool(role_value) + return bool(default_allow) + + +def field_perms_for_page(user_id, role, page, page_view=True, page_edit=False): + """返回合并角色、岗位、用户后的字段权限。""" + fields = get_field_list(page) + if not fields: + return {} + if is_admin_role(role): + return {field['key']: {'view': True, 'edit': True} for field in fields} + + role_perm, position_perm, user_perm, _ = _load_field_config(user_id, role, page) + result = {} + for field in fields: + key = field['key'] + result[key] = { + 'view': _resolve_field_value(user_perm, position_perm, role_perm, key, 'view', page_view), + 'edit': _resolve_field_value(user_perm, position_perm, role_perm, key, 'edit', page_edit), + } + return result + + +def role_field_perms_for_page(role, page): + """仅角色级字段权限,用于角色权限矩阵。""" + fields = get_field_list(page) + if not fields: + return {} + if is_admin_role(role): + return {field['key']: {'view': True, 'edit': True} for field in fields} + aliases = _page_aliases(page) + placeholders = ','.join(['%s'] * len(aliases)) + role_perm = {} + rows = query( + f"SELECT operation, permission FROM role_operation_permissions " + f"WHERE role=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'", + tuple(['viewer' if role == '查看者' else role] + aliases) + ) + for row in rows: + parts = row['operation'].split(':', 2) + if len(parts) == 3: + role_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission']) + return { + field['key']: { + 'view': _resolve_field_value({}, {}, role_perm, field['key'], 'view', True), + 'edit': _resolve_field_value({}, {}, role_perm, field['key'], 'edit', False), + } + for field in fields + } + + +def inherited_field_perms_for_page(user_id, role, page): + """角色+岗位的字段继承值,不包含用户个人覆盖。""" + fields = get_field_list(page) + if not fields: + return {} + if is_admin_role(role): + return {field['key']: {'view': True, 'edit': True} for field in fields} + role_perm, position_perm, _, _ = _load_field_config(user_id, role, page) + return { + field['key']: { + 'view': _resolve_field_value({}, position_perm, role_perm, field['key'], 'view', True), + 'edit': _resolve_field_value({}, position_perm, role_perm, field['key'], 'edit', False), + } + for field in fields + } + +def filter_row(page, row, user_id, role, page_view=True, page_edit=False): + """对单个 dict 按字段 view 权限剔除无权查看的字段(原地修改并返回)。""" + if row is None or is_admin_role(role): + return row + perms = field_perms_for_page(user_id, role, page, page_view, page_edit) + if not perms: + return row + for key in list(row.keys()): + # 响应字段可能用中文显示名,也可能用英文 DB 列名;统一按映射剔除 + matched = None + for fkey, fperm in perms.items(): + if key in _field_resp_keys(page, fkey): + matched = (fkey, fperm) + break + if matched and not matched[1]['view']: + del row[key] + return row + + +def filter_rows(page, rows, user_id, role, page_view=True, page_edit=False): + """对 dict 列表按字段 view 权限剔除字段(原地修改并返回)。""" + if not rows or is_admin_role(role): + return rows + perms = field_perms_for_page(user_id, role, page, page_view, page_edit) + if not perms: + return rows + # 预先构建 响应key -> 是否可查看 的映射,避免逐行重复计算 + resp_visibility = {} + for fkey, fperm in perms.items(): + for rkey in _field_resp_keys(page, fkey): + resp_visibility[rkey] = fperm['view'] + for row in rows: + if not isinstance(row, dict): + continue + for key in list(row.keys()): + if key in resp_visibility and not resp_visibility[key]: + del row[key] + return rows + + +def effective_field_overrides(user_id, role, page_view_by_page=None, page_edit_by_page=None): + """返回需要注入前端的字段级权限显式值(用于敏感字段默认隐藏 + 显式配置)。 + + 仅返回「敏感字段」和「有显式配置」的字段;其余字段由前端按页面权限默认继承。 + 返回结构:{runtime_page: {field_key: {'view': 0/1, 'edit': 0/1}}}。 + """ + if is_admin_role(role): + return {} + page_view_by_page = page_view_by_page or {} + page_edit_by_page = page_edit_by_page or {} + overrides = {} + for page, fields in FIELD_REGISTRY.items(): + role_perm, position_perm, user_perm, explicit = _load_field_config(user_id, role, page) + pv = page_view_by_page.get(page, True) + pe = page_edit_by_page.get(page, False) + page_overrides = {} + for f in fields: + key = f['key'] + sensitive = bool(f.get('sensitive')) + if key not in explicit: + continue + page_overrides[key] = { + 'view': 1 if _resolve_field_value(user_perm, position_perm, role_perm, key, 'view', pv) else 0, + 'edit': 1 if _resolve_field_value(user_perm, position_perm, role_perm, key, 'edit', pe) else 0, + } + if page_overrides: + overrides[_runtime_page(page)] = page_overrides + return overrides diff --git a/services/planning_order_flag.py b/services/planning_order_flag.py new file mode 100644 index 0000000..66afed4 --- /dev/null +++ b/services/planning_order_flag.py @@ -0,0 +1,10 @@ +"""Manual new/old choice, independent of the calculated product-age field.""" +from services.db import query + + +def snapshot_new_old(product_code, saved_value=None): + """Preserve an existing pending snapshot during quantity/remark edits.""" + if saved_value in ('是', '否'): + return saved_value + rows = query('SELECT planning_new_old FROM products WHERE product_code=%s', (product_code,)) + return '是' if rows and rows[0].get('planning_new_old') == '是' else '否' diff --git a/static/js/app.js b/static/js/app.js index 0876d9f..bc39326 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -13005,6 +13005,7 @@ const PLAN_COLUMNS = [ {field: 'product_name', label: '货品名称', defaultShow: true}, {field: 'brand', label: '品牌', defaultShow: true}, {field: '批注', label: '批注', defaultShow: true}, + {field: '新旧品', label: '新旧品', defaultShow: true}, {field: 'bom', label: '下单清单', defaultShow: true, filterable: false}, {field: '产品分级', label: '产品分级', defaultShow: true}, {field: '昨日销量', label: '昨日销量', defaultShow: true}, @@ -13091,6 +13092,7 @@ const PLAN_FILTER_OPTIONS = { '产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'], '销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'], '是否新品': ['新品', '返单品'], + '新旧品': ['是', '否'], '风险把控': ['正常', '新品', '呆滞品', '需启动清库存'], '波动风险': ['X', 'Y', 'Z'], '极值判断': ['正常', '极大', '极小', '极大/极小'], @@ -13152,6 +13154,49 @@ if (!window._planColDragOrder) { } // 行内编辑功能 +function renderPlanNewOldSelect(code, value) { + var selected = value === '是' ? '是' : '否'; + var editable = window._canEditField && window._canEditField('planning', '新旧品'); + return ''; +} + +window.savePlanNewOld = function(input) { + var code = input.dataset.code, value = input.value, previous = input.dataset.original; + if (!window._planNewOldSaving) window._planNewOldSaving = {}; + window._planNewOldSaving[code] = true; + document.querySelectorAll('.plan-new-old-select').forEach(function(el) { + if (el.dataset.code === code) el.disabled = true; + }); + return fetch('/api/planning/new-old/save', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({product_code: code, value: value}) + }).then(function(r) { return r.json(); }).then(function(res) { + if (res.code !== 0) throw new Error(res.msg || '保存失败'); + if (window._planAllRows && window._planAllRows[code]) { + window._planAllRows[code]['新旧品'] = value; + window._planAllRows[code].planning_new_old = value; + } + document.querySelectorAll('.plan-new-old-select').forEach(function(el) { + if (el.dataset.code === code) { el.value = value; el.dataset.original = value; } + }); + if (window._pageCache) delete window._pageCache.planning; + }).catch(function(err) { + input.value = previous; + showCenteredAlert('保存失败', err.message || '请检查网络后重试'); + }).finally(function() { + delete window._planNewOldSaving[code]; + document.querySelectorAll('.plan-new-old-select').forEach(function(el) { + if (el.dataset.code === code) el.disabled = !window._canEditField('planning', '新旧品'); + }); + }); +}; + +function planNewOldStillSaving(codes) { + if (!codes.some(function(code) { return window._planNewOldSaving && window._planNewOldSaving[code]; })) return false; + showCenteredAlert('正在保存', '新旧品选择正在保存,请稍后再下单'); + return true; +} + window.prodInlineEditClick = function(el) { inlineEditClick(el); }; @@ -13825,6 +13870,9 @@ function renderPlanTable(rows) { var cls = trendColors[raw] || 'tag-grey'; return '' + (raw || '-') + ''; } + if (c.field === '新旧品') { + return renderPlanNewOldSelect(r.product_code, raw); + } if (c.field === '是否新品') { return (raw === '新品') ? '新品' : '返单品'; } @@ -15159,8 +15207,14 @@ window.loadPlanScheme = function(schemeId) { }); PLAN_COLUMNS.forEach(function(c) { if (pendingVisible[c.field] === undefined) { - newOrder.push(c.field); - pendingVisible[c.field] = false; + if (c.field === '新旧品') { + var bomIndex = newOrder.indexOf('bom'); + newOrder.splice(bomIndex < 0 ? newOrder.length : bomIndex, 0, c.field); + pendingVisible[c.field] = true; + } else { + newOrder.push(c.field); + pendingVisible[c.field] = false; + } } }); @@ -17534,6 +17588,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR var modalId = 'bom-modal-' + Date.now(); // 批注同步外面列表的批注内容(products.remark,与计划预测批注列同源) var productRemark = (res.data.product && res.data.product['批注']) || ''; + var productNewOld = (res.data.product && res.data.product.planning_new_old) || '否'; var productSummaryHtml = '
' + '
📦 成品下单信息
' + @@ -17555,6 +17610,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR '' + '
'; var bomContent = productSummaryHtml + + (window._canViewField('planning', '新旧品') ? '
新旧品:' + renderPlanNewOldSelect(productCode, productNewOld) + '
' : '') + '
' + '
📝 下单备注
' + '' + @@ -19790,6 +19846,9 @@ window.showSkuInfoModal = function(el, title) { if (d.product_name) fields.push(['货品名称', d.product_name]); if (d.brand) fields.push(['品牌', d.brand]); if (d.product_type) fields.push(['产品类型', d.product_type]); + if (title && title.indexOf('海外') !== -1 && window._canViewField('orders_overseas', '新旧品')) { + fields.push(['新旧品', el.dataset.newOld || '-']); + } if (d.product_grade) fields.push(['产品分级', d.product_grade]); if (d.cost_price !== null && d.cost_price !== undefined && d.cost_price !== '') fields.push(['成本价', formatPrice4(d.cost_price, true)]); if (d.supplier_name) fields.push(['供应商', d.supplier_name]); @@ -19927,6 +19986,7 @@ function showOrderDetail(encodedStr, title) { col2 = [ {label:'预计到货日期', field:'预计到货日期', editable:true, type:'date'}, {label:'货品名称', field:'货品名称', editable:false}, + {label:'新旧品', field:'新旧品', editable:false}, {label:'跟单下单日期', field:'日期', editable:true, type:'date'}, {label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'}, {label:'实际下单数量', field:'数量', editable:true}, @@ -22181,8 +22241,9 @@ function loadOverseasOrdersPage(container) { if (fieldAttr === '产品编码') thStyle = 'min-width:120px;position:sticky;left:90px;z-index:3;background:var(--bg,#f5f5f5)'; else if (fieldAttr === '货品名称') thStyle = 'min-width:200px;position:sticky;left:210px;z-index:3;background:var(--bg,#f5f5f5)'; else if (fieldAttr === '货品类型') thStyle = 'min-width:100px;position:sticky;left:410px;z-index:3;background:var(--bg,#f5f5f5)'; - else if (fieldAttr === '日期') thStyle = 'width:85px;min-width:85px;position:sticky;left:510px;z-index:3;background:var(--bg,#f5f5f5)'; - else if (fieldAttr === '计划下单时间' || fieldAttr === '新旧品') thStyle = 'width:1%;white-space:nowrap'; + else if (fieldAttr === '新旧品') thStyle = 'width:80px;min-width:80px;position:sticky;left:510px;z-index:3;background:var(--bg,#f5f5f5)'; + else if (fieldAttr === '日期') thStyle = 'width:85px;min-width:85px;position:sticky;left:590px;z-index:3;background:var(--bg,#f5f5f5)'; + else if (fieldAttr === '计划下单时间') thStyle = 'width:1%;white-space:nowrap'; else if (fieldAttr === '单价') thStyle = 'min-width:70px'; else if (fieldAttr === '是否审核价格') thStyle = 'min-width:80px'; else if (fieldAttr === '数量') thStyle = 'min-width:60px'; @@ -22355,13 +22416,13 @@ function overseasLoadOrders(page) { overseasOrderPage = page; order.forEach(function(field) { var cellHtml = ''; if (field === '产品编码') { - cellHtml = '' + escHtml(r['产品编码'] || '') + ''; + cellHtml = '' + escHtml(r['产品编码'] || '') + ''; } else if (field === '货品名称') { cellHtml = '' + escHtml(r['货品名称'] || '') + ''; } else if (field === '货品类型') { cellHtml = '' + escHtml(r['货品类型'] || '') + ''; } else if (field === '日期') { - cellHtml = '' + escHtml(window._padDateStr(r['日期'] || '')) + ''; + cellHtml = '' + escHtml(window._padDateStr(r['日期'] || '')) + ''; } else if (field === '计划下单时间') { var planTime = window._formatPlanOrderTime(r['计划下单时间'] || ''); cellHtml = '' + escHtml(planTime) + ''; @@ -22381,7 +22442,7 @@ function overseasLoadOrders(page) { overseasOrderPage = page; } else if (field === '仓库') { cellHtml = '' + escHtml(r['仓库'] || '') + ''; } else if (field === '新旧品') { - cellHtml = '' + escHtml(r['新旧品'] || '') + ''; + cellHtml = '' + escHtml(r['新旧品'] || '') + ''; } else if (field === '是否已做采购单') { cellHtml = '' + escHtml(r['是否已做采购单'] || '') + ''; } else if (field === '是否做入库') { @@ -25006,6 +25067,7 @@ const OVERSEAS_COLUMNS = [ {field:'产品编码',label:'货品编号',defShow:true}, {field:'货品名称',label:'货品名称',defShow:true}, {field:'货品类型',label:'货品类型',defShow:true}, + {field:'新旧品',label:'新旧品',defShow:true}, {field:'日期',label:'跟单下单日期',defShow:true}, {field:'计划下单时间',label:'计划下单日期',defShow:true}, {field:'单价',label:'单价',defShow:true,type:'number'}, @@ -25014,7 +25076,6 @@ const OVERSEAS_COLUMNS = [ {field:'金额',label:'金额',defShow:true,type:'number'}, {field:'供应商',label:'供应商名称',defShow:true}, {field:'仓库',label:'仓库',defShow:true}, - {field:'新旧品',label:'新旧品',defShow:true}, {field:'是否已做采购单',label:'已做采购单',defShow:true}, {field:'是否做入库',label:'金蝶是否入库',defShow:true}, {field:'采购员',label:'跟单小组',defShow:true}, @@ -25434,6 +25495,11 @@ window.loadOrderScheme = function(type, schemeId) { pendingVisible[c.field] = false; } }); + if (type === 'overseas') { + newOrder = newOrder.filter(function(field) { return field !== '新旧品'; }); + var productTypeIndex = newOrder.indexOf('货品类型'); + newOrder.splice(productTypeIndex < 0 ? newOrder.length : productTypeIndex + 1, 0, '新旧品'); + } window[dragVar] = newOrder.slice(); Object.keys(pendingVisible).forEach(function(field) { window[pendingVar][field] = pendingVisible[field]; @@ -27075,7 +27141,7 @@ window.exportBom = function() { // ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)─── var POPUP_VISIBLE_FIELDS = [ - 'product_type', '产品种类', 'product_code', 'product_name', '产品分级', + 'product_type', '产品种类', 'product_code', 'product_name', '新旧品', '产品分级', '昨日销量', '销售趋势', '库存', '加工厂库存', '生产在途', '采购在途', '日销量预测', @@ -27090,6 +27156,7 @@ var POPUP_VISIBLE_FIELDS = [ /** 字段显示名称映射 */ var POPUP_FIELD_LABELS = { + '新旧品': '新旧品', 'product_type': '产品类型', '产品种类': '产品种类', 'product_code': '货品编号', @@ -27297,6 +27364,7 @@ function planBatchOrder() { } showCenteredConfirm('确认', '确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?').then(function(ok) { if (!ok) return; + if (planNewOldStillSaving(checkedCodes)) return; // 清除待下单页面缓存 if (window._pageCache) delete window._pageCache['purchase']; fetch('/api/purchase/batch_add_pending', { @@ -27508,6 +27576,7 @@ window.planOrder = function(data) { alert('数据异常,无法下单'); return; } + if (planNewOldStillSaving([data.product_code])) return; // 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过) var fqvSpan = document.getElementById('fqv-' + data.product_code); if (fqvSpan) { @@ -27680,6 +27749,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) { document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); }; document.getElementById('planOrderConfirmBtn').onclick = function() { + if (planNewOldStillSaving([data.product_code])) return; modal.remove(); if (window._pageCache) delete window._pageCache['purchase']; var orderData = JSON.parse(JSON.stringify(data)); @@ -27765,6 +27835,7 @@ window.showProductDetail = function(productCode) { // \u5206\u7ec4\u5b9a\u4e49\uff1a\u57fa\u7840\u4fe1\u606f\uff082\u5217\uff09/ \u9500\u91cf\u4fe1\u606f\uff084\u5217\uff09 var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name', + '新旧品', '\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8']; var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014', '\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf', diff --git a/tests/test_planning_new_old.py b/tests/test_planning_new_old.py new file mode 100644 index 0000000..1d61bf4 --- /dev/null +++ b/tests/test_planning_new_old.py @@ -0,0 +1,121 @@ +"""Exercise the manual flag across save, pending and overseas confirmation.""" +import unittest +from unittest import mock +from flask import Flask + +import routes.planning as planning +import routes.purchase as purchase +import services.field_perms as field_perms +import services.planning_order_flag as flags + + +class PlanningNewOldTest(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.secret_key = 'unit-test' + + def test_save_rejects_invalid_choice_and_missing_permission(self): + for value, allowed, expected in [('新品', True, 400), ('是', False, 403)]: + with self.subTest(value=value, allowed=allowed), self.app.test_request_context( + json={'product_code': 'P1', 'value': value} + ), mock.patch.object(planning, 'operation_required', return_value=allowed), \ + mock.patch.object(field_perms, 'field_perms_for_page', return_value={'新旧品': {'edit': allowed}}), \ + mock.patch.object(planning, 'query') as sql: + response, status = planning.planning_new_old_save.__wrapped__() + self.assertEqual(status, expected) + sql.assert_not_called() + + def test_save_both_choices(self): + for value in ('是', '否'): + with self.subTest(value=value), self.app.test_request_context( + json={'product_code': 'P1', 'value': value} + ), mock.patch.object(planning, 'operation_required', return_value=True), \ + mock.patch.object(field_perms, 'field_perms_for_page', return_value={'新旧品': {'edit': True}}), \ + mock.patch.object(planning, 'query', return_value=[{'planning_new_old': '否'}]) as sql, \ + mock.patch.object(planning, 'add_operation_log'): + response = planning.planning_new_old_save.__wrapped__() + self.assertEqual(response.get_json()['code'], 0) + self.assertEqual(sql.call_args.args[1], (value, 'P1')) + + def test_saved_snapshot_survives_later_planning_changes(self): + for value in ('是', '否'): + with mock.patch.object(flags, 'query') as sql: + self.assertEqual(flags.snapshot_new_old('P1', value), value) + sql.assert_not_called() + with mock.patch.object(flags, 'query', return_value=[{'planning_new_old': '是'}]): + self.assertEqual(flags.snapshot_new_old('P1'), '是') + + def test_single_add_and_quantity_edit_capture_and_preserve_choice(self): + for existing, captured in [(None, '是'), ({'id': 5, 'bom_items': None, 'new_old_product': '否'}, '否')]: + writes = [] + def sql(query, params=None): + if query.lstrip().startswith('SELECT id, bom_items'): + return [existing] if existing else [] + if query.lstrip().startswith('SELECT'): + return [] + self.assertEqual(query.count('%s'), len(params)) + writes.append((query, params)) + return 5 + with self.subTest(existing=existing), self.app.test_request_context(json={ + 'product_code': 'P1', 'product_name': '商品', 'product_type': '成品', 'formal_order_qty': 10 + }), mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \ + mock.patch.object(purchase, 'query', side_effect=sql), \ + mock.patch.object(flags, 'query', return_value=[{'planning_new_old': '是'}]): + response = purchase.purchase_add_pending.__wrapped__() + self.assertEqual(response.get_json()['code'], 0) + params = writes[0][1] + self.assertEqual(params[-2] if existing else params[-1], captured) + + def test_batch_add_captures_each_product_choice(self): + writes = [] + def sql(query, params=None): + if 'SELECT p.product_code' in query: + return [{'product_code': 'P1', 'planning_new_old': '是'}, + {'product_code': 'P2', 'planning_new_old': '否'}] + if query.lstrip().startswith('SELECT'): + return [] + self.assertEqual(query.count('%s'), len(params)) + writes.append(params) + return 1 + with self.app.test_request_context(json={'product_codes': ['P1', 'P2']}), \ + mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \ + mock.patch.object(purchase, 'query', side_effect=sql), \ + mock.patch.object(purchase, 'add_operation_log'): + response = purchase.purchase_batch_add_pending.__wrapped__() + self.assertEqual(response.get_json()['data']['added'], 2) + self.assertEqual([params[-1] for params in writes], ['是', '否']) + + def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self): + for value in ('是', '否'): + writes = [] + def sql(query, params=None): + if 'SELECT product_code FROM pending_orders' in query: + return [{'product_code': 'P1'}] + if 'SELECT brand, product_name FROM products' in query: + return [{'brand': 'TEST', 'product_name': '商品'}] + if 'SELECT category FROM brand_information' in query: + return [{'category': '海外'}] + if 'SELECT * FROM pending_orders' in query: + return [{'id': 5, 'product_code': 'P1', 'product_name': '商品', + 'formal_order_qty': 10, 'new_old_product': value}] + if query.lstrip().startswith('SELECT'): + return [] + self.assertEqual(query.count('%s'), len(params)) + writes.append((query, params)) + return 1 + with self.subTest(value=value), self.app.test_request_context(json={'product_codes': ['P1']}), \ + mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \ + mock.patch.object(purchase, 'query', side_effect=sql), \ + mock.patch.object(purchase, 'execute', return_value=1), \ + mock.patch.object(purchase, '_resolve_follower_group', return_value=''), \ + mock.patch.object(purchase, '_is_overseas_notify_product', return_value=False), \ + mock.patch.object(flags, 'query') as current: + response = purchase.purchase_confirm_order.__wrapped__() + self.assertEqual(response.get_json()['code'], 0) + order = next(params for query, params in writes if 'INSERT INTO overseas_orders' in query) + self.assertEqual(order[-1], value) + current.assert_not_called() + + +if __name__ == '__main__': + unittest.main()