feat: capture planning new-old choice in overseas orders
This commit is contained in:
parent
24d0fab271
commit
910612b03f
@ -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 供应商名称,
|
||||
|
||||
@ -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",
|
||||
|
||||
28
scripts/migrate_planning_new_old.py
Normal file
28
scripts/migrate_planning_new_old.py
Normal 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
717
services/field_perms.py
Normal 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
|
||||
10
services/planning_order_flag.py
Normal file
10
services/planning_order_flag.py
Normal 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 '否'
|
||||
@ -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 '<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) {
|
||||
inlineEditClick(el);
|
||||
};
|
||||
@ -13825,6 +13870,9 @@ function renderPlanTable(rows) {
|
||||
var cls = trendColors[raw] || 'tag-grey';
|
||||
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
|
||||
}
|
||||
if (c.field === '新旧品') {
|
||||
return renderPlanNewOldSelect(r.product_code, raw);
|
||||
}
|
||||
if (c.field === '是否新品') {
|
||||
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) {
|
||||
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);
|
||||
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 =
|
||||
'<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>' +
|
||||
@ -17555,6 +17610,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
|
||||
'</table>' +
|
||||
'</div>';
|
||||
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="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>' +
|
||||
@ -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 = '<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 === '货品名称') {
|
||||
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 === '货品类型') {
|
||||
cellHtml = '<td style="position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['货品类型'] || '') + '</td>';
|
||||
} 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 === '计划下单时间') {
|
||||
var planTime = window._formatPlanOrderTime(r['计划下单时间'] || '');
|
||||
cellHtml = '<td>' + escHtml(planTime) + '</td>';
|
||||
@ -22381,7 +22442,7 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
|
||||
} else if (field === '仓库') {
|
||||
cellHtml = '<td>' + escHtml(r['仓库'] || '') + '</td>';
|
||||
} 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 === '是否已做采购单') {
|
||||
cellHtml = '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>';
|
||||
} 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',
|
||||
|
||||
121
tests/test_planning_new_old.py
Normal file
121
tests/test_planning_new_old.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user