Compare commits
10 Commits
8c229bc162
...
aad91d223c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad91d223c | ||
|
|
6d6a1110a5 | ||
|
|
071d9965fc | ||
|
|
910612b03f | ||
|
|
24d0fab271 | ||
|
|
c88182bcd8 | ||
|
|
9e77a7cdc0 | ||
|
|
93df618998 | ||
|
|
8c3aac43a9 | ||
|
|
ae0cae8cbf |
1488
routes/planning.py
Normal file
1488
routes/planning.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -265,7 +265,9 @@ def products_list():
|
|||||||
if supplier_filter:
|
if supplier_filter:
|
||||||
where.append("(s.supplier_name LIKE %s OR p.supplier_name_match LIKE %s)")
|
where.append("(s.supplier_name LIKE %s OR p.supplier_name_match LIKE %s)")
|
||||||
params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"])
|
params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"])
|
||||||
if brand_filter:
|
if brand_filter == '__EMPTY__':
|
||||||
|
where.append("(p.brand IS NULL OR TRIM(p.brand)='')")
|
||||||
|
elif brand_filter:
|
||||||
where.append("TRIM(p.brand)=%s")
|
where.append("TRIM(p.brand)=%s")
|
||||||
params.append(brand_filter)
|
params.append(brand_filter)
|
||||||
if category1_filter:
|
if category1_filter:
|
||||||
@ -405,10 +407,17 @@ def products_filters():
|
|||||||
f"SELECT DISTINCT category1 FROM products WHERE {scope_clause}"
|
f"SELECT DISTINCT category1 FROM products WHERE {scope_clause}"
|
||||||
"category1 IS NOT NULL AND category1 != '' ORDER BY category1"
|
"category1 IS NOT NULL AND category1 != '' ORDER BY category1"
|
||||||
)
|
)
|
||||||
|
empty_brand_count = query(
|
||||||
|
f"SELECT COUNT(*) AS cnt FROM products WHERE {scope_clause}"
|
||||||
|
"(brand IS NULL OR TRIM(brand) = '')"
|
||||||
|
)[0]['cnt']
|
||||||
|
brand_values = [b['brand'] for b in brands]
|
||||||
|
if empty_brand_count:
|
||||||
|
brand_values.insert(0, '__EMPTY__')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"data": {
|
||||||
"brands": [b['brand'] for b in brands],
|
"brands": brand_values,
|
||||||
"categories": [c['category1'] for c in categories]
|
"categories": [c['category1'] for c in categories]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -495,6 +504,9 @@ def product_batch_update():
|
|||||||
for field, value in fields.items():
|
for field, value in fields.items():
|
||||||
if field not in allowed_fields:
|
if field not in allowed_fields:
|
||||||
continue
|
continue
|
||||||
|
# 品牌来自表格/剪贴板时可能带有尾部 Tab 或换行;只清理首尾空白,保留名称中的正常空格。
|
||||||
|
if field == 'brand' and isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
if value == '' or value is None:
|
if value == '' or value is None:
|
||||||
value = None
|
value = None
|
||||||
# 数值字段范围校验
|
# 数值字段范围校验
|
||||||
@ -851,126 +863,39 @@ def product_planning_detail():
|
|||||||
product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type'))
|
product['产品种类'] = get_product_type_cached(product.get('product_name', ''), product.get('brand', ''), product.get('product_type'))
|
||||||
product['批注'] = product.get('remark') or ''
|
product['批注'] = product.get('remark') or ''
|
||||||
|
|
||||||
# 计算计划预测字段
|
# 复用计划预测列表/导出的唯一计算逻辑,避免详情弹窗另维护一套公式。
|
||||||
|
from routes.planning import _enrich_row_with_planning_data
|
||||||
|
|
||||||
sales = compute_product_sales_cached()
|
sales = compute_product_sales_cached()
|
||||||
inv_map = compute_product_inventory_cached()
|
inv_map = compute_product_inventory_cached()
|
||||||
inv15_map = compute_product_inventory_15()
|
inv15_map = compute_product_inventory_15()
|
||||||
transit_map = compute_product_in_transit_cached()
|
transit_map = compute_product_in_transit_cached()
|
||||||
|
cloud_inv_map = compute_cloud_inventory_cached()
|
||||||
|
ext_inv_map = compute_external_inventory_cached()
|
||||||
|
extended_sales = compute_product_sales_extended_cached()
|
||||||
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
|
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
|
||||||
|
from services.forecast import get_global_config
|
||||||
|
gc = get_global_config()
|
||||||
|
|
||||||
row = product
|
row = product
|
||||||
pc = row.get('product_code', '')
|
pc = row.get('product_code', '')
|
||||||
row['库存'] = inv_map.get(pc, 0)
|
row = _enrich_row_with_planning_data(
|
||||||
row['加工厂库存'] = inv15_map.get(pc, 0)
|
row, sales, inv_map, inv15_map, transit_map, sp_map, gc,
|
||||||
t = transit_map.get(pc, {})
|
cloud_inv_map=cloud_inv_map,
|
||||||
row['生产在途'] = t.get('生产在途', 0)
|
ext_inv_map=ext_inv_map,
|
||||||
row['采购在途'] = t.get('采购在途', 0)
|
extended_sales=extended_sales,
|
||||||
|
)
|
||||||
if pc in sales:
|
|
||||||
s = sales[pc]
|
|
||||||
for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量',
|
|
||||||
'30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量',
|
|
||||||
'14天最高销量', '30天最高销量', '14天最低销量', '14天标准差',
|
|
||||||
'最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']:
|
|
||||||
row[k] = s.get(k, 0)
|
|
||||||
row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量'])
|
|
||||||
else:
|
|
||||||
for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量',
|
|
||||||
'30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量',
|
|
||||||
'14天最高销量', '30天最高销量', '14天最低销量', '14天标准差',
|
|
||||||
'最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']:
|
|
||||||
row[k] = 0
|
|
||||||
row['销售趋势'] = _determine_trend(0, 0, 0)
|
|
||||||
|
|
||||||
row['产品分级'] = get_product_grade(row.get('30天平均销量', 0) * 30,
|
|
||||||
row.get('product_name', ''),
|
|
||||||
row.get('产品种类', ''))
|
|
||||||
row['是否新品'] = is_new_product(row.get('created_at', ''))
|
|
||||||
row['日销量预测'] = compute_daily_forecast(row, sp_map)
|
|
||||||
|
|
||||||
# 支撑天数
|
|
||||||
from services.forecast import get_global_config
|
|
||||||
gc = get_global_config()
|
|
||||||
safety_days = int(gc.get('safety_stock_days', '7'))
|
|
||||||
return_days = int(gc.get('suggested_return_days', '15'))
|
|
||||||
forecast_val = row.get('日销量预测', 0)
|
|
||||||
fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0
|
|
||||||
inv_val = float(row.get('库存', 0) or 0)
|
|
||||||
inv15_val = float(row.get('加工厂库存', 0) or 0)
|
inv15_val = float(row.get('加工厂库存', 0) or 0)
|
||||||
prod_transit = float(row.get('生产在途', 0) or 0)
|
|
||||||
purch_transit = float(row.get('采购在途', 0) or 0)
|
|
||||||
|
|
||||||
if fcast == 0:
|
|
||||||
row['库存可支撑天数'] = 999
|
|
||||||
row['库存工厂可支撑天数'] = 999
|
|
||||||
row['库存工厂待生产可支撑天数'] = 999
|
|
||||||
row['加工厂库存支撑天数'] = 999
|
|
||||||
row['总支撑天数'] = 999
|
|
||||||
else:
|
|
||||||
row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999
|
|
||||||
row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999
|
|
||||||
row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999
|
|
||||||
row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999
|
|
||||||
total_inv = prod_transit + inv15_val
|
|
||||||
row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999
|
|
||||||
|
|
||||||
lead_h = float(row.get('lead_time', 0) or 0)
|
|
||||||
logis_h = float(row.get('logistics_time', 0) or 0)
|
|
||||||
sup_days = row.get('库存可支撑天数', 999)
|
|
||||||
sup_days_val = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else None
|
|
||||||
if sup_days_val is not None and (lead_h + logis_h) > 0:
|
|
||||||
row['库存健康指数'] = round(min(sup_days_val / (lead_h + logis_h), 999), 1)
|
|
||||||
else:
|
|
||||||
row['库存健康指数'] = 999
|
|
||||||
|
|
||||||
created = row.get('created_at', '')
|
|
||||||
days_since_created = 999
|
|
||||||
try:
|
|
||||||
if created:
|
|
||||||
created_dt = datetime.strptime(created[:10], '%Y-%m-%d') if isinstance(created, str) else created
|
|
||||||
days_since_created = (datetime.now() - created_dt).days
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
sup_days_val_f = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else 999
|
|
||||||
if days_since_created < 90:
|
|
||||||
row['风险把控'] = '新品'
|
|
||||||
elif days_since_created > 180 and sup_days_val_f > 240:
|
|
||||||
row['风险把控'] = '需启动清库存'
|
|
||||||
elif days_since_created > 90 and sup_days_val_f > 90:
|
|
||||||
row['风险把控'] = '呆滞品'
|
|
||||||
else:
|
|
||||||
row['风险把控'] = '正常'
|
|
||||||
|
|
||||||
if fcast == 0 and inv_val > 0:
|
|
||||||
row['建议下单数量'] = 0
|
|
||||||
else:
|
|
||||||
row['建议下单数量'] = int(fcast * (safety_days + lead_h + logis_h))
|
|
||||||
row['实际下单数量'] = ((row['建议下单数量'] + 999) // 1000) * 1000 if row['建议下单数量'] > 0 else 0
|
|
||||||
|
|
||||||
total_transit = prod_transit + purch_transit
|
|
||||||
if fcast > 0 and total_transit > 0:
|
|
||||||
calc_val = fcast * return_days - inv_val
|
|
||||||
round_up = max(0, int(calc_val * 10 + 0.99999) / 10)
|
|
||||||
row['建议回货数量'] = round_up if round_up <= total_transit else total_transit
|
|
||||||
else:
|
|
||||||
row['建议回货数量'] = 0
|
|
||||||
|
|
||||||
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
|
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
|
||||||
cloud_sales = compute_product_sales_extended_cached().get('cloud', {}).get(pc, {})
|
cloud_sales = extended_sales.get('cloud', {}).get(pc, {})
|
||||||
cloud_inventory = compute_cloud_inventory_cached().get(pc, 0)
|
cloud_inventory = row.get('云仓库存', 0)
|
||||||
cloud_3 = cloud_sales.get('3天平均销量', 0)
|
cloud_3 = cloud_sales.get('3天平均销量', 0)
|
||||||
cloud_7 = cloud_sales.get('7天平均销量', 0)
|
cloud_7 = cloud_sales.get('7天平均销量', 0)
|
||||||
cloud_14 = cloud_sales.get('14天平均销量', 0)
|
cloud_14 = cloud_sales.get('14天平均销量', 0)
|
||||||
cloud_30 = cloud_sales.get('30天平均销量', 0)
|
cloud_30 = cloud_sales.get('30天平均销量', 0)
|
||||||
cloud_trend = _determine_trend(cloud_3, cloud_7, cloud_14)
|
cloud_trend = _determine_trend(cloud_3, cloud_7, cloud_14)
|
||||||
cloud_forecast_row = {
|
cloud_forecast = row.get('云仓日销量预测', 0)
|
||||||
'3天平均销量': cloud_3,
|
|
||||||
'7天平均销量': cloud_7,
|
|
||||||
'14天平均销量': cloud_14,
|
|
||||||
'30天最高销量': cloud_sales.get('30天最高销量', 0),
|
|
||||||
'销售趋势': cloud_trend,
|
|
||||||
}
|
|
||||||
cloud_forecast = compute_daily_forecast(cloud_forecast_row, sp_map)
|
|
||||||
cloud_fcast = float(cloud_forecast) if cloud_forecast not in ('近期无销售', 0, '0') else 0
|
cloud_fcast = float(cloud_forecast) if cloud_forecast not in ('近期无销售', 0, '0') else 0
|
||||||
row['云仓数据'] = {
|
row['云仓数据'] = {
|
||||||
'产品分级': row.get('产品分级', ''),
|
'产品分级': row.get('产品分级', ''),
|
||||||
@ -986,26 +911,19 @@ def product_planning_detail():
|
|||||||
'14天平均销量': cloud_14,
|
'14天平均销量': cloud_14,
|
||||||
'库存可支撑天数': round(float(cloud_inventory or 0) / cloud_fcast, 1) if cloud_fcast else 999,
|
'库存可支撑天数': round(float(cloud_inventory or 0) / cloud_fcast, 1) if cloud_fcast else 999,
|
||||||
'加工厂库存支撑天数': round(inv15_val / cloud_fcast, 1) if cloud_fcast else 999,
|
'加工厂库存支撑天数': round(inv15_val / cloud_fcast, 1) if cloud_fcast else 999,
|
||||||
'总支撑天数': round((prod_transit + inv15_val) / cloud_fcast, 1) if cloud_fcast else 999,
|
'总支撑天数': row.get('云仓支撑天数', 999),
|
||||||
'30天平均销量': cloud_30,
|
'30天平均销量': cloud_30,
|
||||||
'极值判断': '-',
|
'极值判断': '-',
|
||||||
}
|
}
|
||||||
|
|
||||||
external_sales = compute_product_sales_extended_cached().get('external', {}).get(pc, {})
|
external_sales = extended_sales.get('external', {}).get(pc, {})
|
||||||
external_inventory = compute_external_inventory_cached().get(pc, 0)
|
external_inventory = row.get('外仓库存', 0)
|
||||||
external_3 = external_sales.get('3天平均销量', 0)
|
external_3 = external_sales.get('3天平均销量', 0)
|
||||||
external_7 = external_sales.get('7天平均销量', 0)
|
external_7 = external_sales.get('7天平均销量', 0)
|
||||||
external_14 = external_sales.get('14天平均销量', 0)
|
external_14 = external_sales.get('14天平均销量', 0)
|
||||||
external_30 = external_sales.get('30天平均销量', 0)
|
external_30 = external_sales.get('30天平均销量', 0)
|
||||||
external_trend = _determine_trend(external_3, external_7, external_14)
|
external_trend = _determine_trend(external_3, external_7, external_14)
|
||||||
external_forecast_row = {
|
external_forecast = row.get('外仓日销量预测', 0)
|
||||||
'3天平均销量': external_3,
|
|
||||||
'7天平均销量': external_7,
|
|
||||||
'14天平均销量': external_14,
|
|
||||||
'30天最高销量': external_sales.get('30天最高销量', 0),
|
|
||||||
'销售趋势': external_trend,
|
|
||||||
}
|
|
||||||
external_forecast = compute_daily_forecast(external_forecast_row, sp_map)
|
|
||||||
external_fcast = float(external_forecast) if external_forecast not in ('近期无销售', 0, '0') else 0
|
external_fcast = float(external_forecast) if external_forecast not in ('近期无销售', 0, '0') else 0
|
||||||
row['外仓数据'] = {
|
row['外仓数据'] = {
|
||||||
'产品分级': row.get('产品分级', ''),
|
'产品分级': row.get('产品分级', ''),
|
||||||
@ -1021,7 +939,7 @@ def product_planning_detail():
|
|||||||
'14天平均销量': external_14,
|
'14天平均销量': external_14,
|
||||||
'库存可支撑天数': round(float(external_inventory or 0) / external_fcast, 1) if external_fcast else 999,
|
'库存可支撑天数': round(float(external_inventory or 0) / external_fcast, 1) if external_fcast else 999,
|
||||||
'加工厂库存支撑天数': round(inv15_val / external_fcast, 1) if external_fcast else 999,
|
'加工厂库存支撑天数': round(inv15_val / external_fcast, 1) if external_fcast else 999,
|
||||||
'总支撑天数': round((prod_transit + inv15_val) / external_fcast, 1) if external_fcast else 999,
|
'总支撑天数': row.get('外仓支撑天数', 999),
|
||||||
'30天平均销量': external_30,
|
'30天平均销量': external_30,
|
||||||
'极值判断': '-',
|
'极值判断': '-',
|
||||||
}
|
}
|
||||||
@ -1190,7 +1108,7 @@ def product_create():
|
|||||||
VALUES (%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)
|
||||||
""", (
|
""", (
|
||||||
product_code, product_name,
|
product_code, product_name,
|
||||||
data.get('brand', ''), data.get('product_type', '成品'),
|
(data.get('brand') or '').strip(), (data.get('product_type') or '成品').strip(),
|
||||||
int(data.get('lead_time', 0)), min(int(data.get('logistics_time', 0)), 999),
|
int(data.get('lead_time', 0)), min(int(data.get('logistics_time', 0)), 999),
|
||||||
data.get('supplier_code', ''), data.get('supplier_name', ''),
|
data.get('supplier_code', ''), data.get('supplier_name', ''),
|
||||||
data.get('procurement_follower', ''), data.get('procurement_planner', ''),
|
data.get('procurement_follower', ''), data.get('procurement_planner', ''),
|
||||||
@ -2180,7 +2098,9 @@ def products_recycle_bin():
|
|||||||
if supplier_filter:
|
if supplier_filter:
|
||||||
where.append("(p.supplier_name_match LIKE %s OR s.supplier_name LIKE %s)")
|
where.append("(p.supplier_name_match LIKE %s OR s.supplier_name LIKE %s)")
|
||||||
params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"])
|
params.extend([f"%{supplier_filter}%", f"%{supplier_filter}%"])
|
||||||
if brand_filter:
|
if brand_filter == '__EMPTY__':
|
||||||
|
where.append("(p.brand IS NULL OR TRIM(p.brand)='')")
|
||||||
|
elif brand_filter:
|
||||||
where.append("TRIM(p.brand)=%s")
|
where.append("TRIM(p.brand)=%s")
|
||||||
params.append(brand_filter)
|
params.append(brand_filter)
|
||||||
if product_type:
|
if product_type:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import re
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
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, execute, add_operation_log
|
from services.db import query, execute, add_operation_log, get_db
|
||||||
from services.field_perms import filter_rows as filter_field_rows
|
from services.field_perms import filter_rows as filter_field_rows
|
||||||
from services.sales_calc import (compute_product_sales_cached,
|
from services.sales_calc import (compute_product_sales_cached,
|
||||||
compute_product_sales_extended_cached)
|
compute_product_sales_extended_cached)
|
||||||
@ -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",
|
||||||
@ -3870,7 +3879,8 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
|
|||||||
其次按传入 ordered_at,最后回退到该商品最近一次下单。
|
其次按传入 ordered_at,最后回退到该商品最近一次下单。
|
||||||
"""
|
"""
|
||||||
if backup_id:
|
if backup_id:
|
||||||
rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,))
|
rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND product_code=%s AND status='ordered'",
|
||||||
|
(backup_id, product_code))
|
||||||
elif ordered_at:
|
elif ordered_at:
|
||||||
return str(ordered_at)[:19]
|
return str(ordered_at)[:19]
|
||||||
else:
|
else:
|
||||||
@ -3884,38 +3894,97 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
|
|||||||
return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19]
|
return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19]
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_linked_order_ids(read, table, product_code, ordered_at):
|
||||||
|
"""Prefer the exact order time; date-only legacy rows need a unique source."""
|
||||||
|
code_field, time_field = {
|
||||||
|
'domestic_orders': ('货品编号', '计划下单时间'),
|
||||||
|
'overseas_orders': ('产品编码', '计划下单时间'),
|
||||||
|
'packaging_orders': ('来源产品编码', '下单时间'),
|
||||||
|
}[table]
|
||||||
|
rows = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND `{time_field}`=%s',
|
||||||
|
(product_code, ordered_at))
|
||||||
|
if rows:
|
||||||
|
return [row['id'] for row in rows]
|
||||||
|
|
||||||
|
day = ordered_at[:10]
|
||||||
|
legacy = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND TRIM(`{time_field}`)=%s',
|
||||||
|
(product_code, day))
|
||||||
|
if not legacy:
|
||||||
|
return []
|
||||||
|
sources = read(
|
||||||
|
"SELECT id FROM pending_orders WHERE product_code=%s "
|
||||||
|
"AND status IN ('ordered','rolled_back') "
|
||||||
|
"AND ordered_at >= %s AND ordered_at < DATE_ADD(%s, INTERVAL 1 DAY)",
|
||||||
|
(product_code, day, day),
|
||||||
|
)
|
||||||
|
# 成品订单须唯一;同一备份可以生成多条包材订单。
|
||||||
|
if len(sources) != 1 or (table != 'packaging_orders' and len(legacy) != 1):
|
||||||
|
raise ValueError('同一商品当天有多笔记录,日期不足以确定对应订单,请核对计划下单时间')
|
||||||
|
return [row['id'] for row in legacy]
|
||||||
|
|
||||||
|
|
||||||
@purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST'])
|
@purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def backup_update_prod_no():
|
def backup_update_prod_no():
|
||||||
denied = _require_purchase_operation('purchase_ready', 'edit')
|
denied = _require_purchase_operation('purchase_ready', 'edit')
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
"""Update only order rows created by this exact backup order time."""
|
"""Save the backup and uniquely linked order numbers in one transaction."""
|
||||||
data = request.json
|
data = request.get_json(silent=True) or {}
|
||||||
product_code = data.get('product_code', '')
|
product_code = data.get('product_code', '')
|
||||||
prod_no = data.get('production_order_no', '')
|
prod_no = data.get('production_order_no', '')
|
||||||
backup_id = data.get('backup_id')
|
backup_id = data.get('backup_id')
|
||||||
ordered_at = data.get('ordered_at', '')
|
ordered_at = data.get('ordered_at', '')
|
||||||
if not product_code:
|
if not product_code:
|
||||||
return jsonify({"code": 400, "msg": "参数错误"})
|
return jsonify({"code": 400, "msg": "参数错误"})
|
||||||
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at)
|
conn = get_db()
|
||||||
if not ordered_at:
|
counts = {}
|
||||||
return jsonify({"code": 404, "msg": "未找到对应下单时间"})
|
try:
|
||||||
if backup_id:
|
conn.begin()
|
||||||
query("""UPDATE pending_orders SET production_order_no=%s WHERE id=%s AND status='ordered'""",
|
with conn.cursor() as cur:
|
||||||
(prod_no, backup_id))
|
def read(sql, params):
|
||||||
else:
|
cur.execute(sql, params)
|
||||||
query("""UPDATE pending_orders SET production_order_no=%s
|
return cur.fetchall()
|
||||||
WHERE product_code=%s AND status='ordered' AND ordered_at=%s""",
|
|
||||||
(prod_no, product_code, ordered_at))
|
if backup_id:
|
||||||
query("""UPDATE domestic_orders SET `旺店通生产单号`=%s
|
backups = read("SELECT id, ordered_at FROM pending_orders WHERE id=%s "
|
||||||
WHERE `货品编号`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
|
"AND product_code=%s AND status='ordered' FOR UPDATE", (backup_id, product_code))
|
||||||
query("""UPDATE overseas_orders SET `旺店通生产单号`=%s
|
elif ordered_at:
|
||||||
WHERE `产品编码`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
|
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
|
||||||
query("""UPDATE packaging_orders SET `生产单号`=%s
|
"AND status='ordered' AND ordered_at=%s FOR UPDATE", (product_code, ordered_at))
|
||||||
WHERE `来源产品编码`=%s AND `下单时间`=%s""", (prod_no, product_code, ordered_at))
|
else:
|
||||||
|
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
|
||||||
|
"AND status='ordered' ORDER BY ordered_at DESC,id DESC LIMIT 1 FOR UPDATE", (product_code,))
|
||||||
|
if len(backups) != 1 or not backups[0].get('ordered_at'):
|
||||||
|
conn.rollback()
|
||||||
|
return jsonify({"code": 404, "msg": "未找到唯一对应的下单备份"})
|
||||||
|
backup = backups[0]
|
||||||
|
time = backup['ordered_at']
|
||||||
|
ordered_at = time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(time, 'strftime') else str(time)[:19]
|
||||||
|
targets = {table: _backup_linked_order_ids(read, table, product_code, ordered_at)
|
||||||
|
for table in ('domestic_orders', 'overseas_orders', 'packaging_orders')}
|
||||||
|
cur.execute('UPDATE pending_orders SET production_order_no=%s WHERE id=%s', (prod_no, backup['id']))
|
||||||
|
for table, ids in targets.items():
|
||||||
|
counts[table] = len(ids)
|
||||||
|
if ids:
|
||||||
|
field = '生产单号' if table == 'packaging_orders' else '旺店通生产单号'
|
||||||
|
placeholders = ','.join(['%s'] * len(ids))
|
||||||
|
cur.execute(f'UPDATE {table} SET `{field}`=%s WHERE id IN ({placeholders})',
|
||||||
|
(prod_no, *ids))
|
||||||
|
conn.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
conn.rollback()
|
||||||
|
return jsonify({"code": 409, "msg": str(exc)})
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}')
|
add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}')
|
||||||
return jsonify({"code": 0, "msg": "保存成功"})
|
msg = '生产单号已保存,已同步对应订单'
|
||||||
|
if not any(counts.values()):
|
||||||
|
msg = '生产单号已保存,但未找到可同步的订单,请核对订单记录'
|
||||||
|
return jsonify({"code": 0, "msg": msg, "data": {"synced": counts}})
|
||||||
|
|
||||||
|
|
||||||
@purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST'])
|
@purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST'])
|
||||||
|
|||||||
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 '否'
|
||||||
314
static/js/app.js
314
static/js/app.js
@ -8322,7 +8322,8 @@ function loadProductBrandFilter(tab) {
|
|||||||
if (!document.body.contains(sel) || !res || res.code !== 0) return;
|
if (!document.body.contains(sel) || !res || res.code !== 0) return;
|
||||||
var brands = (res.data && res.data.brands) || [];
|
var brands = (res.data && res.data.brands) || [];
|
||||||
sel.innerHTML = '<option value="">全部品牌</option>' + brands.map(function(brand) {
|
sel.innerHTML = '<option value="">全部品牌</option>' + brands.map(function(brand) {
|
||||||
return '<option value="' + escHtml(brand) + '">' + escHtml(brand) + '</option>';
|
var label = brand === '__EMPTY__' ? '(品牌为空)' : brand;
|
||||||
|
return '<option value="' + escHtml(brand) + '">' + escHtml(label) + '</option>';
|
||||||
}).join('');
|
}).join('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -13004,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},
|
||||||
@ -13090,6 +13092,7 @@ const PLAN_FILTER_OPTIONS = {
|
|||||||
'产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'],
|
'产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'],
|
||||||
'销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'],
|
'销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'],
|
||||||
'是否新品': ['新品', '返单品'],
|
'是否新品': ['新品', '返单品'],
|
||||||
|
'新旧品': ['是', '否'],
|
||||||
'风险把控': ['正常', '新品', '呆滞品', '需启动清库存'],
|
'风险把控': ['正常', '新品', '呆滞品', '需启动清库存'],
|
||||||
'波动风险': ['X', 'Y', 'Z'],
|
'波动风险': ['X', 'Y', 'Z'],
|
||||||
'极值判断': ['正常', '极大', '极小', '极大/极小'],
|
'极值判断': ['正常', '极大', '极小', '极大/极小'],
|
||||||
@ -13151,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);
|
||||||
};
|
};
|
||||||
@ -13824,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>';
|
||||||
}
|
}
|
||||||
@ -15158,8 +15207,14 @@ window.loadPlanScheme = function(schemeId) {
|
|||||||
});
|
});
|
||||||
PLAN_COLUMNS.forEach(function(c) {
|
PLAN_COLUMNS.forEach(function(c) {
|
||||||
if (pendingVisible[c.field] === undefined) {
|
if (pendingVisible[c.field] === undefined) {
|
||||||
newOrder.push(c.field);
|
if (c.field === '新旧品') {
|
||||||
pendingVisible[c.field] = false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -17533,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>' +
|
||||||
@ -17554,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>' +
|
||||||
@ -18043,6 +18100,34 @@ function updateFormalQty(productCode, val) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 待下单详情:在不改变原输入框宽度的情况下,完整查看成品/包材下单备注。
|
||||||
|
window.openPendingRemarkViewer = function(trigger, title) {
|
||||||
|
var wrapper = trigger && trigger.closest ? trigger.closest('.pending-remark-field') : null;
|
||||||
|
var input = wrapper ? wrapper.querySelector('input, textarea') : null;
|
||||||
|
var remark = input ? input.value : '';
|
||||||
|
var oldViewer = document.getElementById('pendingRemarkViewer');
|
||||||
|
if (oldViewer) oldViewer.remove();
|
||||||
|
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.id = 'pendingRemarkViewer';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100002;display:flex;align-items:center;justify-content:center;padding:20px';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div role="dialog" aria-modal="true" aria-labelledby="pendingRemarkViewerTitle" style="background:#fff;border-radius:10px;width:620px;max-width:92vw;box-shadow:0 10px 36px rgba(0,0,0,.2);overflow:hidden">' +
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid #eee">' +
|
||||||
|
'<h3 id="pendingRemarkViewerTitle" style="margin:0;font-size:16px">' + escHtml(title || '下单备注') + '</h3>' +
|
||||||
|
'<button type="button" aria-label="关闭" style="border:0;background:none;color:#888;font-size:22px;line-height:1;cursor:pointer;padding:2px 5px" onclick="document.getElementById(\'pendingRemarkViewer\').remove()">×</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="padding:18px">' +
|
||||||
|
'<textarea readonly aria-label="完整备注内容" style="width:100%;min-height:240px;max-height:55vh;box-sizing:border-box;padding:12px;border:1px solid #d9d9d9;border-radius:6px;background:#fafafa;color:#333;font-size:14px;line-height:1.7;resize:vertical;white-space:pre-wrap" placeholder="暂无备注内容"></textarea>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="padding:0 18px 16px;text-align:right"><button type="button" class="btn" onclick="document.getElementById(\'pendingRemarkViewer\').remove()">关闭</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
overlay.querySelector('textarea').value = remark;
|
||||||
|
overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('textarea').focus();
|
||||||
|
};
|
||||||
|
|
||||||
function pendingDetail(productCode) {
|
function pendingDetail(productCode) {
|
||||||
var item = null;
|
var item = null;
|
||||||
_pendingItems.forEach(function(i) {
|
_pendingItems.forEach(function(i) {
|
||||||
@ -18117,7 +18202,13 @@ function pendingDetail(productCode) {
|
|||||||
var valHtml = escHtml(String(f.val));
|
var valHtml = escHtml(String(f.val));
|
||||||
if (f.editable) {
|
if (f.editable) {
|
||||||
var _extraCls = (f.label === '实际下单数量') ? ' pend-product-actual-qty' : '';
|
var _extraCls = (f.label === '实际下单数量') ? ' pend-product-actual-qty' : '';
|
||||||
valHtml = '<input type="text" class="form-input' + _extraCls + '" style="width:100%;box-sizing:border-box;padding:3px 6px;font-size:13px" value="' + valHtml + '" onchange="updatePendingProdField(\'' + item.product_code + '\',\'' + f.label + '\',this.value)">';
|
var _isRemark = f.label === '下单备注';
|
||||||
|
var _inputStyle = 'width:100%;box-sizing:border-box;padding:3px ' + (_isRemark ? '30px' : '6px') + ' 3px 6px;font-size:13px';
|
||||||
|
valHtml = '<input type="text" class="form-input' + _extraCls + '" style="' + _inputStyle + '" value="' + valHtml + '" onchange="updatePendingProdField(\'' + item.product_code + '\',\'' + f.label + '\',this.value)">';
|
||||||
|
if (_isRemark) {
|
||||||
|
valHtml = '<span class="pending-remark-field" style="display:block;position:relative;width:100%">' + valHtml +
|
||||||
|
'<button type="button" aria-label="查看完整成品下单备注" title="查看全部" onclick="openPendingRemarkViewer(this,\'成品下单备注\')" style="position:absolute;right:4px;bottom:3px;width:21px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:14px;cursor:pointer">⛶</button></span>';
|
||||||
|
}
|
||||||
} else if (f.readonly) {
|
} else if (f.readonly) {
|
||||||
valHtml = '<span style="font-weight:600;color:#8c8c8c">' + valHtml + '</span>';
|
valHtml = '<span style="font-weight:600;color:#8c8c8c">' + valHtml + '</span>';
|
||||||
} else {
|
} else {
|
||||||
@ -18138,7 +18229,8 @@ function pendingDetail(productCode) {
|
|||||||
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无包材物料清单</div>';
|
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无包材物料清单</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var formalQty = item.formal_order_qty || 0;
|
var productSuggestedQty = Number(item.suggested_qty) || 0;
|
||||||
|
var productInTransit = (Number(p['生产在途']) || 0) + (Number(p['采购在途']) || 0);
|
||||||
// 读取已保存的包材实际下单数量
|
// 读取已保存的包材实际下单数量
|
||||||
var savedBomActuals = {};
|
var savedBomActuals = {};
|
||||||
var savedBomRemarks = {};
|
var savedBomRemarks = {};
|
||||||
@ -18171,8 +18263,13 @@ function pendingDetail(productCode) {
|
|||||||
'</tr></thead><tbody>';
|
'</tr></thead><tbody>';
|
||||||
bom.forEach(function(b) {
|
bom.forEach(function(b) {
|
||||||
var matCode = escHtml(b.material_code || '');
|
var matCode = escHtml(b.material_code || '');
|
||||||
var qty = b.bom_qty || 0;
|
var qty = Number(b.bom_qty) || 0;
|
||||||
var orderQty = Math.ceil(formalQty * qty);
|
var orderQty = Math.max(0, Math.ceil(
|
||||||
|
(productSuggestedQty - productInTransit) * qty
|
||||||
|
- (Number(b.available_qty) || 0)
|
||||||
|
- (Number(b.production_in_transit) || 0)
|
||||||
|
- (Number(b.purchase_in_transit) || 0)
|
||||||
|
));
|
||||||
// 使用已保存的实际下单数量(包括0),没有保存则用建议值
|
// 使用已保存的实际下单数量(包括0),没有保存则用建议值
|
||||||
var actualQty = b.material_code in savedBomActuals ? (savedBomActuals[b.material_code] || 0) : orderQty;
|
var actualQty = b.material_code in savedBomActuals ? (savedBomActuals[b.material_code] || 0) : orderQty;
|
||||||
tableHtml += '<tr>' +
|
tableHtml += '<tr>' +
|
||||||
@ -18183,7 +18280,7 @@ function pendingDetail(productCode) {
|
|||||||
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-col="actual_qty"></td>' +
|
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-col="actual_qty"></td>' +
|
||||||
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px" value="' + (b.pre_pack_qty || 0) + '" data-material="' + matCode + '" data-col="pre_pack"></td>' +
|
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px" value="' + (b.pre_pack_qty || 0) + '" data-material="' + matCode + '" data-col="pre_pack"></td>' +
|
||||||
'<td style="' + tdBase + '"><span class="bom-post-pack-qty" style="display:inline-block;width:70px;text-align:center;font-size:12px;padding:3px 4px;color:#666;background:#f5f5f5" data-material="' + matCode + '">' + (b.post_pack_qty || 0) + '</span></td>' +
|
'<td style="' + tdBase + '"><span class="bom-post-pack-qty" style="display:inline-block;width:70px;text-align:center;font-size:12px;padding:3px 4px;color:#666;background:#f5f5f5" data-material="' + matCode + '">' + (b.post_pack_qty || 0) + '</span></td>' +
|
||||||
'<td style="' + tdBase + '"><input type="text" class="form-input pend-detail-editable" style="width:100%;box-sizing:border-box;text-align:left;font-size:12px;padding:3px 4px" value="' + escHtml(savedBomRemarks[b.material_code] || '') + '" data-material="' + matCode + '" data-col="remark"></td>' +
|
'<td style="' + tdBase + '"><span class="pending-remark-field" style="display:block;position:relative;min-width:150px"><input type="text" class="form-input pend-detail-editable" style="width:100%;box-sizing:border-box;text-align:left;font-size:12px;padding:3px 30px 3px 4px" value="' + escHtml(savedBomRemarks[b.material_code] || '') + '" data-material="' + matCode + '" data-col="remark"><button type="button" aria-label="查看完整包材下单备注" title="查看全部" onclick="openPendingRemarkViewer(this,\'包材下单备注\')" style="position:absolute;right:4px;bottom:3px;width:21px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:14px;cursor:pointer">⛶</button></span></td>' +
|
||||||
'<td style="' + tdBase + '"><select class="form-input bom-manuscript pend-detail-editable" style="width:50px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600" data-material="' + matCode + '" data-col="manuscript" onchange="this.style.color=this.value===\'是\' ? \'#f5222d\' : \'#333\'"><option value="">否</option><option value="是">是</option></select></td>' +
|
'<td style="' + tdBase + '"><select class="form-input bom-manuscript pend-detail-editable" style="width:50px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600" data-material="' + matCode + '" data-col="manuscript" onchange="this.style.color=this.value===\'是\' ? \'#f5222d\' : \'#333\'"><option value="">否</option><option value="是">是</option></select></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
});
|
});
|
||||||
@ -19392,25 +19489,13 @@ window.saveBackupProdNo = function(bid, code) {
|
|||||||
inp.blur();
|
inp.blur();
|
||||||
// 更新缓存数据
|
// 更新缓存数据
|
||||||
_bkItems().forEach(function(it) {
|
_bkItems().forEach(function(it) {
|
||||||
if (it.product_code === code) it.production_order_no = val;
|
if (String(it.id) === String(bid)) it.production_order_no = val;
|
||||||
});
|
});
|
||||||
|
_renderFilteredBackup();
|
||||||
_refreshLinkedOrderLists();
|
_refreshLinkedOrderLists();
|
||||||
// 同步更新表格中的生产单号列
|
showCenteredAlert('保存成功', '✅ ' + (res.msg || '生产单号已保存'));
|
||||||
var tbody = _bkEl('Tbody');
|
|
||||||
if (tbody) {
|
|
||||||
tbody.querySelectorAll('tr').forEach(function(tr) {
|
|
||||||
var codeEl = tr.querySelector('code');
|
|
||||||
if (codeEl && codeEl.textContent.trim() === code) {
|
|
||||||
var tds = tr.querySelectorAll('td');
|
|
||||||
// 生产单号是第7列(序号0,产品类型1,货品编号2,货品名称3,下单时间4,下单人5,生产单号6)
|
|
||||||
if (tds.length > 6) {
|
|
||||||
tds[6].textContent = val || '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
showCenteredAlert('保存成功', '✅ 生产单号已保存');
|
|
||||||
} else {
|
} else {
|
||||||
|
showCenteredAlert('保存失败', res.msg || '生产单号保存失败');
|
||||||
inp.style.borderColor = '#f5222d';
|
inp.style.borderColor = '#f5222d';
|
||||||
setTimeout(function(){ inp.style.borderColor = ''; }, 1500);
|
setTimeout(function(){ inp.style.borderColor = ''; }, 1500);
|
||||||
}
|
}
|
||||||
@ -19519,12 +19604,12 @@ window.saveBackupAll = function(bid, code, saveBtn) {
|
|||||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; }
|
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; }
|
||||||
var hasError = results.some(function(r) { return r.code !== 0; });
|
var hasError = results.some(function(r) { return r.code !== 0; });
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
showCenteredAlert('保存失败', '❌ 部分数据保存失败,请重试');
|
showCenteredAlert('保存失败', '❌ ' + results.filter(function(r) { return r.code !== 0; }).map(function(r) { return r.msg || '部分数据保存失败'; }).join(';'));
|
||||||
} else {
|
} else {
|
||||||
// ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ----
|
// ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ----
|
||||||
var newProdNo = prodNoInp ? prodNoInp.value.trim() : '';
|
var newProdNo = prodNoInp ? prodNoInp.value.trim() : '';
|
||||||
_bkItems().forEach(function(it) {
|
_bkItems().forEach(function(it) {
|
||||||
if (it.product_code === code) {
|
if (String(it.id) === String(bid)) {
|
||||||
// 更新生产单号
|
// 更新生产单号
|
||||||
it.production_order_no = newProdNo;
|
it.production_order_no = newProdNo;
|
||||||
// 更新包材采购单号(bom_items JSON内)
|
// 更新包材采购单号(bom_items JSON内)
|
||||||
@ -19545,27 +19630,15 @@ window.saveBackupAll = function(bid, code, saveBtn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 同步更新表格中的生产单号列
|
_renderFilteredBackup();
|
||||||
var tbody = _bkEl('Tbody');
|
|
||||||
if (tbody) {
|
|
||||||
tbody.querySelectorAll('tr').forEach(function(tr) {
|
|
||||||
var codeEl = tr.querySelector('code');
|
|
||||||
if (codeEl && codeEl.textContent.trim() === code) {
|
|
||||||
var tds = tr.querySelectorAll('td');
|
|
||||||
if (tds.length > 6) {
|
|
||||||
tds[6].textContent = newProdNo || '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 后台刷新列表数据(用户下次查看时拉取最新)
|
// 后台刷新列表数据(用户下次查看时拉取最新)
|
||||||
if (typeof loadBackupList === 'function') {
|
if (typeof loadBackupList === 'function') {
|
||||||
loadBackupList();
|
loadBackupList(false);
|
||||||
}
|
}
|
||||||
_refreshLinkedOrderLists();
|
_refreshLinkedOrderLists();
|
||||||
// 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据
|
// 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据
|
||||||
var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay');
|
var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay');
|
||||||
showCenteredAlert('保存成功', '✅ 生产单号、采购单号已保存');
|
showCenteredAlert('保存成功', '✅ ' + (prodNoInp && results[0] ? results[0].msg : '生产单号、采购单号已保存'));
|
||||||
if (detailOverlay) detailOverlay.remove();
|
if (detailOverlay) detailOverlay.remove();
|
||||||
}
|
}
|
||||||
}).catch(function() {
|
}).catch(function() {
|
||||||
@ -19714,6 +19787,10 @@ function loadOrderFollowers() {
|
|||||||
_orderFollowersPromise = fetch('/api/users/followers').then(function(r){return r.json()}).then(function(res){
|
_orderFollowersPromise = fetch('/api/users/followers').then(function(r){return r.json()}).then(function(res){
|
||||||
if (res.code === 0) domesticFollowers = res.data || [];
|
if (res.code === 0) domesticFollowers = res.data || [];
|
||||||
return domesticFollowers;
|
return domesticFollowers;
|
||||||
|
}).catch(function() {
|
||||||
|
// 人员接口异常不能阻塞订单列表;下单人下拉框随后使用空列表降级展示。
|
||||||
|
domesticFollowers = [];
|
||||||
|
return domesticFollowers;
|
||||||
});
|
});
|
||||||
return _orderFollowersPromise;
|
return _orderFollowersPromise;
|
||||||
}
|
}
|
||||||
@ -19751,6 +19828,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]);
|
||||||
@ -19764,6 +19844,7 @@ window.showSkuInfoModal = function(el, title) {
|
|||||||
if (d.min_order_qty) fields.push(['最低起订量', d.min_order_qty]);
|
if (d.min_order_qty) fields.push(['最低起订量', d.min_order_qty]);
|
||||||
if (d.created_at) fields.push(['创建时间', d.created_at]);
|
if (d.created_at) fields.push(['创建时间', d.created_at]);
|
||||||
var bomCount = (res.data.bom_materials || []).length;
|
var bomCount = (res.data.bom_materials || []).length;
|
||||||
|
var transitOrders = res.data.orders_in_transit || [];
|
||||||
if (bomCount > 0) fields.push(['关联包材', bomCount + ' 种']);
|
if (bomCount > 0) fields.push(['关联包材', bomCount + ' 种']);
|
||||||
|
|
||||||
// 调试:记录API返回
|
// 调试:记录API返回
|
||||||
@ -19772,51 +19853,62 @@ window.showSkuInfoModal = function(el, title) {
|
|||||||
console.log('[SKU] ' + code + ' 在途订单: ' + _debugTransit.length + ' 条');
|
console.log('[SKU] ' + code + ' 在途订单: ' + _debugTransit.length + ' 条');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建弹窗HTML
|
// 构建订单 SKU 信息弹窗:标题、摘要卡片、字段卡片和在途表格分层展示。
|
||||||
var html = '<div class="modal" style="width:650px;max-height:85vh;display:flex;flex-direction:column;border-radius:12px;overflow:hidden">';
|
var html = '<div class="modal sku-info-modal" style="width:900px;max-width:96vw;max-height:88vh;display:flex;flex-direction:column;border-radius:14px;overflow:hidden">';
|
||||||
html += '<div class="modal-header" style="padding:14px 20px;border-bottom:1px solid #eee;display:flex;align-items:center;justify-content:space-between;background:#fafafa">';
|
html += '<div style="padding:18px 24px;border-bottom:1px solid #e8edf3;background:linear-gradient(135deg,#f7fbff,#fff);display:flex;align-items:center;justify-content:space-between;gap:16px">';
|
||||||
html += '<h3 style="margin:0;font-size:16px">📋 ' + escHtml(code) + '</h3>';
|
html += '<div style="min-width:0"><div style="font-size:12px;color:#8c9bab;margin-bottom:5px">' + escHtml(title || '订单 SKU 详情') + '</div><h3 style="margin:0;font-size:19px;color:#1f2d3d;letter-spacing:.2px;word-break:break-all">' + escHtml(code) + '</h3></div>';
|
||||||
html += '<span class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()" style="font-size:22px;cursor:pointer;color:#999">×</span>';
|
html += '<span style="display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:20px;background:#e6f4ff;color:#1677ff;font-size:12px;white-space:nowrap">● 商品信息</span>';
|
||||||
html += '</div><div class="modal-body" style="padding:16px 20px;overflow:auto;flex:1">';
|
html += '<button class="modal-close" aria-label="关闭" onclick="this.closest(\'.modal-overlay\').remove()" style="font-size:24px;cursor:pointer;color:#8c9bab;border:0;background:none;line-height:1;padding:0 2px">×</button>';
|
||||||
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
|
html += '</div><div class="modal-body" style="padding:20px 24px;overflow:auto;flex:1;background:#f5f7fa">';
|
||||||
fields.forEach(function(f, idx) {
|
html += '<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:18px">';
|
||||||
var k = f[0], v = f[1] !== undefined && f[1] !== null ? String(f[1]) : '-';
|
html += '<div style="padding:12px 14px;background:#fff;border:1px solid #e8edf3;border-radius:9px"><div style="font-size:12px;color:#8c9bab">关联包材</div><strong style="display:block;margin-top:5px;font-size:18px;color:#1677ff">' + bomCount + ' <small style="font-size:12px;font-weight:400;color:#8c9bab">种</small></strong></div>';
|
||||||
if (idx % 2 === 0) html += '<tr>';
|
html += '<div style="padding:12px 14px;background:#fff;border:1px solid #e8edf3;border-radius:9px"><div style="font-size:12px;color:#8c9bab">订单在途</div><strong style="display:block;margin-top:5px;font-size:18px;color:#13a673">' + transitOrders.length + ' <small style="font-size:12px;font-weight:400;color:#8c9bab">笔</small></strong></div>';
|
||||||
html += '<td style="padding:7px 10px;border:1px solid #eee;font-size:12px;color:#888;background:#fafafa;white-space:nowrap;width:150px">' + escHtml(k) + '</td>';
|
html += '<div style="padding:12px 14px;background:#fff;border:1px solid #e8edf3;border-radius:9px"><div style="font-size:12px;color:#8c9bab">产品类型</div><strong style="display:block;margin-top:5px;font-size:15px;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + escHtml(d.product_type || '-') + '</strong></div>';
|
||||||
html += '<td style="padding:7px 10px;border:1px solid #eee;color:#333;word-break:break-all">' + escHtml(v) + '</td>';
|
html += '</div>';
|
||||||
if (idx % 2 === 1) html += '</tr>';
|
html += '<section style="background:#fff;border:1px solid #b8c8db;border-radius:10px;overflow:hidden;margin-bottom:18px"><div style="padding:10px 14px;background:#dcecff;border-bottom:1px solid #9fb6cf;font-size:14px;font-weight:700;color:#234f7d">📋 基础信息</div><div style="overflow-x:auto"><table class="sku-info-basic-table" style="width:100%;min-width:650px;border-collapse:collapse;font-size:13px;table-layout:fixed">';
|
||||||
});
|
html += '<thead><tr style="background:#eef6ff;color:#315f8d"><th style="width:15%;padding:9px 10px;border:1px solid #b8c8db;text-align:center">字段</th><th style="width:35%;padding:9px 10px;border:1px solid #b8c8db;text-align:left">内容</th><th style="width:15%;padding:9px 10px;border:1px solid #b8c8db;text-align:center">字段</th><th style="width:35%;padding:9px 10px;border:1px solid #b8c8db;text-align:left">内容</th></tr></thead><tbody>';
|
||||||
if (fields.length % 2 === 1) html += '<td style="border:1px solid #eee"></td><td style="border:1px solid #eee"></td></tr>';
|
for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex += 2) {
|
||||||
html += '</table>';
|
var leftField = fields[fieldIndex];
|
||||||
|
var rightField = fields[fieldIndex + 1];
|
||||||
|
var leftValue = leftField[1] !== undefined && leftField[1] !== null && leftField[1] !== '' ? String(leftField[1]) : '-';
|
||||||
|
var rowBg = (fieldIndex / 2) % 2 === 0 ? '#ffffff' : '#f8fbff';
|
||||||
|
html += '<tr style="background:' + rowBg + '"><th style="padding:9px 10px;border:1px solid #b8c8db;background:#edf5fd;color:#526b85;text-align:center;font-weight:600">' + escHtml(leftField[0]) + '</th><td style="padding:9px 11px;border:1px solid #b8c8db;color:#263445;font-weight:500;word-break:break-all">' + escHtml(leftValue) + '</td>';
|
||||||
|
if (rightField) {
|
||||||
|
var rightValue = rightField[1] !== undefined && rightField[1] !== null && rightField[1] !== '' ? String(rightField[1]) : '-';
|
||||||
|
html += '<th style="padding:9px 10px;border:1px solid #b8c8db;background:#edf5fd;color:#526b85;text-align:center;font-weight:600">' + escHtml(rightField[0]) + '</th><td style="padding:9px 11px;border:1px solid #b8c8db;color:#263445;font-weight:500;word-break:break-all">' + escHtml(rightValue) + '</td>';
|
||||||
|
} else {
|
||||||
|
html += '<th style="padding:9px 10px;border:1px solid #b8c8db;background:#edf5fd"></th><td style="padding:9px 11px;border:1px solid #b8c8db"></td>';
|
||||||
|
}
|
||||||
|
html += '</tr>';
|
||||||
|
}
|
||||||
|
html += '</tbody></table></div></section>';
|
||||||
|
|
||||||
// 订单在途列表
|
// 订单在途列表
|
||||||
var transitOrders = res.data.orders_in_transit || [];
|
html += '<section style="background:#fff;border:1px solid #b9cdbf;border-radius:10px;overflow:hidden"><div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#ddf3e4;border-bottom:1px solid #9fc4aa"><div style="font-size:14px;font-weight:700;color:#27633b">📦 订单在途</div><span style="font-size:12px;color:#4d765a">共 ' + transitOrders.length + ' 笔</span></div>';
|
||||||
html += '<div style="margin-top:16px">';
|
html += '<div style="overflow-x:auto"><table class="sku-info-transit-table" style="width:100%;border-collapse:collapse;font-size:12px;min-width:680px">';
|
||||||
html += '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:8px">📦 订单在途 (' + transitOrders.length + ')</div>';
|
html += '<thead><tr style="background:#edf8f0;color:#376548">';
|
||||||
html += '<table style="width:100%;border-collapse:collapse;font-size:12px">';
|
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">下单时间</th>';
|
||||||
html += '<thead><tr style="background:#e6f7ff">';
|
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">编号</th>';
|
||||||
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">下单时间</th>';
|
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:left;white-space:nowrap">名称</th>';
|
||||||
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">编号</th>';
|
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">下单数量</th>';
|
||||||
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">名称</th>';
|
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">采购跟单</th>';
|
||||||
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">下单数量</th>';
|
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">采购计划</th>';
|
||||||
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">采购跟单</th>';
|
|
||||||
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">采购计划</th>';
|
|
||||||
html += '</tr></thead><tbody>';
|
html += '</tr></thead><tbody>';
|
||||||
if (transitOrders.length > 0) {
|
if (transitOrders.length > 0) {
|
||||||
transitOrders.forEach(function(o) {
|
transitOrders.forEach(function(o, transitIndex) {
|
||||||
html += '<tr>';
|
html += '<tr style="background:' + (transitIndex % 2 === 0 ? '#ffffff' : '#f6fbf7') + '">';
|
||||||
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;white-space:nowrap">' + escHtml(o['下单时间'] || '-') + '</td>';
|
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap;color:#526273">' + escHtml(o['下单时间'] || '-') + '</td>';
|
||||||
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center"><code style="font-size:11px">' + escHtml(o['编码'] || '') + '</code></td>';
|
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center"><code style="font-size:11px;color:#1677ff">' + escHtml(o['编码'] || '') + '</code></td>';
|
||||||
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;max-width:180px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(o['名称'] || '') + '">' + escHtml(o['名称'] || '-') + '</td>';
|
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:left;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(o['名称'] || '') + '">' + escHtml(o['名称'] || '-') + '</td>';
|
||||||
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;font-weight:600">' + (parseInt(o['下单数量']) || 0).toLocaleString() + '</td>';
|
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center;font-weight:700;color:#27633b">' + (parseInt(o['下单数量']) || 0).toLocaleString() + '</td>';
|
||||||
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center">' + escHtml(o['采购跟单'] || '-') + '</td>';
|
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center">' + escHtml(o['采购跟单'] || '-') + '</td>';
|
||||||
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center">' + escHtml(o['采购计划'] || '-') + '</td>';
|
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center">' + escHtml(o['采购计划'] || '-') + '</td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
html += '<tr><td style="padding:4px 8px;border:1px solid #eee;text-align:center;color:#ccc">-</td><td style="padding:4px 8px;border:1px solid #eee;text-align:center;color:#ccc">-</td><td style="padding:4px 8px;border:1px solid #eee;text-align:center;color:#ccc">-</td><td style="padding:4px 8px;border:1px solid #eee;text-align:center;color:#ccc">0</td><td style="padding:4px 8px;border:1px solid #eee;text-align:center;color:#ccc">-</td><td style="padding:4px 8px;border:1px solid #eee;text-align:center;color:#ccc">-</td></tr>';
|
html += '<tr><td colspan="6" style="padding:24px 8px;border:1px solid #b9cdbf;text-align:center;color:#8ca397;background:#fbfdfb">暂无在途订单</td></tr>';
|
||||||
}
|
}
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div></section>';
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '<div class="modal-footer" style="padding:10px 20px;border-top:1px solid #eee;text-align:right;background:#fafafa">';
|
html += '<div class="modal-footer" style="padding:10px 20px;border-top:1px solid #eee;text-align:right;background:#fafafa">';
|
||||||
@ -19876,6 +19968,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},
|
||||||
@ -22130,8 +22223,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';
|
||||||
@ -22304,13 +22398,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>';
|
||||||
@ -22330,7 +22424,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 === '是否做入库') {
|
||||||
@ -22921,27 +23015,13 @@ function loadPackagingOrdersPage(container) {
|
|||||||
renderOrderColCheckboxes('packaging');
|
renderOrderColCheckboxes('packaging');
|
||||||
}
|
}
|
||||||
|
|
||||||
packagingLoadFilters().then(function() {
|
// 列表是页面核心内容,必须立即加载,不能被筛选项、人员或分组接口阻塞。
|
||||||
loadDefaultOrderScheme('packaging');
|
// 辅助接口失败时仅降级对应下拉/标签,不再让表格永远停留在“加载中”。
|
||||||
// 恢复上次的筛选状态(切页/刷新后保留搜索、筛选、表头筛选、页码)
|
packagingLoadOrders(1);
|
||||||
var restoredPage = 1;
|
packagingLoadFilters();
|
||||||
if (_restorePackagingFilterState()) {
|
loadOrderFollowers();
|
||||||
restoredPage = packagingOrderPage || 1;
|
loadPersonnelGroupsForLinkedPages();
|
||||||
var _si = document.getElementById('packagingSearchInput');
|
loadDefaultOrderScheme('packaging');
|
||||||
if (_si) _si.value = packagingSearch;
|
|
||||||
var _sf = document.getElementById('packagingSupplierFilter');
|
|
||||||
if (_sf) _sf.value = packagingFilterSupplier;
|
|
||||||
var _ss = document.getElementById('packagingStatusFilter');
|
|
||||||
if (_ss) _ss.value = packagingFilterStatus;
|
|
||||||
var _so = document.getElementById('packagingOrdererFilter');
|
|
||||||
if (_so) _so.value = packagingFilterOrderer;
|
|
||||||
_syncOrderFilterIcons('packaging');
|
|
||||||
}
|
|
||||||
// Load members and personnel groups before rendering the list.
|
|
||||||
Promise.all([loadOrderFollowers(), loadPersonnelGroupsForLinkedPages()]).then(function() {
|
|
||||||
packagingLoadOrders(restoredPage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// 后台预拉取所有列的筛选值
|
// 后台预拉取所有列的筛选值
|
||||||
prefetchOrderColumnValues('packaging');
|
prefetchOrderColumnValues('packaging');
|
||||||
}
|
}
|
||||||
@ -22957,7 +23037,7 @@ function packagingLoadFilters() {
|
|||||||
opt.value = s; opt.textContent = s;
|
opt.value = s; opt.textContent = s;
|
||||||
so.appendChild(opt);
|
so.appendChild(opt);
|
||||||
});
|
});
|
||||||
});
|
}).catch(function() { return null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function packagingDoSearch() {
|
function packagingDoSearch() {
|
||||||
@ -23010,9 +23090,10 @@ function packagingLoadOrders(page) { packagingOrderPage = page;
|
|||||||
})
|
})
|
||||||
.then(function(r){return r.json()})
|
.then(function(r){return r.json()})
|
||||||
.then(function(res){
|
.then(function(res){
|
||||||
if (res.code !== 0) return;
|
if (res.code !== 0) throw new Error(res.msg || '包材订单加载失败');
|
||||||
var data = res.data;
|
var data = res.data;
|
||||||
var tbody = document.getElementById('packagingOrdersTbody');
|
var tbody = document.getElementById('packagingOrdersTbody');
|
||||||
|
if (!tbody) return;
|
||||||
if (document.getElementById('packagingTotalInfo'))
|
if (document.getElementById('packagingTotalInfo'))
|
||||||
document.getElementById('packagingTotalInfo').textContent = '共 ' + data.total + ' 条';
|
document.getElementById('packagingTotalInfo').textContent = '共 ' + data.total + ' 条';
|
||||||
|
|
||||||
@ -23118,6 +23199,13 @@ function packagingLoadOrders(page) { packagingOrderPage = page;
|
|||||||
renderPaginationWithSize(pel, page, data.total || 0, PACKAGING_PAGE_SIZE, 'packagingLoadOrders', 'PACKAGING_PAGE_SIZE');
|
renderPaginationWithSize(pel, page, data.total || 0, PACKAGING_PAGE_SIZE, 'packagingLoadOrders', 'PACKAGING_PAGE_SIZE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}).catch(function(error) {
|
||||||
|
var tbody = document.getElementById('packagingOrdersTbody');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="22" style="text-align:center;padding:50px;color:#8c8c8c">' +
|
||||||
|
'<div style="margin-bottom:10px;color:#f5222d">加载失败:' + escHtml(error.message || '网络异常') + '</div>' +
|
||||||
|
'<button class="btn btn-sm btn-primary" onclick="packagingLoadOrders(' + (page || 1) + ')">重新加载</button></td></tr>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24961,6 +25049,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'},
|
||||||
@ -24969,7 +25058,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},
|
||||||
@ -25389,6 +25477,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];
|
||||||
@ -27030,7 +27123,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', '新旧品', '产品分级',
|
||||||
'昨日销量', '销售趋势',
|
'昨日销量', '销售趋势',
|
||||||
'库存', '加工厂库存', '生产在途', '采购在途',
|
'库存', '加工厂库存', '生产在途', '采购在途',
|
||||||
'日销量预测',
|
'日销量预测',
|
||||||
@ -27045,6 +27138,7 @@ var POPUP_VISIBLE_FIELDS = [
|
|||||||
|
|
||||||
/** 字段显示名称映射 */
|
/** 字段显示名称映射 */
|
||||||
var POPUP_FIELD_LABELS = {
|
var POPUP_FIELD_LABELS = {
|
||||||
|
'新旧品': '新旧品',
|
||||||
'product_type': '产品类型',
|
'product_type': '产品类型',
|
||||||
'产品种类': '产品种类',
|
'产品种类': '产品种类',
|
||||||
'product_code': '货品编号',
|
'product_code': '货品编号',
|
||||||
@ -27252,6 +27346,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', {
|
||||||
@ -27463,6 +27558,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) {
|
||||||
@ -27635,6 +27731,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));
|
||||||
@ -27720,6 +27817,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',
|
||||||
|
|||||||
662
templates/base.html
Normal file
662
templates/base.html
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>蝶雅采购管理系统</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#1677ff">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="蝶雅数据">
|
||||||
|
<link rel="icon" type="image/png" href="/static/logo-small.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/logo-small.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css?v=1785481700">
|
||||||
|
<script src="/static/js/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.nav-hidden { display: none !important; }
|
||||||
|
/* 侧边栏折叠时,子菜单弹出面板 */
|
||||||
|
.sidebar.collapsed .nav-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.sidebar-popup-menu {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 60px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||||
|
z-index: 99999;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.sidebar-popup-menu.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sidebar-popup-menu a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sidebar-popup-menu a:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
/* BOM树形表格 */
|
||||||
|
#bomTable .bom-product-row td { border-bottom: 1px solid var(--border); }
|
||||||
|
#bomTable .bom-material-row td { border-bottom: 1px dashed #e0e0e0; font-size: 13px; }
|
||||||
|
#bomTable .bom-material-row:last-child td { border-bottom: 1px solid var(--border); }
|
||||||
|
#bomTable .bom-product-row:hover { background: #e8f0fe !important; }
|
||||||
|
#bomTable .bom-material-row:hover { background: #f0f4f8 !important; }
|
||||||
|
|
||||||
|
.filter-wrap .filter-clear{position:absolute;right:5px;top:50%;transform:translateY(-50%);cursor:pointer;color:#bfbfbf;font-size:13px;display:none;z-index:2;padding:1px 4px;border-radius:3px;line-height:1;background:var(--bg,#fff)}
|
||||||
|
.filter-wrap.has-value:hover .filter-clear{display:block}
|
||||||
|
.filter-clear:hover{color:#cf1322;background:#eee}
|
||||||
|
|
||||||
|
/* 产品详情明细弹窗 - 美化样式 */
|
||||||
|
.detail-two-col{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px}
|
||||||
|
.detail-col{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.detail-pair{display:flex;border:1px solid #d0d0d0;border-radius:5px;overflow:hidden;min-height:36px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.05)}
|
||||||
|
.detail-lbl{width:115px;min-width:115px;padding:6px 10px;background:#b0b0b0;color:#333;font-size:12px;font-weight:600;white-space:nowrap;display:flex;align-items:center;justify-content:flex-end;letter-spacing:0.3px;border-right:1px solid #999}
|
||||||
|
.detail-val-wrap{flex:1;padding:4px 10px;display:flex;align-items:center}
|
||||||
|
.detail-val{font-size:13px;color:#333;word-break:break-all;line-height:1.5}
|
||||||
|
.detail-input{width:100%;box-sizing:border-box;font-size:13px;border:1px solid #ccc;border-radius:3px;padding:5px 8px;color:#333;background:#fff;min-width:0;transition:border-color .2s,box-shadow .2s}
|
||||||
|
.detail-input:hover{border-color:#999}
|
||||||
|
.detail-input:focus{border-color:#1890ff;outline:none;box-shadow:0 0 0 2px rgba(24,144,255,0.15)}
|
||||||
|
.detail-select{appearance:auto;-webkit-appearance:auto;padding:5px 6px!important;cursor:pointer;background:#fff!important}
|
||||||
|
.detail-textarea:focus{border-color:#1890ff!important;outline:none;box-shadow:0 0 0 2px rgba(24,144,255,0.15)}
|
||||||
|
.orderer-select{background:#fff;cursor:pointer;min-height:28px}
|
||||||
|
.orderer-select option{padding:3px 6px}
|
||||||
|
.ac{width:auto;min-width:0}
|
||||||
|
.col-resize-handle{cursor:col-resize;transition:background .15s}
|
||||||
|
.col-resize-handle:hover{background:rgba(102,126,234,0.3)!important}
|
||||||
|
|
||||||
|
.filter-wrap.has-value::after{content:"▾";position:absolute;right:5px;top:50%;transform:translateY(-50%);font-size:11px;color:#999;pointer-events:none;transition:opacity .15s}
|
||||||
|
.filter-wrap.has-value:hover::after{opacity:0}
|
||||||
|
|
||||||
|
.pd-fields-2col{display:grid;grid-template-columns:1fr 1fr;gap:0}
|
||||||
|
.pd-fields-3col{display:grid;grid-template-columns:1fr 1fr 1fr;gap:0}
|
||||||
|
.pd-fields-4col{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0}
|
||||||
|
.pd-row-2col{border-bottom:1px solid #f0f0f0}
|
||||||
|
.pd-row-3col{border-bottom:1px solid #f0f0f0}
|
||||||
|
.pd-row-4col{border-bottom:1px solid #f0f0f0}
|
||||||
|
|
||||||
|
.filter-wrap{position:relative;display:inline-block;vertical-align:middle}
|
||||||
|
.filter-wrap select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding-right:22px!important}
|
||||||
|
.filter-wrap::after{content:"\25be";position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:11px;color:#999;pointer-events:none;transition:opacity .15s}
|
||||||
|
.filter-wrap.has-value:hover::after{opacity:0}
|
||||||
|
.filter-wrap .filter-clear{position:absolute;right:4px;top:50%;transform:translateY(-50%);cursor:pointer;color:#999;font-size:14px;display:none;z-index:3;line-height:1;padding:0 3px}
|
||||||
|
.filter-wrap.has-value:hover .filter-clear{display:block}
|
||||||
|
.filter-clear:hover{color:#cf1322}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<img src="/static/logo-small.png" alt="Logo" style="width:32px;height:32px;border-radius:6px;">
|
||||||
|
</div>
|
||||||
|
<span class="sidebar-title">采购管理系统</span>
|
||||||
|
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()" aria-label="切换菜单">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
{# 所有用户可见 #}
|
||||||
|
<a href="/dashboard" class="nav-item {% if page == 'dashboard' %}active{% endif %}" data-page="dashboard" data-roles="all">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">数据概览</span>
|
||||||
|
</a>
|
||||||
|
{# 采销协同(含子菜单): 所有用户可见 #}
|
||||||
|
<div class="nav-group" data-roles="all">
|
||||||
|
<a href="#" class="nav-item nav-group-header" data-group="collaboration" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">采销协同</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-collaboration">
|
||||||
|
<a href="/collaboration/inventory-dashboard" class="nav-sub-item {% if page == 'collaboration_inventory_dashboard' %}active{% endif %}" data-page="collaboration_inventory_dashboard" data-roles="all"><span class="nav-sub-icon">●</span><span>库存数据看板</span></a>
|
||||||
|
<a href="/collaboration/key-sku-forecast" class="nav-sub-item {% if page == 'collaboration_key_sku_forecast' %}active{% endif %}" data-page="collaboration_key_sku_forecast" data-roles="all"><span class="nav-sub-icon">●</span><span>重点SKU预测</span></a>
|
||||||
|
<a href="/collaboration/sku-health-index" class="nav-sub-item {% if page == 'collaboration_sku_health_index' %}active{% endif %}" data-page="collaboration_sku_health_index" data-roles="all"><span class="nav-sub-icon">●</span><span>SKU库存健康指数</span></a>
|
||||||
|
<a href="/collaboration/slow-moving-sku" class="nav-sub-item {% if page == 'collaboration_slow_moving_sku' %}active{% endif %}" data-page="collaboration_slow_moving_sku" data-roles="all"><span class="nav-sub-icon">●</span><span>呆滞SKU清单</span></a>
|
||||||
|
<a href="/collaboration/sales-management" class="nav-sub-item {% if page == 'collaboration_sales_management' %}active{% endif %}" data-page="collaboration_sales_management" data-roles="all"><span class="nav-sub-icon">●</span><span>动销管理</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# 基础数据(含子菜单): 所有非管理员也可查看 #}
|
||||||
|
<div class="nav-group" data-roles="admin,super_admin,procurement_planner,procurement_follower,product_developer,operator,viewer,查看者">
|
||||||
|
<a href="#" class="nav-item nav-group-header" data-group="basic" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">基础数据</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-basic">
|
||||||
|
<a href="/products" class="nav-sub-item {% if page == 'products' %}active{% endif %}" data-page="products">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>产品信息管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/brand-management" class="nav-sub-item {% if page == 'brand_management' %}active{% endif %}" data-page="brand_management">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>品牌信息管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/bom" class="nav-sub-item {% if page == 'bom' %}active{% endif %}" data-page="bom">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>物料清单管理</span>
|
||||||
|
</a>
|
||||||
|
{# 仓库管理: 所有非管理员也可查看 #}
|
||||||
|
<a href="/warehouse" class="nav-sub-item {% if page == 'warehouse' %}active{% endif %}" data-page="warehouse" data-roles="admin,super_admin,viewer,查看者">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>仓库信息管理</span>
|
||||||
|
</a>
|
||||||
|
{# 供应商管理: 所有非管理员也可查看 #}
|
||||||
|
<a href="/suppliers" class="nav-sub-item {% if page == 'suppliers' %}active{% endif %}" data-page="suppliers" data-roles="admin,super_admin,procurement_planner,procurement_follower,viewer,查看者">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>供应商管理</span>
|
||||||
|
</a>
|
||||||
|
{# 人员信息管理: 所有非管理员也可查看 #}
|
||||||
|
<a href="/personnel" class="nav-sub-item {% if page == 'personnel' %}active{% endif %}" data-page="personnel" data-roles="admin,super_admin,procurement_planner,procurement_follower,viewer,查看者">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>人员信息管理</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# 库存与出库(含子菜单): 所有人 #}
|
||||||
|
<div class="nav-group" data-roles="all">
|
||||||
|
<a href="#" class="nav-item nav-group-header" data-group="inventory" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">库存与出库</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-inventory">
|
||||||
|
<a href="/inventory/finished" class="nav-sub-item {% if page == 'inventory_finished' %}active{% endif %}" data-page="inventory_finished">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>成品库存</span>
|
||||||
|
</a>
|
||||||
|
<a href="/inventory/packaging" class="nav-sub-item {% if page == 'inventory_packaging' %}active{% endif %}" data-page="inventory_packaging">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>包材库存</span>
|
||||||
|
</a>
|
||||||
|
<a href="/data/summary" class="nav-sub-item {% if page == 'data_summary' %}active{% endif %}" data-page="data_summary">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>出库数据</span>
|
||||||
|
</a>
|
||||||
|
<a href="/data/detail" class="nav-sub-item {% if page == 'data_detail' %}active{% endif %}" data-page="data_detail">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>出库明细</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# 计划预测: 所有非管理员也可查看 #}
|
||||||
|
<a href="/planning" data-roles="admin,super_admin,procurement_planner,procurement_follower,product_developer,operator,viewer,查看者" class="nav-item {% if page == 'planning' %}active{% endif %}" data-page="planning">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">计划预测</span>
|
||||||
|
</a>
|
||||||
|
{# 现货模式(含子菜单): 所有非管理员也可查看 #}
|
||||||
|
<div class="nav-group" data-roles="admin,super_admin,procurement_planner,procurement_follower,product_developer,operator,viewer,查看者">
|
||||||
|
<a href="#" class="nav-item nav-group-header" data-group="spot" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">现货模式</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-spot">
|
||||||
|
<a href="/spot/dashboard" class="nav-sub-item {% if page == 'spot_dashboard' %}active{% endif %}" data-page="spot_dashboard">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>现货看板</span>
|
||||||
|
</a>
|
||||||
|
<a href="/spot/application" class="nav-sub-item {% if page == 'spot_application' %}active{% endif %}" data-page="spot_application">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>现货申请</span>
|
||||||
|
</a>
|
||||||
|
<a href="/spot/confirmation" class="nav-sub-item {% if page == 'spot_confirmation' %}active{% endif %}" data-page="spot_confirmation">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>现货确认</span>
|
||||||
|
</a>
|
||||||
|
<a href="/spot/sales" class="nav-sub-item {% if page == 'spot_sales' %}active{% endif %}" data-page="spot_sales">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>销量管理</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# 采购订单(含子菜单): 所有非管理员也可查看 #}
|
||||||
|
<div class="nav-group" data-roles="admin,super_admin,procurement_planner,procurement_follower,viewer,查看者">
|
||||||
|
<a href="#" class="nav-item nav-group-header" data-group="purchase" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">订单管理</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-purchase">
|
||||||
|
<a href="/purchase" class="nav-sub-item {% if page == 'purchase' %}active{% endif %}" data-page="purchase">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>待下单管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/orders/domestic" class="nav-sub-item {% if page == 'orders_domestic' %}active{% endif %}" data-page="orders_domestic">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>国内订单管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/orders/overseas" class="nav-sub-item {% if page == 'orders_overseas' %}active{% endif %}" data-page="orders_overseas">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>海外订单管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/orders/packaging" class="nav-sub-item {% if page == 'orders_packaging' %}active{% endif %}" data-page="orders_packaging">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>包材订单管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/orders/finished" class="nav-sub-item {% if page == 'orders_finished' %}active{% endif %}" data-page="orders_finished">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>成品提货管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/purchase/ready" class="nav-sub-item {% if page == 'purchase_ready' %}active{% endif %}" data-page="purchase_ready">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>下单备份管理</span>
|
||||||
|
</a>
|
||||||
|
{# 合同模板: 所有用户可见 #}
|
||||||
|
<a href="/contract-templates" class="nav-sub-item {% if page == 'contract_templates' %}active{% endif %}" data-page="contract_templates">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>合同模板管理</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# 产品下单(含子菜单): 所有用户可见 #}
|
||||||
|
<div class="nav-group" data-roles="all">
|
||||||
|
<a href="#" class="nav-item nav-item-accent nav-group-header" data-group="product-order" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
||||||
|
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">新品下单</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-product-order">
|
||||||
|
<a href="/product-new" class="nav-sub-item {% if page == 'product_new' %}active{% endif %}" data-page="product_new" data-roles="all">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>新品成品管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/product-packaging" class="nav-sub-item {% if page == 'product_packaging' %}active{% endif %}" data-page="product_packaging" data-roles="all">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>新品包材管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/product-category" class="nav-sub-item {% if page == 'product_category' %}active{% endif %}" data-page="product_category" data-roles="all">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>类目管理</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# 知识库: 所有用户可见 #}
|
||||||
|
<a href="/knowledge" class="nav-item {% if page == 'knowledge' %}active{% endif %}" data-page="knowledge" data-roles="all">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M12 6v6l2-1 2 1V6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">知识库</span>
|
||||||
|
</a>
|
||||||
|
{# 测试问题: 所有用户可见 #}
|
||||||
|
<a href="/feedback" class="nav-item {% if page == 'feedback' %}active{% endif %}" data-page="feedback" data-roles="all">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">测试问题</span>
|
||||||
|
</a>
|
||||||
|
{# AI分析: 所有用户可见 #}
|
||||||
|
<a href="/ai-analysis" class="nav-item {% if page == 'ai_analysis' %}active{% endif %}" data-page="ai_analysis" data-roles="all">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">AI分析</span>
|
||||||
|
</a>
|
||||||
|
{# 系统设置(含子菜单): 仅管理员 #}
|
||||||
|
<div class="nav-group" data-roles="admin,super_admin">
|
||||||
|
<a href="#" class="nav-item nav-group-header" data-group="sysconfig" onclick="toggleSubmenu(event)">
|
||||||
|
<span class="nav-icon nav-icon-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="nav-text">系统设置</span>
|
||||||
|
<span class="nav-arrow">▾</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-submenu" id="submenu-sysconfig">
|
||||||
|
<a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>用户管理</span>
|
||||||
|
</a>
|
||||||
|
<a href="/permissions" class="nav-sub-item {% if page == 'permissions' %}active{% endif %}" data-page="permissions">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>权限配置</span>
|
||||||
|
</a>
|
||||||
|
<a href="/config" class="nav-sub-item {% if page == 'config' %}active{% endif %}" data-page="config">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>参数配置</span>
|
||||||
|
</a>
|
||||||
|
<a href="/logs" class="nav-sub-item {% if page == 'logs' %}active{% endif %}" data-page="logs">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>操作日志</span>
|
||||||
|
</a>
|
||||||
|
<a href="/login-records" class="nav-sub-item {% if page == 'login_records' %}active{% endif %}" data-page="login_records">
|
||||||
|
<span class="nav-sub-icon">●</span>
|
||||||
|
<span>访问记录</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
<!-- 底部用户信息 -->
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="sidebar-avatar">
|
||||||
|
<img src="/static/logo-small.png" alt="avatar" style="width:32px;height:32px;border-radius:50%">
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-user-info" style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:rgba(255,255,255,0.85);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ session.get('real_name', session.get('username', '用户')) }}</div>
|
||||||
|
<div style="font-size:11px;color:rgba(255,255,255,0.45)">
|
||||||
|
{% set role = session.get('role', '') %}
|
||||||
|
{% set role_names = {'admin':'管理员','procurement_planner':'采购计划','procurement_follower':'采购跟单','product_developer':'产品开发','operator':'运营专员'} %}
|
||||||
|
{{ role_names.get(role, '') }}
|
||||||
|
{% set level = session.get('user_level', '') %}
|
||||||
|
{% set level_names = {'employee':'','leader':'·组长','director':'·主管'} %}
|
||||||
|
{{ level_names.get(level, '') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div onclick="handleLogout()" title="退出登录" style="cursor:pointer;color:rgba(255,255,255,0.5);padding:4px;border-radius:4px;display:flex;align-items:center" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,0.5)'">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main class="main-content" id="mainContent">
|
||||||
|
<div class="page-loading" id="pageLoading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window._currentRole = '{{ session.get('role', '') }}';
|
||||||
|
window._currentUserLevel = '{{ session.get('user_level', 'employee') }}';
|
||||||
|
window._currentUser = {
|
||||||
|
user_id: {{ session.get('user_id', 0) }},
|
||||||
|
username: '{{ session.get('username', '') }}',
|
||||||
|
role: '{{ session.get('role', '') }}',
|
||||||
|
permissions: {{ _user_permissions_json|safe }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/marked.min.js"></script>
|
||||||
|
<script src="/static/js/dompurify.min.js"></script>
|
||||||
|
<script src="/static/js/app.js?v={{ app_js_version }}"></script>
|
||||||
|
<script src="/static/js/feedback.js?v=1786347000"></script>
|
||||||
|
<script src="/static/js/knowledge-actions.js?v=1"></script>
|
||||||
|
<script>
|
||||||
|
// 根据角色隐藏无权限的菜单
|
||||||
|
(function() {
|
||||||
|
var role = window._currentRole || '';
|
||||||
|
document.querySelectorAll('[data-roles]').forEach(function(el) {
|
||||||
|
var roles = el.getAttribute('data-roles');
|
||||||
|
if (roles === 'all') return;
|
||||||
|
var allowed = roles.split(',').map(function(r) { return r.trim(); });
|
||||||
|
if (role === 'super_admin') return; // 超级管理员全可见
|
||||||
|
if (allowed.indexOf(role) === -1) {
|
||||||
|
el.classList.add('nav-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
fetch('/api/logout', {method: 'POST'})
|
||||||
|
.then(function() { window.location.href = '/login'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
var sidebar = document.getElementById('sidebar');
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
var overlay = document.getElementById('sidebarOverlay');
|
||||||
|
if (sidebar.classList.contains('collapsed')) {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
// 侧边栏缩进后,给图标添加title提示
|
||||||
|
sidebar.querySelectorAll('.nav-item').forEach(function(item) {
|
||||||
|
var textEl = item.querySelector('.nav-text');
|
||||||
|
if (textEl && textEl.textContent.trim()) {
|
||||||
|
item.setAttribute('title', textEl.textContent.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sidebar.querySelectorAll('.nav-sub-item').forEach(function(item) {
|
||||||
|
var textEl = item.querySelector('span:not(.nav-sub-icon)');
|
||||||
|
if (textEl && textEl.textContent.trim()) {
|
||||||
|
item.setAttribute('title', textEl.textContent.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 展开后移除title
|
||||||
|
sidebar.querySelectorAll('.nav-item, .nav-sub-item').forEach(function(item) {
|
||||||
|
item.removeAttribute('title');
|
||||||
|
});
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
overlay.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleSubmenu(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
var header = event.currentTarget;
|
||||||
|
var group = header.dataset.group;
|
||||||
|
var submenu = document.getElementById('submenu-' + group);
|
||||||
|
var arrow = header.querySelector('.nav-arrow');
|
||||||
|
if (!submenu) return;
|
||||||
|
|
||||||
|
var sidebar = document.getElementById('sidebar');
|
||||||
|
if (sidebar && sidebar.classList.contains('collapsed')) {
|
||||||
|
// 折叠状态:直接打开独立的弹出面板
|
||||||
|
var popup = document.querySelector('.sidebar-popup-menu');
|
||||||
|
if (popup && popup.dataset.group === group) {
|
||||||
|
popup.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (popup) popup.remove();
|
||||||
|
|
||||||
|
// 收集子菜单链接
|
||||||
|
var itemsHtml = '';
|
||||||
|
submenu.querySelectorAll('a').forEach(function(a) {
|
||||||
|
var href = a.getAttribute('href') || '#';
|
||||||
|
var text = a.textContent.trim();
|
||||||
|
var isActive = window.location.pathname === href;
|
||||||
|
itemsHtml += '<a href="' + href + '" style="display:block;padding:10px 16px;font-size:14px;text-decoration:none;' + (isActive ? 'color:#1677ff;font-weight:600;background:#e6f7ff' : 'color:#333') + '">' + text + '</a>';
|
||||||
|
});
|
||||||
|
|
||||||
|
popup = document.createElement('div');
|
||||||
|
popup.className = 'sidebar-popup-menu';
|
||||||
|
popup.dataset.group = group;
|
||||||
|
popup.innerHTML = itemsHtml;
|
||||||
|
var rect = header.getBoundingClientRect();
|
||||||
|
popup.style.top = (rect.top - 4) + 'px';
|
||||||
|
document.body.appendChild(popup);
|
||||||
|
|
||||||
|
// 延迟后再添加 open 类确保过渡效果
|
||||||
|
setTimeout(function() { popup.classList.add('open'); }, 10);
|
||||||
|
|
||||||
|
// SPA导航
|
||||||
|
popup.addEventListener('click', function(e) {
|
||||||
|
var link = e.target.closest('a');
|
||||||
|
if (link) {
|
||||||
|
e.preventDefault();
|
||||||
|
popup.remove();
|
||||||
|
var href = link.getAttribute('href');
|
||||||
|
if (href && typeof navigateTo === 'function') navigateTo(href);
|
||||||
|
else window.location.href = href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 点击外部关闭
|
||||||
|
setTimeout(function() {
|
||||||
|
document.addEventListener('click', function popupOutside(e) {
|
||||||
|
if (!popup.contains(e.target) && e.target !== header && !header.contains(e.target)) {
|
||||||
|
if (popup.parentNode) popup.remove();
|
||||||
|
document.removeEventListener('click', popupOutside);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
submenu.classList.toggle('open');
|
||||||
|
if (arrow) arrow.classList.toggle('rotated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时检查侧边栏状态
|
||||||
|
function updateSidebarTitles() {
|
||||||
|
var sidebar = document.getElementById('sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
if (sidebar.classList.contains('collapsed')) {
|
||||||
|
sidebar.querySelectorAll('.nav-item').forEach(function(item) {
|
||||||
|
var textEl = item.querySelector('.nav-text');
|
||||||
|
if (textEl && textEl.textContent.trim()) {
|
||||||
|
item.setAttribute('title', textEl.textContent.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sidebar.querySelectorAll('.nav-sub-item').forEach(function(item) {
|
||||||
|
var textEl = item.querySelector('span:not(.nav-sub-icon)');
|
||||||
|
if (textEl && textEl.textContent.trim()) {
|
||||||
|
item.setAttribute('title', textEl.textContent.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateSidebarTitles();
|
||||||
|
var page = '{{ page }}';
|
||||||
|
// 自动展开当前页面对应的子菜单
|
||||||
|
var activeSubItem = document.querySelector('.nav-sub-item.active');
|
||||||
|
if (activeSubItem) {
|
||||||
|
var submenu = activeSubItem.closest('.nav-submenu');
|
||||||
|
if (submenu) {
|
||||||
|
submenu.classList.add('open');
|
||||||
|
var arrow = submenu.closest('.nav-group') ? submenu.closest('.nav-group').querySelector('.nav-arrow') : null;
|
||||||
|
if (arrow) arrow.classList.add('rotated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 展开基础数据子菜单
|
||||||
|
var basicActive = document.querySelector('#submenu-basic .nav-sub-item.active');
|
||||||
|
if (basicActive) {
|
||||||
|
var bMenu = document.getElementById('submenu-basic');
|
||||||
|
if (bMenu) bMenu.classList.add('open');
|
||||||
|
var bArrow = document.querySelector('.nav-group-header[data-group="basic"] .nav-arrow');
|
||||||
|
if (bArrow) bArrow.classList.add('rotated');
|
||||||
|
}
|
||||||
|
// 展开库存与出库子菜单
|
||||||
|
var invActive = document.querySelector('#submenu-inventory .nav-sub-item.active');
|
||||||
|
if (invActive) {
|
||||||
|
var iMenu = document.getElementById('submenu-inventory');
|
||||||
|
if (iMenu) iMenu.classList.add('open');
|
||||||
|
var iArrow = document.querySelector('.nav-group-header[data-group="inventory"] .nav-arrow');
|
||||||
|
if (iArrow) iArrow.classList.add('rotated');
|
||||||
|
}
|
||||||
|
// 展开采购订单菜单
|
||||||
|
var purchaseActive = document.querySelector('#submenu-purchase .nav-sub-item.active');
|
||||||
|
if (purchaseActive) {
|
||||||
|
var pMenu = document.getElementById('submenu-purchase');
|
||||||
|
if (pMenu) pMenu.classList.add('open');
|
||||||
|
var pArrow = document.querySelector('.nav-group-header[data-group="purchase"] .nav-arrow');
|
||||||
|
if (pArrow) pArrow.classList.add('rotated');
|
||||||
|
}
|
||||||
|
// 如果当前在高权限页面,展开系统设置菜单
|
||||||
|
var sysConfigActive = document.querySelector('#submenu-sysconfig .nav-sub-item.active');
|
||||||
|
if (sysConfigActive) {
|
||||||
|
var sysMenu = document.getElementById('submenu-sysconfig');
|
||||||
|
if (sysMenu) sysMenu.classList.add('open');
|
||||||
|
var sysArrow = document.querySelector('.nav-group-header[data-group="sysconfig"] .nav-arrow');
|
||||||
|
if (sysArrow) sysArrow.classList.add('rotated');
|
||||||
|
}
|
||||||
|
// 权限检查:如果初始页面无查看权限,跳转到第一个可访问的页面
|
||||||
|
var startPage = page;
|
||||||
|
if (page !== 'login' && window._currentUser && window._currentUser.role !== 'super_admin') {
|
||||||
|
var perms = window._currentUser.permissions || {};
|
||||||
|
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
|
||||||
|
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) {
|
||||||
|
// 找第一个有权限的页面
|
||||||
|
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','feedback','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
|
||||||
|
for (var pi = 0; pi < accessiblePages.length; pi++) {
|
||||||
|
var ap = accessiblePages[pi];
|
||||||
|
var apPerms = perms[ap] || {};
|
||||||
|
if (apPerms['view'] !== 0) {
|
||||||
|
startPage = ap;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window._currentPage = startPage;
|
||||||
|
loadPage(startPage);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
// 先清除所有旧 SW 缓存
|
||||||
|
if (caches) {
|
||||||
|
caches.keys().then(function(keys) {
|
||||||
|
keys.forEach(function(k) { if (k.startsWith('dy-dashboard-')) caches.delete(k); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
navigator.serviceWorker.register('/static/sw.js?v=21').then(function(reg) {
|
||||||
|
reg.update();
|
||||||
|
if (reg.installing) {
|
||||||
|
reg.installing.addEventListener('statechange', function() {
|
||||||
|
if (this.state === 'activated') location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.log('SW failed:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<div style="position:fixed;bottom:4px;right:8px;font-size:10px;color:#ccc;z-index:99999">v202607311130</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
202
templates/login.html
Normal file
202
templates/login.html
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>蝶雅采购管理系统 - 登录</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css?v=317">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-logo">
|
||||||
|
<img src="/static/logo-small.png" alt="Logo" style="width:64px;height:64px;border-radius:12px;">
|
||||||
|
</div>
|
||||||
|
<h1>蝶雅采购管理系统</h1>
|
||||||
|
<p class="login-subtitle">采购智能管理平台</p>
|
||||||
|
</div>
|
||||||
|
<form id="loginForm" class="login-form" onsubmit="return false;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="username" class="form-input" placeholder="请输入用户名" autocomplete="off" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<div class="password-input-wrap">
|
||||||
|
<input type="password" id="password" class="form-input" placeholder="请输入密码" autocomplete="off">
|
||||||
|
<button type="button" class="password-toggle" onclick="togglePassword('password', this)" aria-label="显示密码" title="显示密码">
|
||||||
|
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A11.7 11.7 0 0 1 12 5c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.1 3M6.6 6.7C3.7 8.5 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-error" id="loginError"></div>
|
||||||
|
<button type="submit" class="btn btn-primary login-btn" onclick="handleLogin()">
|
||||||
|
<span class="btn-text">登 录</span>
|
||||||
|
<span class="btn-loading" style="display:none">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form id="changePasswordForm" class="login-form" onsubmit="return false;" style="display:none">
|
||||||
|
<p class="password-notice">首次登录或密码重置后,必须先设置新密码。</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>新密码</label>
|
||||||
|
<div class="password-input-wrap">
|
||||||
|
<input type="password" id="newPassword" class="form-input" placeholder="至少10位,包含字母、数字和特殊字符" autocomplete="off">
|
||||||
|
<button type="button" class="password-toggle" onclick="togglePassword('newPassword', this)" aria-label="显示密码" title="显示密码">
|
||||||
|
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A11.7 11.7 0 0 1 12 5c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.1 3M6.6 6.7C3.7 8.5 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>确认新密码</label>
|
||||||
|
<div class="password-input-wrap">
|
||||||
|
<input type="password" id="confirmPassword" class="form-input" placeholder="请再次输入新密码" autocomplete="off">
|
||||||
|
<button type="button" class="password-toggle" onclick="togglePassword('confirmPassword', this)" aria-label="显示密码" title="显示密码">
|
||||||
|
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A11.7 11.7 0 0 1 12 5c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.1 3M6.6 6.7C3.7 8.5 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-error" id="changePasswordError"></div>
|
||||||
|
<button type="submit" class="btn btn-primary login-btn" onclick="handleChangePassword()">
|
||||||
|
<span class="btn-text">确认修改</span>
|
||||||
|
<span class="btn-loading" style="display:none">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="passwordPrompt" class="password-prompt-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="passwordPromptTitle">
|
||||||
|
<div class="password-prompt-card">
|
||||||
|
<div class="password-prompt-icon">!</div>
|
||||||
|
<h2 id="passwordPromptTitle">需要修改密码</h2>
|
||||||
|
<p>为了账号安全,本次登录需要先设置新密码。修改完成后才能正常访问系统。</p>
|
||||||
|
<button type="button" class="btn btn-primary password-prompt-btn" onclick="enterChangePassword()">我知道了,去修改</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function handleLogin() {
|
||||||
|
var btn = document.querySelector('.login-btn');
|
||||||
|
var text = btn.querySelector('.btn-text');
|
||||||
|
var loading = btn.querySelector('.btn-loading');
|
||||||
|
var error = document.getElementById('loginError');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
text.style.display = 'none';
|
||||||
|
loading.style.display = 'inline';
|
||||||
|
error.textContent = '';
|
||||||
|
|
||||||
|
fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.code === 0) {
|
||||||
|
if (data.data && data.data.must_change_password) {
|
||||||
|
document.getElementById('password').value = '';
|
||||||
|
document.getElementById('passwordPrompt').style.display = 'flex';
|
||||||
|
document.querySelector('.password-prompt-btn').focus();
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.textContent = data.msg;
|
||||||
|
btn.disabled = false;
|
||||||
|
text.style.display = 'inline';
|
||||||
|
loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
error.textContent = '网络错误,请重试';
|
||||||
|
btn.disabled = false;
|
||||||
|
text.style.display = 'inline';
|
||||||
|
loading.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterChangePassword() {
|
||||||
|
document.getElementById('passwordPrompt').style.display = 'none';
|
||||||
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
|
document.getElementById('changePasswordForm').style.display = 'block';
|
||||||
|
document.querySelector('.login-subtitle').textContent = '请设置新的登录密码';
|
||||||
|
document.getElementById('newPassword').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePassword(inputId, button) {
|
||||||
|
var input = document.getElementById(inputId);
|
||||||
|
var show = input.type === 'password';
|
||||||
|
input.type = show ? 'text' : 'password';
|
||||||
|
button.classList.toggle('is-visible', show);
|
||||||
|
button.setAttribute('aria-label', show ? '隐藏密码' : '显示密码');
|
||||||
|
button.setAttribute('title', show ? '隐藏密码' : '显示密码');
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangePassword() {
|
||||||
|
var form = document.getElementById('changePasswordForm');
|
||||||
|
var btn = form.querySelector('.login-btn');
|
||||||
|
var text = btn.querySelector('.btn-text');
|
||||||
|
var loading = btn.querySelector('.btn-loading');
|
||||||
|
var error = document.getElementById('changePasswordError');
|
||||||
|
var newPassword = document.getElementById('newPassword').value;
|
||||||
|
var confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
|
error.textContent = '';
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
error.textContent = '两次输入的密码不一致';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
text.style.display = 'none';
|
||||||
|
loading.style.display = 'inline';
|
||||||
|
|
||||||
|
fetch('/api/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
new_password: newPassword,
|
||||||
|
confirm_password: confirmPassword
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.code === 0) {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.textContent = data.msg;
|
||||||
|
btn.disabled = false;
|
||||||
|
text.style.display = 'inline';
|
||||||
|
loading.style.display = 'none';
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
error.textContent = '网络错误,请重试';
|
||||||
|
btn.disabled = false;
|
||||||
|
text.style.display = 'inline';
|
||||||
|
loading.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loginForm').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') handleLogin();
|
||||||
|
});
|
||||||
|
document.getElementById('changePasswordForm').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') handleChangePassword();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
120
tests/test_backup_production_no_sync.py
Normal file
120
tests/test_backup_production_no_sync.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Keep production numbers linked to one order, including date-only history."""
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest import mock
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
import routes.purchase as purchase
|
||||||
|
|
||||||
|
|
||||||
|
class BackupProductionNoSyncTest(unittest.TestCase):
|
||||||
|
def test_exact_time_wins_without_using_date_fallback(self):
|
||||||
|
read = mock.Mock(return_value=[{'id': 9}])
|
||||||
|
self.assertEqual(purchase._backup_linked_order_ids(
|
||||||
|
read, 'domestic_orders', 'P1', '2026-09-27 18:29:13'), [9])
|
||||||
|
read.assert_called_once()
|
||||||
|
self.assertEqual(read.call_args.args[1], ('P1', '2026-09-27 18:29:13'))
|
||||||
|
|
||||||
|
def test_legacy_date_matches_only_unique_backup_and_product_order(self):
|
||||||
|
read = mock.Mock(side_effect=[[], [{'id': 2804}], [{'id': 1555}]])
|
||||||
|
self.assertEqual(purchase._backup_linked_order_ids(
|
||||||
|
read, 'domestic_orders', 'P1', '2026-09-27 18:29:13'), [2804])
|
||||||
|
self.assertIn('TRIM(`计划下单时间`)=%s', read.call_args_list[1].args[0])
|
||||||
|
self.assertEqual(read.call_args_list[1].args[1], ('P1', '2026-09-27'))
|
||||||
|
|
||||||
|
def test_ambiguous_same_day_backups_are_never_broadly_updated(self):
|
||||||
|
read = mock.Mock(side_effect=[[], [{'id': 9}], [{'id': 1}, {'id': 2}]])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
purchase._backup_linked_order_ids(read, 'domestic_orders', 'P1', '2026-09-27 18:29:13')
|
||||||
|
|
||||||
|
def test_ambiguous_legacy_product_orders_are_rejected(self):
|
||||||
|
read = mock.Mock(side_effect=[[], [{'id': 9}, {'id': 10}], [{'id': 1}]])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
purchase._backup_linked_order_ids(read, 'overseas_orders', 'P1', '2026-09-27 18:29:13')
|
||||||
|
|
||||||
|
def test_multiple_packaging_rows_can_belong_to_one_backup(self):
|
||||||
|
read = mock.Mock(side_effect=[[], [{'id': 9}, {'id': 10}], [{'id': 1}]])
|
||||||
|
self.assertEqual(purchase._backup_linked_order_ids(
|
||||||
|
read, 'packaging_orders', 'P1', '2026-09-27 18:29:13'), [9, 10])
|
||||||
|
|
||||||
|
def call_save(self, replies, execute_error=None):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = 'test-only'
|
||||||
|
conn = mock.MagicMock()
|
||||||
|
cur = conn.cursor.return_value.__enter__.return_value
|
||||||
|
cur.fetchall.side_effect = replies
|
||||||
|
if execute_error:
|
||||||
|
cur.execute.side_effect = execute_error
|
||||||
|
with app.test_request_context(json={
|
||||||
|
'product_code': 'P1', 'backup_id': 1555, 'production_order_no': 'PS2026093029'
|
||||||
|
}), mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
|
||||||
|
mock.patch.object(purchase, 'get_db', return_value=conn), \
|
||||||
|
mock.patch.object(purchase, 'add_operation_log'):
|
||||||
|
response = purchase.backup_update_prod_no.__wrapped__()
|
||||||
|
return response.get_json(), conn, cur
|
||||||
|
|
||||||
|
def test_save_and_sync_commit_together_using_target_ids(self):
|
||||||
|
body, conn, cur = self.call_save([
|
||||||
|
[{'id': 1555, 'ordered_at': datetime(2026, 9, 27, 18, 29, 13)}],
|
||||||
|
[], [{'id': 2804}], [{'id': 1555}], # domestic legacy date
|
||||||
|
[], [], # no overseas order
|
||||||
|
[{'id': 3}, {'id': 4}], # exact packaging rows
|
||||||
|
])
|
||||||
|
self.assertEqual(body['code'], 0)
|
||||||
|
self.assertEqual(body['data']['synced'], {
|
||||||
|
'domestic_orders': 1, 'overseas_orders': 0, 'packaging_orders': 2,
|
||||||
|
})
|
||||||
|
writes = [call.args for call in cur.execute.call_args_list if call.args[0].startswith('UPDATE')]
|
||||||
|
self.assertEqual([args[1] for args in writes], [
|
||||||
|
('PS2026093029', 1555), ('PS2026093029', 2804), ('PS2026093029', 3, 4)
|
||||||
|
])
|
||||||
|
for sql, params in writes:
|
||||||
|
self.assertEqual(sql.count('%s'), len(params))
|
||||||
|
conn.commit.assert_called_once()
|
||||||
|
conn.rollback.assert_not_called()
|
||||||
|
conn.close.assert_called_once()
|
||||||
|
|
||||||
|
def test_ambiguity_prevents_even_the_backup_write(self):
|
||||||
|
body, conn, cur = self.call_save([
|
||||||
|
[{'id': 1555, 'ordered_at': datetime(2026, 9, 27, 18, 29, 13)}],
|
||||||
|
[], [{'id': 2804}], [{'id': 1555}, {'id': 1556}],
|
||||||
|
])
|
||||||
|
self.assertEqual(body['code'], 409)
|
||||||
|
self.assertFalse(any(call.args[0].startswith('UPDATE') for call in cur.execute.call_args_list))
|
||||||
|
conn.commit.assert_not_called()
|
||||||
|
conn.rollback.assert_called_once()
|
||||||
|
|
||||||
|
def test_wrong_product_or_missing_backup_is_not_saved(self):
|
||||||
|
body, conn, cur = self.call_save([[]])
|
||||||
|
self.assertEqual(body['code'], 404)
|
||||||
|
self.assertIn('product_code=%s', cur.execute.call_args.args[0])
|
||||||
|
self.assertEqual(cur.execute.call_args.args[1], (1555, 'P1'))
|
||||||
|
conn.commit.assert_not_called()
|
||||||
|
conn.rollback.assert_called_once()
|
||||||
|
|
||||||
|
def test_write_failure_rolls_back_all_related_updates(self):
|
||||||
|
app = Flask(__name__)
|
||||||
|
conn = mock.MagicMock()
|
||||||
|
cur = conn.cursor.return_value.__enter__.return_value
|
||||||
|
cur.fetchall.side_effect = [
|
||||||
|
[{'id': 1555, 'ordered_at': datetime(2026, 9, 27, 18, 29, 13)}],
|
||||||
|
[{'id': 2804}], [], [], [], [],
|
||||||
|
]
|
||||||
|
def execute(sql, params):
|
||||||
|
if sql.startswith('UPDATE domestic_orders'):
|
||||||
|
raise RuntimeError('database write failure')
|
||||||
|
cur.execute.side_effect = execute
|
||||||
|
with app.test_request_context(json={'product_code': 'P1', 'backup_id': 1555, 'production_order_no': 'PS1'}), \
|
||||||
|
mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
|
||||||
|
mock.patch.object(purchase, 'get_db', return_value=conn), \
|
||||||
|
mock.patch.object(purchase, 'add_operation_log') as audit:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
purchase.backup_update_prod_no.__wrapped__()
|
||||||
|
audit.assert_not_called()
|
||||||
|
conn.commit.assert_not_called()
|
||||||
|
conn.rollback.assert_called_once()
|
||||||
|
conn.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
45
tests/test_packaging_loading_and_sku_modal.py
Normal file
45
tests/test_packaging_loading_and_sku_modal.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""包材订单首屏加载与订单 SKU 弹窗布局回归测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_JS = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class PackagingLoadingAndSkuModalTest(unittest.TestCase):
|
||||||
|
def test_packaging_list_does_not_wait_for_auxiliary_requests(self):
|
||||||
|
start = APP_JS.index('function loadPackagingOrdersPage(container)')
|
||||||
|
end = APP_JS.index('function packagingLoadFilters()', start)
|
||||||
|
page_loader = APP_JS[start:end]
|
||||||
|
|
||||||
|
list_call = page_loader.rindex('packagingLoadOrders(1);')
|
||||||
|
filter_call = page_loader.rindex('packagingLoadFilters();')
|
||||||
|
follower_call = page_loader.rindex('loadOrderFollowers();')
|
||||||
|
self.assertLess(list_call, filter_call)
|
||||||
|
self.assertLess(list_call, follower_call)
|
||||||
|
self.assertNotIn("packagingLoadFilters().then(function()", page_loader)
|
||||||
|
|
||||||
|
def test_packaging_errors_replace_permanent_loading_state(self):
|
||||||
|
self.assertIn("throw new Error(res.msg || '包材订单加载失败')", APP_JS)
|
||||||
|
self.assertIn('加载失败:', APP_JS)
|
||||||
|
self.assertIn('重新加载', APP_JS)
|
||||||
|
|
||||||
|
def test_domestic_and_overseas_sku_modal_use_card_layout(self):
|
||||||
|
self.assertIn('class="modal sku-info-modal"', APP_JS)
|
||||||
|
self.assertIn('📋 基础信息', APP_JS)
|
||||||
|
self.assertIn('关联包材', APP_JS)
|
||||||
|
self.assertIn('订单在途', APP_JS)
|
||||||
|
self.assertIn('grid-template-columns:repeat(2,minmax(0,1fr))', APP_JS)
|
||||||
|
|
||||||
|
def test_sku_information_sections_use_bordered_tables_with_colored_headers(self):
|
||||||
|
self.assertIn('class="sku-info-basic-table"', APP_JS)
|
||||||
|
self.assertIn('class="sku-info-transit-table"', APP_JS)
|
||||||
|
self.assertIn('border:1px solid #b8c8db', APP_JS)
|
||||||
|
self.assertIn('border:1px solid #b9cdbf', APP_JS)
|
||||||
|
self.assertIn('background:#eef6ff', APP_JS)
|
||||||
|
self.assertIn('background:#edf8f0', APP_JS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
26
tests/test_pending_remark_viewer.py
Normal file
26
tests/test_pending_remark_viewer.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""待下单详情下单备注完整查看功能回归测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_JS = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class PendingRemarkViewerTest(unittest.TestCase):
|
||||||
|
def test_viewer_reads_the_current_input_value(self):
|
||||||
|
self.assertIn('window.openPendingRemarkViewer = function(trigger, title)', APP_JS)
|
||||||
|
self.assertIn("var remark = input ? input.value : '';", APP_JS)
|
||||||
|
self.assertIn("overlay.querySelector('textarea').value = remark;", APP_JS)
|
||||||
|
|
||||||
|
def test_product_remark_has_full_content_button(self):
|
||||||
|
self.assertIn('aria-label="查看完整成品下单备注"', APP_JS)
|
||||||
|
self.assertIn("openPendingRemarkViewer(this,\\'成品下单备注\\')", APP_JS)
|
||||||
|
|
||||||
|
def test_material_remark_has_full_content_button(self):
|
||||||
|
self.assertIn('aria-label="查看完整包材下单备注"', APP_JS)
|
||||||
|
self.assertIn("openPendingRemarkViewer(this,\\'包材下单备注\\')", APP_JS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
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()
|
||||||
41
tests/test_planning_support_days_consistency.py
Normal file
41
tests/test_planning_support_days_consistency.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""计划预测支撑天数统一口径回归测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PRODUCTS_PY = (ROOT / "routes" / "products.py").read_text(encoding="utf-8")
|
||||||
|
PLANNING_PY = (ROOT / "routes" / "planning.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSupportDaysConsistencyTest(unittest.TestCase):
|
||||||
|
def test_detail_reuses_the_list_and_export_enrichment(self):
|
||||||
|
detail_source = PRODUCTS_PY.split("def product_planning_detail():", 1)[1].split(
|
||||||
|
"@products_bp.route('/api/bom/list')", 1
|
||||||
|
)[0]
|
||||||
|
self.assertIn("from routes.planning import _enrich_row_with_planning_data", detail_source)
|
||||||
|
self.assertIn("row = _enrich_row_with_planning_data(", detail_source)
|
||||||
|
self.assertNotIn("total_inv = prod_transit + inv15_val", detail_source)
|
||||||
|
|
||||||
|
def test_cloud_and_external_detail_use_shared_support_days(self):
|
||||||
|
detail_source = PRODUCTS_PY.split("def product_planning_detail():", 1)[1].split(
|
||||||
|
"@products_bp.route('/api/bom/list')", 1
|
||||||
|
)[0]
|
||||||
|
self.assertIn("'总支撑天数': row.get('云仓支撑天数', 999)", detail_source)
|
||||||
|
self.assertIn("'总支撑天数': row.get('外仓支撑天数', 999)", detail_source)
|
||||||
|
|
||||||
|
def test_export_accepts_real_support_day_field_names(self):
|
||||||
|
export_source = PLANNING_PY.split("def planning_export():", 1)[1].split(
|
||||||
|
"def planning_page():", 1
|
||||||
|
)[0]
|
||||||
|
for field in (
|
||||||
|
"库存工厂可支撑天数",
|
||||||
|
"加工厂库存支撑天数",
|
||||||
|
"库存工厂待生产可支撑天数",
|
||||||
|
):
|
||||||
|
self.assertIn(field, export_source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -18,6 +18,7 @@ class ProductBrandFilterStaticTest(unittest.TestCase):
|
|||||||
self.assertIn("loadProductBrandFilter('成品')", APP_JS)
|
self.assertIn("loadProductBrandFilter('成品')", APP_JS)
|
||||||
self.assertIn('loadProductBrandFilter(tab)', APP_JS)
|
self.assertIn('loadProductBrandFilter(tab)', APP_JS)
|
||||||
self.assertGreaterEqual(APP_JS.count("url += '&brand='"), 2)
|
self.assertGreaterEqual(APP_JS.count("url += '&brand='"), 2)
|
||||||
|
self.assertIn("brand === '__EMPTY__' ? '(品牌为空)' : brand", APP_JS)
|
||||||
|
|
||||||
|
|
||||||
class ProductBrandFilterApiTest(unittest.TestCase):
|
class ProductBrandFilterApiTest(unittest.TestCase):
|
||||||
@ -35,16 +36,43 @@ class ProductBrandFilterApiTest(unittest.TestCase):
|
|||||||
self.assertIn('TRIM(p.brand)=%s', query.call_args_list[0].args[0])
|
self.assertIn('TRIM(p.brand)=%s', query.call_args_list[0].args[0])
|
||||||
self.assertEqual(query.call_args_list[0].args[1], ['无'])
|
self.assertEqual(query.call_args_list[0].args[1], ['无'])
|
||||||
|
|
||||||
|
def test_active_products_filter_empty_brand(self):
|
||||||
|
with mock.patch.object(products, 'query', side_effect=[[], [{'cnt': 0}], []]) as query:
|
||||||
|
response = self.client.get('/api/products/list?brand=__EMPTY__')
|
||||||
|
|
||||||
|
self.assertEqual(response.get_json()['code'], 0)
|
||||||
|
self.assertIn("(p.brand IS NULL OR TRIM(p.brand)='')", query.call_args_list[1].args[0])
|
||||||
|
self.assertEqual(query.call_args_list[1].args[1], [])
|
||||||
|
|
||||||
|
def test_recycle_bin_filters_empty_brand(self):
|
||||||
|
with mock.patch.object(products, 'query', side_effect=[[{'cnt': 0}], []]) as query:
|
||||||
|
response = self.client.get('/api/products/recycle_bin?brand=__EMPTY__')
|
||||||
|
|
||||||
|
self.assertEqual(response.get_json()['code'], 0)
|
||||||
|
self.assertIn("(p.brand IS NULL OR TRIM(p.brand)='')", query.call_args_list[0].args[0])
|
||||||
|
self.assertEqual(query.call_args_list[0].args[1], [])
|
||||||
|
|
||||||
def test_filter_options_can_be_scoped_to_recycle_bin(self):
|
def test_filter_options_can_be_scoped_to_recycle_bin(self):
|
||||||
with mock.patch.object(products, 'query', side_effect=[
|
with mock.patch.object(products, 'query', side_effect=[
|
||||||
[{'brand': '回收品牌'}],
|
[{'brand': '回收品牌'}],
|
||||||
[{'category1': '分类'}],
|
[{'category1': '分类'}],
|
||||||
|
[{'cnt': 2}],
|
||||||
]) as query:
|
]) as query:
|
||||||
response = self.client.get('/api/products/filters?scope=recycle')
|
response = self.client.get('/api/products/filters?scope=recycle')
|
||||||
|
|
||||||
self.assertEqual(response.get_json()['data']['brands'], ['回收品牌'])
|
self.assertEqual(response.get_json()['data']['brands'], ['__EMPTY__', '回收品牌'])
|
||||||
self.assertIn('deleted_at IS NOT NULL', query.call_args_list[0].args[0])
|
self.assertIn('deleted_at IS NOT NULL', query.call_args_list[0].args[0])
|
||||||
|
|
||||||
|
def test_filter_options_omit_empty_brand_when_scope_has_none(self):
|
||||||
|
with mock.patch.object(products, 'query', side_effect=[
|
||||||
|
[{'brand': '正常品牌'}],
|
||||||
|
[],
|
||||||
|
[{'cnt': 0}],
|
||||||
|
]):
|
||||||
|
response = self.client.get('/api/products/filters?scope=active')
|
||||||
|
|
||||||
|
self.assertEqual(response.get_json()['data']['brands'], ['正常品牌'])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
24
tests/test_system_branding.py
Normal file
24
tests/test_system_branding.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""登录页与系统导航名称回归测试。"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
LOGIN_HTML = (ROOT / 'templates/login.html').read_text(encoding='utf-8')
|
||||||
|
BASE_HTML = (ROOT / 'templates/base.html').read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class SystemBrandingTest(unittest.TestCase):
|
||||||
|
def test_login_uses_full_management_system_name(self):
|
||||||
|
self.assertIn('<h1>蝶雅采购管理系统</h1>', LOGIN_HTML)
|
||||||
|
self.assertIn('<title>蝶雅采购管理系统 - 登录</title>', LOGIN_HTML)
|
||||||
|
self.assertNotIn('蝶雅采购智能体平台', LOGIN_HTML)
|
||||||
|
|
||||||
|
def test_sidebar_uses_short_management_system_name(self):
|
||||||
|
self.assertIn('<span class="sidebar-title">采购管理系统</span>', BASE_HTML)
|
||||||
|
self.assertIn('<title>蝶雅采购管理系统</title>', BASE_HTML)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user