feat: capture planning new-old choice in overseas orders

This commit is contained in:
Codex 2026-10-13 11:55:01 +08:00
parent 24d0fab271
commit 910612b03f
7 changed files with 1013 additions and 30 deletions

View File

@ -1,7 +1,7 @@
"""计划预测路由(最肥模块)""" """计划预测路由(最肥模块)"""
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template 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.product_type import get_product_type_cached
from services.sales_calc import compute_product_sales_cached, compute_product_sales_extended_cached from services.sales_calc import compute_product_sales_cached, compute_product_sales_extended_cached
from services.inventory_calc import (compute_product_inventory_cached, from services.inventory_calc import (compute_product_inventory_cached,
@ -21,6 +21,28 @@ import json
planning_bp = Blueprint('planning', __name__) 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): def _personnel_group_for_value(raw_value, groups):
"""Infer a group's label from a stored person list without cross-group leakage.""" """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()] 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): extended_sales=None):
"""给一行产品数据补充计划预测相关的所有计算字段""" """给一行产品数据补充计划预测相关的所有计算字段"""
pc = row.get('product_code', '') pc = row.get('product_code', '')
row['新旧品'] = row.get('planning_new_old') or '否'
inv_val = inv_map.get(pc, 0) inv_val = inv_map.get(pc, 0)
inv15_val = inv15_map.get(pc, 0) inv15_val = inv15_map.get(pc, 0)
t = transit_map.get(pc, {}) t = transit_map.get(pc, {})
@ -556,6 +579,7 @@ def planning_list():
'生产货期': 'p.lead_time', '生产货期': 'p.lead_time',
'物流时间': 'p.logistics_time', '物流时间': 'p.logistics_time',
'最低起订量': 'p.min_order_qty', '最低起订量': 'p.min_order_qty',
'新旧品': 'p.planning_new_old',
} }
db_col = db_col_map.get(ff) db_col = db_col_map.get(ff)
if ft == 'search': if ft == 'search':
@ -648,7 +672,7 @@ def planning_list():
# 全量捞取 + Python端筛选排序 # 全量捞取 + Python端筛选排序
all_rows = query(f""" all_rows = query(f"""
SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, 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 创建时间, 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 采购跟单, COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, p.procurement_follower AS 采购跟单,
p.procurement_planner AS 采购计划, p.operator AS 运营专员, p.procurement_planner AS 采购计划, p.operator AS 运营专员,
@ -863,7 +887,7 @@ def planning_list():
# SQL分页 + 少量Python计算 # SQL分页 + 少量Python计算
rows = query(f""" rows = query(f"""
SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, 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 创建时间, 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 采购跟单, COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, p.procurement_follower AS 采购跟单,
p.procurement_planner AS 采购计划, p.operator AS 运营专员, p.procurement_planner AS 采购计划, p.operator AS 运营专员,
@ -948,7 +972,7 @@ def planning_export():
'建议回货数量','specification', '建议回货数量','specification',
'30天平均销量','60天平均销量','3天最高销量','7天最高销量','14天最高销量','30天最高销量', '30天平均销量','60天平均销量','3天最高销量','7天最高销量','14天最高销量','30天最高销量',
'14天最低销量','14天标准差','最低销量偏差率','最高销量偏差率','极值判断','14天波动率', '14天最低销量','14天标准差','最低销量偏差率','最高销量偏差率','极值判断','14天波动率',
'波动风险','生产货期','物流时间','最低起订量','创建时间','是否新品','一级类目','二级类目','三级类目' '波动风险','生产货期','物流时间','最低起订量','创建时间','是否新品','新旧品','一级类目','二级类目','三级类目'
} }
columns = [c for c in columns if isinstance(c, dict) and c.get('field') in allowed_fields] columns = [c for c in columns if isinstance(c, dict) and c.get('field') in allowed_fields]
if not columns: if not columns:
@ -974,7 +998,7 @@ def planning_export():
# ── 全量拉取(与列表全量分支一致)── # ── 全量拉取(与列表全量分支一致)──
all_rows = query(f""" all_rows = query(f"""
SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, 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 创建时间, 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 采购跟单, COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, p.procurement_follower AS 采购跟单,
p.procurement_planner AS 采购计划, p.operator AS 运营专员, p.procurement_planner AS 采购计划, p.operator AS 运营专员,
@ -1158,6 +1182,7 @@ def planning_brands():
'二级类目': 'p.category2', '三级类目': 'p.category3', '二级类目': 'p.category2', '三级类目': 'p.category3',
'成本价': 'p.cost_price', '生产货期': 'p.lead_time', '成本价': 'p.cost_price', '生产货期': 'p.lead_time',
'物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty', '物流时间': 'p.logistics_time', '最低起订量': 'p.min_order_qty',
'新旧品': 'p.planning_new_old',
} }
for _k, _v in _pfs.items(): for _k, _v in _pfs.items():
if _k == 'brand': if _k == 'brand':
@ -1253,6 +1278,7 @@ def planning_column_values():
'生产货期': 'p.lead_time', '生产货期': 'p.lead_time',
'物流时间': 'p.logistics_time', '物流时间': 'p.logistics_time',
'最低起订量': 'p.min_order_qty', '最低起订量': 'p.min_order_qty',
'新旧品': 'p.planning_new_old',
} }
for _k, _v in _pfs.items(): for _k, _v in _pfs.items():
if _k == field: if _k == field:
@ -1315,6 +1341,7 @@ def planning_column_values():
'生产货期': 'p.lead_time', '生产货期': 'p.lead_time',
'物流时间': 'p.logistics_time', '物流时间': 'p.logistics_time',
'最低起订量': 'p.min_order_qty', '最低起订量': 'p.min_order_qty',
'新旧品': 'p.planning_new_old',
} }
db_col = db_col_map.get(field) db_col = db_col_map.get(field)
@ -1352,7 +1379,7 @@ def planning_column_values():
try: try:
all_rows = query(f""" all_rows = query(f"""
SELECT p.product_code, p.product_name, p.brand, p.product_type, p.specification, 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.lead_time AS 生产货期, p.logistics_time AS 物流时间,
p.min_order_qty AS 最低起订量, p.created_at AS 创建时间, p.min_order_qty AS 最低起订量, p.created_at AS 创建时间,
COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称, COALESCE(s.supplier_name, p.supplier_name_match) AS 供应商名称,

View File

@ -957,7 +957,7 @@ def purchase_add_pending():
is_update = bool(keys & update_fields) is_update = bool(keys & update_fields)
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。 # 待下单当前行按 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: if existing_pending and not is_update:
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"}) return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
@ -1026,6 +1026,8 @@ def purchase_add_pending():
remark_val = data.get('remark', '') remark_val = data.get('remark', '')
audit_price = float(data.get('audit_price', 0)) 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 唯一索引; # 已存在待下单行时按 id 更新,避免依赖 product_code 唯一索引;
# 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。 # 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。
if existing_pending: if existing_pending:
@ -1035,13 +1037,14 @@ def purchase_add_pending():
lead_time=%s, logistics_time=%s, total_support_days=%s, lead_time=%s, logistics_time=%s, total_support_days=%s,
procurement_follower=%s, procurement_planner=%s, platform=%s, operator=%s, created_by=%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, 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 WHERE id=%s
""", ( """, (
pn, pt, pg, sn, pn, pt, pg, sn,
int(lt), int(lo), float(ts), int(lt), int(lo), float(ts),
pf, pp, pl, op, created_by, 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,
existing_pending[0]['id'] existing_pending[0]['id']
)) ))
else: else:
@ -1050,15 +1053,15 @@ def purchase_add_pending():
(product_code, product_name, product_type, product_grade, supplier_name, (product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by, procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark) 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) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", ( """, (
pc, pc,
pn, pt, pg, sn, pn, pt, pg, sn,
int(lt), int(lo), float(ts), int(lt), int(lo), float(ts),
pf, pp, pl, op, pf, pp, pl, op,
created_by, 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: if req_bom_items and bom_items_normalized:
_sync_pending_pre_packaging(pc, bom_items_normalized) _sync_pending_pre_packaging(pc, bom_items_normalized)
@ -1294,10 +1297,13 @@ def purchase_confirm_order():
has_product_order = True has_product_order = True
if is_overseas and qty > 0: 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 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,下单备注,旺店通生产单号) (日期,计划下单时间,产品编码,状态,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) 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, None, now_str,
row.get('product_code'), '未完结', row.get('product_code'), '未完结',
@ -1321,7 +1327,8 @@ def purchase_confirm_order():
row.get('procurement_follower'), row.get('procurement_follower'),
row.get('product_name', ''), row.get('product_name', ''),
row.get('remark', ''), row.get('remark', ''),
row.get('production_order_no', '') row.get('production_order_no', ''),
new_old_product
)) ))
elif not is_overseas and qty > 0: elif not is_overseas and qty > 0:
previous_order = _latest_domestic_order_values(row.get('product_code')) previous_order = _latest_domestic_order_values(row.get('product_code'))
@ -1610,7 +1617,7 @@ def purchase_batch_add_pending():
products = query(f""" products = query(f"""
SELECT p.product_code, p.product_name, p.product_type, SELECT p.product_code, p.product_name, p.product_type,
p.lead_time, p.logistics_time, p.supplier_name_match AS supplier_name, 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}) FROM products p WHERE p.product_code IN ({placeholders})
""", product_codes) """, product_codes)
@ -1638,14 +1645,15 @@ def purchase_batch_add_pending():
(product_code, product_name, product_type, product_grade, supplier_name, (product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, procurement_follower, procurement_planner, platform, operator,
audit_price, formal_order_qty, bom_items) 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) 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', ''), '', pc, p.get('product_name', ''), p.get('product_type', ''), '',
p.get('supplier_name', ''), int(p.get('lead_time', 0) or 0), p.get('supplier_name', ''), int(p.get('lead_time', 0) or 0),
int(p.get('logistics_time', 0) or 0), 0, int(p.get('logistics_time', 0) or 0), 0,
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '', 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 added += 1
@ -3795,14 +3803,15 @@ def purchase_rollback_order():
lead_time, logistics_time, total_support_days, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by, procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items,
production_order_no, remark, status, created_at, updated_at, ordered_at) 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, 'pending', NOW(), NOW(), NULL) 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('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('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('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('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( pending_check = query(
"SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1", "SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1",

View File

@ -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()

717
services/field_perms.py Normal file
View File

@ -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

View File

@ -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 '否'

View File

@ -13005,6 +13005,7 @@ const PLAN_COLUMNS = [
{field: 'product_name', label: '货品名称', defaultShow: true}, {field: 'product_name', label: '货品名称', defaultShow: true},
{field: 'brand', label: '品牌', defaultShow: true}, {field: 'brand', label: '品牌', defaultShow: true},
{field: '批注', label: '批注', defaultShow: true}, {field: '批注', label: '批注', defaultShow: true},
{field: '新旧品', label: '新旧品', defaultShow: true},
{field: 'bom', label: '下单清单', defaultShow: true, filterable: false}, {field: 'bom', label: '下单清单', defaultShow: true, filterable: false},
{field: '产品分级', label: '产品分级', defaultShow: true}, {field: '产品分级', label: '产品分级', defaultShow: true},
{field: '昨日销量', label: '昨日销量', defaultShow: true}, {field: '昨日销量', label: '昨日销量', defaultShow: true},
@ -13091,6 +13092,7 @@ const PLAN_FILTER_OPTIONS = {
'产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'], '产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'],
'销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'], '销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'],
'是否新品': ['新品', '返单品'], '是否新品': ['新品', '返单品'],
'新旧品': ['是', '否'],
'风险把控': ['正常', '新品', '呆滞品', '需启动清库存'], '风险把控': ['正常', '新品', '呆滞品', '需启动清库存'],
'波动风险': ['X', 'Y', 'Z'], '波动风险': ['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 '<select class="form-input plan-new-old-select" aria-label="新旧品" style="width:70px;padding:3px 6px" data-code="' + escHtml(code) + '" data-original="' + selected + '" onchange="savePlanNewOld(this)"' + (editable ? '' : ' disabled') + '><option value="是"' + (selected === '是' ? ' selected' : '') + '>是</option><option value="否"' + (selected === '否' ? ' selected' : '') + '>否</option></select>';
}
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) { window.prodInlineEditClick = function(el) {
inlineEditClick(el); inlineEditClick(el);
}; };
@ -13825,6 +13870,9 @@ function renderPlanTable(rows) {
var cls = trendColors[raw] || 'tag-grey'; var cls = trendColors[raw] || 'tag-grey';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>'; return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
} }
if (c.field === '新旧品') {
return renderPlanNewOldSelect(r.product_code, raw);
}
if (c.field === '是否新品') { if (c.field === '是否新品') {
return (raw === '新品') ? '<span class="tag tag-blue">新品</span>' : '<span class="tag tag-grey">返单品</span>'; return (raw === '新品') ? '<span class="tag tag-blue">新品</span>' : '<span class="tag tag-grey">返单品</span>';
} }
@ -15159,9 +15207,15 @@ window.loadPlanScheme = function(schemeId) {
}); });
PLAN_COLUMNS.forEach(function(c) { PLAN_COLUMNS.forEach(function(c) {
if (pendingVisible[c.field] === undefined) { if (pendingVisible[c.field] === undefined) {
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); newOrder.push(c.field);
pendingVisible[c.field] = false; pendingVisible[c.field] = false;
} }
}
}); });
// 更新临时勾选状态和拖拽顺序 // 更新临时勾选状态和拖拽顺序
@ -17534,6 +17588,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
var modalId = 'bom-modal-' + Date.now(); var modalId = 'bom-modal-' + Date.now();
// 批注同步外面列表的批注内容(products.remark,与计划预测批注列同源) // 批注同步外面列表的批注内容(products.remark,与计划预测批注列同源)
var productRemark = (res.data.product && res.data.product['批注']) || ''; var productRemark = (res.data.product && res.data.product['批注']) || '';
var productNewOld = (res.data.product && res.data.product.planning_new_old) || '否';
var productSummaryHtml = var productSummaryHtml =
'<div style="margin-bottom:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' + '<div style="margin-bottom:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' +
'<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📦 成品下单信息</div>' + '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📦 成品下单信息</div>' +
@ -17555,6 +17610,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
'</table>' + '</table>' +
'</div>'; '</div>';
var bomContent = productSummaryHtml + var bomContent = productSummaryHtml +
(window._canViewField('planning', '新旧品') ? '<div style="margin-bottom:12px">新旧品:' + renderPlanNewOldSelect(productCode, productNewOld) + '</div>' : '') +
'<div style="margin-top:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' + '<div style="margin-top:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' +
'<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📝 下单备注</div>' + '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📝 下单备注</div>' +
'<textarea class="form-input bom-product-remark" autocomplete="off" style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:vertical;line-height:1.6" data-product="' + escHtml(productCode) + '" placeholder="下单备注"' + (window._canEditField && window._canEditField('planning','下单备注') ? '' : ' readonly style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:none;line-height:1.6;background:#f5f5f5;color:#999"') + '>' + escHtml(orderRemark || '') + '</textarea>' + '<textarea class="form-input bom-product-remark" autocomplete="off" style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:vertical;line-height:1.6" data-product="' + escHtml(productCode) + '" placeholder="下单备注"' + (window._canEditField && window._canEditField('planning','下单备注') ? '' : ' readonly style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:none;line-height:1.6;background:#f5f5f5;color:#999"') + '>' + escHtml(orderRemark || '') + '</textarea>' +
@ -19790,6 +19846,9 @@ window.showSkuInfoModal = function(el, title) {
if (d.product_name) fields.push(['货品名称', d.product_name]); if (d.product_name) fields.push(['货品名称', d.product_name]);
if (d.brand) fields.push(['品牌', d.brand]); if (d.brand) fields.push(['品牌', d.brand]);
if (d.product_type) fields.push(['产品类型', d.product_type]); 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.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.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]); if (d.supplier_name) fields.push(['供应商', d.supplier_name]);
@ -19927,6 +19986,7 @@ function showOrderDetail(encodedStr, title) {
col2 = [ col2 = [
{label:'预计到货日期', field:'预计到货日期', editable:true, type:'date'}, {label:'预计到货日期', field:'预计到货日期', editable:true, type:'date'},
{label:'货品名称', field:'货品名称', editable:false}, {label:'货品名称', field:'货品名称', editable:false},
{label:'新旧品', field:'新旧品', editable:false},
{label:'跟单下单日期', field:'日期', editable:true, type:'date'}, {label:'跟单下单日期', field:'日期', editable:true, type:'date'},
{label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'}, {label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'},
{label:'实际下单数量', field:'数量', editable:true}, {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)'; 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: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 = '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 === '新旧品') thStyle = 'width:80px;min-width:80px;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: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:70px';
else if (fieldAttr === '是否审核价格') thStyle = 'min-width:80px'; else if (fieldAttr === '是否审核价格') thStyle = 'min-width:80px';
else if (fieldAttr === '数量') thStyle = 'min-width:60px'; else if (fieldAttr === '数量') thStyle = 'min-width:60px';
@ -22355,13 +22416,13 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
order.forEach(function(field) { order.forEach(function(field) {
var cellHtml = ''; var cellHtml = '';
if (field === '产品编码') { if (field === '产品编码') {
cellHtml = '<td style="position:sticky;left:90px;z-index:2;background:var(--card-bg,#fff)"><code style="cursor:pointer;color:#1890ff" onclick="showSkuInfoModal(this,\'海外订单详情\')">' + escHtml(r['产品编码'] || '') + '</code></td>'; cellHtml = '<td style="position:sticky;left:90px;z-index:2;background:var(--card-bg,#fff)"><code data-new-old="' + escHtml(r['新旧品'] || '') + '" style="cursor:pointer;color:#1890ff" onclick="showSkuInfoModal(this,\'海外订单详情\')">' + escHtml(r['产品编码'] || '') + '</code></td>';
} else if (field === '货品名称') { } else if (field === '货品名称') {
cellHtml = '<td class="order-product-name-cell" style="position:sticky;left:210px;z-index:2;background:var(--card-bg,#fff)"><span class="order-product-name-text" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</span></td>'; cellHtml = '<td class="order-product-name-cell" style="position:sticky;left:210px;z-index:2;background:var(--card-bg,#fff)"><span class="order-product-name-text" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</span></td>';
} else if (field === '货品类型') { } else if (field === '货品类型') {
cellHtml = '<td style="position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['货品类型'] || '') + '</td>'; cellHtml = '<td style="position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['货品类型'] || '') + '</td>';
} else if (field === '日期') { } else if (field === '日期') {
cellHtml = '<td style="position:sticky;left:510px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(window._padDateStr(r['日期'] || '')) + '</span></td>'; cellHtml = '<td style="position:sticky;left:590px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(window._padDateStr(r['日期'] || '')) + '</span></td>';
} else if (field === '计划下单时间') { } else if (field === '计划下单时间') {
var planTime = window._formatPlanOrderTime(r['计划下单时间'] || ''); var planTime = window._formatPlanOrderTime(r['计划下单时间'] || '');
cellHtml = '<td>' + escHtml(planTime) + '</td>'; cellHtml = '<td>' + escHtml(planTime) + '</td>';
@ -22381,7 +22442,7 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
} else if (field === '仓库') { } else if (field === '仓库') {
cellHtml = '<td>' + escHtml(r['仓库'] || '') + '</td>'; cellHtml = '<td>' + escHtml(r['仓库'] || '') + '</td>';
} else if (field === '新旧品') { } else if (field === '新旧品') {
cellHtml = '<td>' + escHtml(r['新旧品'] || '') + '</td>'; cellHtml = '<td style="position:sticky;left:510px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['新旧品'] || '') + '</td>';
} else if (field === '是否已做采购单') { } else if (field === '是否已做采购单') {
cellHtml = '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>'; cellHtml = '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>';
} else if (field === '是否做入库') { } 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},
{field:'日期',label:'跟单下单日期',defShow:true}, {field:'日期',label:'跟单下单日期',defShow:true},
{field:'计划下单时间',label:'计划下单日期',defShow:true}, {field:'计划下单时间',label:'计划下单日期',defShow:true},
{field:'单价',label:'单价',defShow:true,type:'number'}, {field:'单价',label:'单价',defShow:true,type:'number'},
@ -25014,7 +25076,6 @@ const OVERSEAS_COLUMNS = [
{field:'金额',label:'金额',defShow:true,type:'number'}, {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}, {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; 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(); window[dragVar] = newOrder.slice();
Object.keys(pendingVisible).forEach(function(field) { Object.keys(pendingVisible).forEach(function(field) {
window[pendingVar][field] = pendingVisible[field]; window[pendingVar][field] = pendingVisible[field];
@ -27075,7 +27141,7 @@ window.exportBom = function() {
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)─── // ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
var POPUP_VISIBLE_FIELDS = [ 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 = { var POPUP_FIELD_LABELS = {
'新旧品': '新旧品',
'product_type': '产品类型', 'product_type': '产品类型',
'产品种类': '产品种类', '产品种类': '产品种类',
'product_code': '货品编号', 'product_code': '货品编号',
@ -27297,6 +27364,7 @@ function planBatchOrder() {
} }
showCenteredConfirm('确认', '确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?').then(function(ok) { showCenteredConfirm('确认', '确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?').then(function(ok) {
if (!ok) return; if (!ok) return;
if (planNewOldStillSaving(checkedCodes)) return;
// 清除待下单页面缓存 // 清除待下单页面缓存
if (window._pageCache) delete window._pageCache['purchase']; if (window._pageCache) delete window._pageCache['purchase'];
fetch('/api/purchase/batch_add_pending', { fetch('/api/purchase/batch_add_pending', {
@ -27508,6 +27576,7 @@ window.planOrder = function(data) {
alert('数据异常,无法下单'); alert('数据异常,无法下单');
return; return;
} }
if (planNewOldStillSaving([data.product_code])) return;
// 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过) // 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过)
var fqvSpan = document.getElementById('fqv-' + data.product_code); var fqvSpan = document.getElementById('fqv-' + data.product_code);
if (fqvSpan) { if (fqvSpan) {
@ -27680,6 +27749,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) {
document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); }; document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); };
document.getElementById('planOrderConfirmBtn').onclick = function() { document.getElementById('planOrderConfirmBtn').onclick = function() {
if (planNewOldStillSaving([data.product_code])) return;
modal.remove(); modal.remove();
if (window._pageCache) delete window._pageCache['purchase']; if (window._pageCache) delete window._pageCache['purchase'];
var orderData = JSON.parse(JSON.stringify(data)); 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 // \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', 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']; '\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', 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', '\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',

View File

@ -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()