Compare commits

...

10 Commits

16 changed files with 3864 additions and 265 deletions

1488
routes/planning.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -265,7 +265,9 @@ def products_list():
if supplier_filter:
where.append("(s.supplier_name LIKE %s OR p.supplier_name_match LIKE %s)")
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")
params.append(brand_filter)
if category1_filter:
@ -405,10 +407,17 @@ def products_filters():
f"SELECT DISTINCT category1 FROM products WHERE {scope_clause}"
"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({
"code": 0,
"data": {
"brands": [b['brand'] for b in brands],
"brands": brand_values,
"categories": [c['category1'] for c in categories]
}
})
@ -495,6 +504,9 @@ def product_batch_update():
for field, value in fields.items():
if field not in allowed_fields:
continue
# 品牌来自表格/剪贴板时可能带有尾部 Tab 或换行;只清理首尾空白,保留名称中的正常空格。
if field == 'brand' and isinstance(value, str):
value = value.strip()
if value == '' or value is 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['批注'] = product.get('remark') or ''
# 计算计划预测字段
# 复用计划预测列表/导出的唯一计算逻辑,避免详情弹窗另维护一套公式。
from routes.planning import _enrich_row_with_planning_data
sales = compute_product_sales_cached()
inv_map = compute_product_inventory_cached()
inv15_map = compute_product_inventory_15()
transit_map = compute_product_in_transit_cached()
cloud_inv_map = compute_cloud_inventory_cached()
ext_inv_map = compute_external_inventory_cached()
extended_sales = compute_product_sales_extended_cached()
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
from services.forecast import get_global_config
gc = get_global_config()
row = product
pc = row.get('product_code', '')
row['库存'] = inv_map.get(pc, 0)
row['加工厂库存'] = inv15_map.get(pc, 0)
t = transit_map.get(pc, {})
row['生产在途'] = t.get('生产在途', 0)
row['采购在途'] = t.get('采购在途', 0)
if pc in sales:
s = sales[pc]
for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量',
'30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量',
'14天最高销量', '30天最高销量', '14天最低销量', '14天标准差',
'最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']:
row[k] = s.get(k, 0)
row['销售趋势'] = _determine_trend(s['3天平均销量'], s['7天平均销量'], s['14天平均销量'])
else:
for k in ['昨日销量', '3天平均销量', '7天平均销量', '14天平均销量',
'30天平均销量', '60天平均销量', '3天最高销量', '7天最高销量',
'14天最高销量', '30天最高销量', '14天最低销量', '14天标准差',
'最低销量偏差率', '最高销量偏差率', '极值判断', '14天波动率', '波动风险']:
row[k] = 0
row['销售趋势'] = _determine_trend(0, 0, 0)
row['产品分级'] = get_product_grade(row.get('30天平均销量', 0) * 30,
row.get('product_name', ''),
row.get('产品种类', ''))
row['是否新品'] = is_new_product(row.get('created_at', ''))
row['日销量预测'] = compute_daily_forecast(row, sp_map)
# 支撑天数
from services.forecast import get_global_config
gc = get_global_config()
safety_days = int(gc.get('safety_stock_days', '7'))
return_days = int(gc.get('suggested_return_days', '15'))
forecast_val = row.get('日销量预测', 0)
fcast = float(forecast_val) if forecast_val not in ('近期无销售', 0, '0') else 0
inv_val = float(row.get('库存', 0) or 0)
row = _enrich_row_with_planning_data(
row, sales, inv_map, inv15_map, transit_map, sp_map, gc,
cloud_inv_map=cloud_inv_map,
ext_inv_map=ext_inv_map,
extended_sales=extended_sales,
)
inv15_val = float(row.get('加工厂库存', 0) or 0)
prod_transit = float(row.get('生产在途', 0) or 0)
purch_transit = float(row.get('采购在途', 0) or 0)
if fcast == 0:
row['库存可支撑天数'] = 999
row['库存工厂可支撑天数'] = 999
row['库存工厂待生产可支撑天数'] = 999
row['加工厂库存支撑天数'] = 999
row['总支撑天数'] = 999
else:
row['库存可支撑天数'] = round(inv_val / fcast, 1) if fcast > 0 else 999
row['库存工厂可支撑天数'] = round(purch_transit / fcast, 1) if fcast > 0 else 999
row['库存工厂待生产可支撑天数'] = round(prod_transit / fcast, 1) if fcast > 0 else 999
row['加工厂库存支撑天数'] = round(inv15_val / fcast, 1) if fcast > 0 else 999
total_inv = prod_transit + inv15_val
row['总支撑天数'] = round(total_inv / fcast, 1) if fcast > 0 else 999
lead_h = float(row.get('lead_time', 0) or 0)
logis_h = float(row.get('logistics_time', 0) or 0)
sup_days = row.get('库存可支撑天数', 999)
sup_days_val = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else None
if sup_days_val is not None and (lead_h + logis_h) > 0:
row['库存健康指数'] = round(min(sup_days_val / (lead_h + logis_h), 999), 1)
else:
row['库存健康指数'] = 999
created = row.get('created_at', '')
days_since_created = 999
try:
if created:
created_dt = datetime.strptime(created[:10], '%Y-%m-%d') if isinstance(created, str) else created
days_since_created = (datetime.now() - created_dt).days
except:
pass
sup_days_val_f = float(sup_days) if isinstance(sup_days, (int, float)) and sup_days != 999 else 999
if days_since_created < 90:
row['风险把控'] = '新品'
elif days_since_created > 180 and sup_days_val_f > 240:
row['风险把控'] = '需启动清库存'
elif days_since_created > 90 and sup_days_val_f > 90:
row['风险把控'] = '呆滞品'
else:
row['风险把控'] = '正常'
if fcast == 0 and inv_val > 0:
row['建议下单数量'] = 0
else:
row['建议下单数量'] = int(fcast * (safety_days + lead_h + logis_h))
row['实际下单数量'] = ((row['建议下单数量'] + 999) // 1000) * 1000 if row['建议下单数量'] > 0 else 0
total_transit = prod_transit + purch_transit
if fcast > 0 and total_transit > 0:
calc_val = fcast * return_days - inv_val
round_up = max(0, int(calc_val * 10 + 0.99999) / 10)
row['建议回货数量'] = round_up if round_up <= total_transit else total_transit
else:
row['建议回货数量'] = 0
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
cloud_sales = compute_product_sales_extended_cached().get('cloud', {}).get(pc, {})
cloud_inventory = compute_cloud_inventory_cached().get(pc, 0)
cloud_sales = extended_sales.get('cloud', {}).get(pc, {})
cloud_inventory = row.get('云仓库存', 0)
cloud_3 = cloud_sales.get('3天平均销量', 0)
cloud_7 = cloud_sales.get('7天平均销量', 0)
cloud_14 = cloud_sales.get('14天平均销量', 0)
cloud_30 = cloud_sales.get('30天平均销量', 0)
cloud_trend = _determine_trend(cloud_3, cloud_7, cloud_14)
cloud_forecast_row = {
'3天平均销量': cloud_3,
'7天平均销量': cloud_7,
'14天平均销量': cloud_14,
'30天最高销量': cloud_sales.get('30天最高销量', 0),
'销售趋势': cloud_trend,
}
cloud_forecast = compute_daily_forecast(cloud_forecast_row, sp_map)
cloud_forecast = row.get('云仓日销量预测', 0)
cloud_fcast = float(cloud_forecast) if cloud_forecast not in ('近期无销售', 0, '0') else 0
row['云仓数据'] = {
'产品分级': row.get('产品分级', ''),
@ -986,26 +911,19 @@ def product_planning_detail():
'14天平均销量': cloud_14,
'库存可支撑天数': round(float(cloud_inventory or 0) / cloud_fcast, 1) if cloud_fcast else 999,
'加工厂库存支撑天数': round(inv15_val / cloud_fcast, 1) if cloud_fcast else 999,
'总支撑天数': round((prod_transit + inv15_val) / cloud_fcast, 1) if cloud_fcast else 999,
'总支撑天数': row.get('云仓支撑天数', 999),
'30天平均销量': cloud_30,
'极值判断': '-',
}
external_sales = compute_product_sales_extended_cached().get('external', {}).get(pc, {})
external_inventory = compute_external_inventory_cached().get(pc, 0)
external_sales = extended_sales.get('external', {}).get(pc, {})
external_inventory = row.get('外仓库存', 0)
external_3 = external_sales.get('3天平均销量', 0)
external_7 = external_sales.get('7天平均销量', 0)
external_14 = external_sales.get('14天平均销量', 0)
external_30 = external_sales.get('30天平均销量', 0)
external_trend = _determine_trend(external_3, external_7, external_14)
external_forecast_row = {
'3天平均销量': external_3,
'7天平均销量': external_7,
'14天平均销量': external_14,
'30天最高销量': external_sales.get('30天最高销量', 0),
'销售趋势': external_trend,
}
external_forecast = compute_daily_forecast(external_forecast_row, sp_map)
external_forecast = row.get('外仓日销量预测', 0)
external_fcast = float(external_forecast) if external_forecast not in ('近期无销售', 0, '0') else 0
row['外仓数据'] = {
'产品分级': row.get('产品分级', ''),
@ -1021,7 +939,7 @@ def product_planning_detail():
'14天平均销量': external_14,
'库存可支撑天数': round(float(external_inventory or 0) / external_fcast, 1) if external_fcast else 999,
'加工厂库存支撑天数': round(inv15_val / external_fcast, 1) if external_fcast else 999,
'总支撑天数': round((prod_transit + inv15_val) / external_fcast, 1) if external_fcast else 999,
'总支撑天数': row.get('外仓支撑天数', 999),
'30天平均销量': external_30,
'极值判断': '-',
}
@ -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)
""", (
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),
data.get('supplier_code', ''), data.get('supplier_name', ''),
data.get('procurement_follower', ''), data.get('procurement_planner', ''),
@ -2180,7 +2098,9 @@ def products_recycle_bin():
if supplier_filter:
where.append("(p.supplier_name_match LIKE %s OR s.supplier_name LIKE %s)")
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")
params.append(brand_filter)
if product_type:

View File

@ -5,7 +5,7 @@ import re
import unicodedata
from datetime import datetime
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.sales_calc import (compute_product_sales_cached,
compute_product_sales_extended_cached)
@ -957,7 +957,7 @@ def purchase_add_pending():
is_update = bool(keys & update_fields)
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。
existing_pending = query("SELECT id, bom_items FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
existing_pending = query("SELECT id, bom_items, new_old_product FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
if existing_pending and not is_update:
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
@ -1026,6 +1026,8 @@ def purchase_add_pending():
remark_val = data.get('remark', '')
audit_price = float(data.get('audit_price', 0))
from services.planning_order_flag import snapshot_new_old
new_old_product = snapshot_new_old(pc, existing_pending[0].get('new_old_product') if existing_pending else None)
# 已存在待下单行时按 id 更新,避免依赖 product_code 唯一索引;
# 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。
if existing_pending:
@ -1035,13 +1037,14 @@ def purchase_add_pending():
lead_time=%s, logistics_time=%s, total_support_days=%s,
procurement_follower=%s, procurement_planner=%s, platform=%s, operator=%s, created_by=%s,
audit_price=%s, formal_order_qty=%s, suggested_qty=%s, actual_order_qty=%s, bom_items=%s, remark=%s,
status='pending', updated_at=NOW()
new_old_product=%s, status='pending', updated_at=NOW()
WHERE id=%s
""", (
pn, pt, pg, sn,
int(lt), int(lo), float(ts),
pf, pp, pl, op, created_by,
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val,
new_old_product,
existing_pending[0]['id']
))
else:
@ -1050,15 +1053,15 @@ def purchase_add_pending():
(product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark, new_old_product)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
pc,
pn, pt, pg, sn,
int(lt), int(lo), float(ts),
pf, pp, pl, op,
created_by,
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val, new_old_product
))
if req_bom_items and bom_items_normalized:
_sync_pending_pre_packaging(pc, bom_items_normalized)
@ -1294,10 +1297,13 @@ def purchase_confirm_order():
has_product_order = True
if is_overseas and qty > 0:
from services.planning_order_flag import snapshot_new_old
new_old_product = snapshot_new_old(pc, row.get('new_old_product'))
query('UPDATE pending_orders SET new_old_product=%s WHERE id=%s', (new_old_product, row['id']))
# 写入海外订单表(含全部字段,日期留空由用户自行填写)
query("""INSERT INTO overseas_orders
(日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
(日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号,新旧品)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
None, now_str,
row.get('product_code'), '未完结',
@ -1321,7 +1327,8 @@ def purchase_confirm_order():
row.get('procurement_follower'),
row.get('product_name', ''),
row.get('remark', ''),
row.get('production_order_no', '')
row.get('production_order_no', ''),
new_old_product
))
elif not is_overseas and qty > 0:
previous_order = _latest_domestic_order_values(row.get('product_code'))
@ -1610,7 +1617,7 @@ def purchase_batch_add_pending():
products = query(f"""
SELECT p.product_code, p.product_name, p.product_type,
p.lead_time, p.logistics_time, p.supplier_name_match AS supplier_name,
p.procurement_follower, p.procurement_planner, p.platform, p.operator
p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.planning_new_old
FROM products p WHERE p.product_code IN ({placeholders})
""", product_codes)
@ -1638,14 +1645,15 @@ def purchase_batch_add_pending():
(product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator,
audit_price, formal_order_qty, bom_items)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
audit_price, formal_order_qty, bom_items, new_old_product)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
pc, p.get('product_name', ''), p.get('product_type', ''), '',
p.get('supplier_name', ''), int(p.get('lead_time', 0) or 0),
int(p.get('logistics_time', 0) or 0), 0,
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '',
p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data
p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data,
p.get('planning_new_old') or '否'
))
added += 1
@ -3795,14 +3803,15 @@ def purchase_rollback_order():
lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items,
production_order_no, remark, status, created_at, updated_at, ordered_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending', NOW(), NOW(), NULL)
production_order_no, remark, new_old_product, status, created_at, updated_at, ordered_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending', NOW(), NOW(), NULL)
""", (
row.get('product_code'), row.get('product_name'), row.get('product_type'), row.get('product_grade'),
row.get('supplier_name'), row.get('lead_time'), row.get('logistics_time'), row.get('total_support_days'),
row.get('procurement_follower'), row.get('procurement_planner'), row.get('platform'), row.get('operator'),
row.get('created_by'), row.get('audit_price'), row.get('formal_order_qty'), row.get('suggested_qty'),
row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark')
row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark'),
row.get('new_old_product')
))
pending_check = query(
"SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1",
@ -3870,7 +3879,8 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
其次按传入 ordered_at,最后回退到该商品最近一次下单。
"""
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:
return str(ordered_at)[:19]
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]
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'])
@login_required
def backup_update_prod_no():
denied = _require_purchase_operation('purchase_ready', 'edit')
if denied:
return denied
"""Update only order rows created by this exact backup order time."""
data = request.json
"""Save the backup and uniquely linked order numbers in one transaction."""
data = request.get_json(silent=True) or {}
product_code = data.get('product_code', '')
prod_no = data.get('production_order_no', '')
backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '')
if not product_code:
return jsonify({"code": 400, "msg": "参数错误"})
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at)
if not ordered_at:
return jsonify({"code": 404, "msg": "未找到对应下单时间"})
conn = get_db()
counts = {}
try:
conn.begin()
with conn.cursor() as cur:
def read(sql, params):
cur.execute(sql, params)
return cur.fetchall()
if backup_id:
query("""UPDATE pending_orders SET production_order_no=%s WHERE id=%s AND status='ordered'""",
(prod_no, backup_id))
backups = read("SELECT id, ordered_at FROM pending_orders WHERE id=%s "
"AND product_code=%s AND status='ordered' FOR UPDATE", (backup_id, product_code))
elif ordered_at:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
"AND status='ordered' AND ordered_at=%s FOR UPDATE", (product_code, ordered_at))
else:
query("""UPDATE pending_orders SET production_order_no=%s
WHERE product_code=%s AND status='ordered' AND ordered_at=%s""",
(prod_no, product_code, ordered_at))
query("""UPDATE domestic_orders SET `旺店通生产单号`=%s
WHERE `货品编号`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
query("""UPDATE overseas_orders SET `旺店通生产单号`=%s
WHERE `产品编码`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
query("""UPDATE packaging_orders SET `生产单号`=%s
WHERE `来源产品编码`=%s AND `下单时间`=%s""", (prod_no, product_code, ordered_at))
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}')
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'])

View File

@ -0,0 +1,28 @@
"""Add the manual flag and pending snapshot; use --local on the test server."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
if '--local' in sys.argv:
import test_wsgi
from services.db import query
def migrate():
for table, column, definition in (
('products', 'planning_new_old', "VARCHAR(8) NOT NULL DEFAULT '否'"),
('pending_orders', 'new_old_product', 'VARCHAR(8) NULL DEFAULT NULL'),
):
exists = query(
'SELECT COLUMN_NAME FROM information_schema.COLUMNS '
'WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME=%s',
(table, column),
)
if not exists:
query(f'ALTER TABLE {table} ADD COLUMN {column} {definition}')
print(f'{table}.{column}: ready')
if __name__ == '__main__':
migrate()

717
services/field_perms.py Normal file
View File

@ -0,0 +1,717 @@
"""字段级权限:字段清单单一来源 + 权限解析 + 后端强制过滤。
设计目标(对应「字段级权限」优化):
1. 字段清单单一来源:FIELD_REGISTRY 定义每个页面的字段(key=中文显示名,与前端列定义、
现有 user_permissions 存储保持一致),sensitive 标记敏感字段。
2. 权限存储复用现有表结构,无需迁移:
- 角色级字段权限:role_operation_permissions,operation = 'field:<字段>:view' / 'field:<字段>:edit'
- 用户级字段权限:user_permissions,operation 同上
3. 敏感字段默认隐藏:敏感字段未显式授权时,默认拒绝查看/编辑(安全优先)。
4. 后端强制过滤:filter_rows / filter_row 按有效字段权限剔除无权查看的字段。
权限解析优先级:管理员全权限 → 用户级明确值 → 角色级明确值 → 敏感字段默认拒绝 / 非敏感默认继承页面权限。
"""
from services.db import query
from services.permission_model import ensure_position_permissions, user_position_code
# ─── 字段清单单一来源 ───
# key 统一使用中文显示名(与前端列定义一致),sensitive=True 表示敏感字段(默认隐藏,需显式授权)。
FIELD_REGISTRY = {
# 计划预测(与 PLAN_COLUMNS 对齐)
'planning': [
{'key': '产品类型', 'sensitive': False},
{'key': '产品种类', 'sensitive': False},
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '批注', 'sensitive': False},
{'key': '新旧品', 'sensitive': False},
{'key': '下单清单', 'sensitive': False},
{'key': '产品分级', 'sensitive': False},
{'key': '昨日销量', 'sensitive': False},
{'key': '云仓昨日销量', 'sensitive': False},
{'key': '外仓昨日销量', 'sensitive': False},
{'key': '销售趋势', 'sensitive': False},
{'key': '总库存', 'sensitive': False},
{'key': '库存', 'sensitive': False},
{'key': '总在途库存', 'sensitive': False},
{'key': '云仓库存', 'sensitive': False},
{'key': '外仓库存', 'sensitive': False},
{'key': '加工厂库存', 'sensitive': False},
{'key': '云仓可分配库存', 'sensitive': False},
{'key': '外仓可分配库存', 'sensitive': False},
{'key': '是否调拨', 'sensitive': False},
{'key': '生产在途', 'sensitive': False},
{'key': '采购在途', 'sensitive': False},
{'key': '日销量预测', 'sensitive': False},
{'key': '云仓日销量预测', 'sensitive': False},
{'key': '外仓日销量预测', 'sensitive': False},
{'key': '建议下单数量', 'sensitive': False},
{'key': '实际下单数量', 'sensitive': False},
{'key': '正式下单数量', 'sensitive': False},
{'key': '库存健康指数', 'sensitive': False},
{'key': '风险把控', 'sensitive': False},
{'key': '供应商名称', 'sensitive': False},
{'key': '采购跟单', 'sensitive': False},
{'key': '采购计划', 'sensitive': False},
{'key': '开发专员', 'sensitive': False},
{'key': '归属平台', 'sensitive': False},
{'key': '运营专员', 'sensitive': False},
{'key': '成本价', 'sensitive': True},
{'key': '3天平均销量', 'sensitive': False},
{'key': '云仓3天平均销量', 'sensitive': False},
{'key': '外仓3天平均销量', 'sensitive': False},
{'key': '7天平均销量', 'sensitive': False},
{'key': '云仓7天平均销量', 'sensitive': False},
{'key': '外仓7天平均销量', 'sensitive': False},
{'key': '14天平均销量', 'sensitive': False},
{'key': '云仓14天平均销量', 'sensitive': False},
{'key': '外仓14天平均销量', 'sensitive': False},
{'key': '库存可支撑天数', 'sensitive': False},
{'key': '现货采购在途', 'sensitive': False},
{'key': '加工厂库存支撑', 'sensitive': False},
{'key': '定制品在途', 'sensitive': False},
{'key': '总支撑天数', 'sensitive': False},
{'key': '云仓支撑天数', 'sensitive': False},
{'key': '外仓支撑天数', 'sensitive': False},
{'key': '建议回货数量', 'sensitive': False},
{'key': '产品规格', 'sensitive': False},
{'key': '30天平均销量', 'sensitive': False},
{'key': '60天平均销量', 'sensitive': False},
{'key': '生产货期', 'sensitive': False},
{'key': '物流时间', 'sensitive': False},
{'key': '最低起订量', 'sensitive': False},
{'key': '创建时间', 'sensitive': False},
{'key': '是否新品', 'sensitive': False},
{'key': '一级类目', 'sensitive': False},
{'key': '二级类目', 'sensitive': False},
{'key': '三级类目', 'sensitive': False},
],
# 数据概览
'data_summary': [
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '店铺', 'sensitive': False},
{'key': '总数量', 'sensitive': False},
{'key': '总成本', 'sensitive': True},
{'key': '日期', 'sensitive': False},
{'key': '数据来源', 'sensitive': False},
],
# 数据明细
'data_detail': [
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '店铺', 'sensitive': False},
{'key': '一级类目', 'sensitive': False},
{'key': '二级类目', 'sensitive': False},
{'key': '三级类目', 'sensitive': False},
{'key': '货品数量', 'sensitive': False},
{'key': '货品成本', 'sensitive': True},
{'key': '日期', 'sensitive': False},
{'key': '数据来源', 'sensitive': False},
],
# 库存-成品
'inventory_finished': [
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '可用库存', 'sensitive': False},
{'key': '生产在途', 'sensitive': False},
{'key': '采购在途', 'sensitive': False},
{'key': '供应商', 'sensitive': False},
{'key': '库存日期', 'sensitive': False},
{'key': '采购跟单', 'sensitive': False},
{'key': '采购计划', 'sensitive': False},
],
# 库存-包材
'inventory_packaging': [
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '可用库存', 'sensitive': False},
{'key': '生产在途', 'sensitive': False},
{'key': '采购在途', 'sensitive': False},
{'key': '供应商', 'sensitive': False},
{'key': '库存日期', 'sensitive': False},
],
# 采购待下单
'purchase_pending': [
{'key': '产品类型', 'sensitive': False},
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '确认下单', 'sensitive': False},
{'key': '产品分级', 'sensitive': False},
{'key': '供应商名称', 'sensitive': False},
{'key': '建议下单数量', 'sensitive': False},
{'key': '正式下单数量', 'sensitive': False},
{'key': '下单备注', 'sensitive': False},
{'key': '货期', 'sensitive': False},
{'key': '物流', 'sensitive': False},
{'key': '总支撑天数', 'sensitive': False},
{'key': '平台', 'sensitive': False},
{'key': '运营', 'sensitive': False},
{'key': '审核价格', 'sensitive': True},
{'key': '下单人员', 'sensitive': False},
{'key': '下单时间', 'sensitive': False},
{'key': '采购跟单', 'sensitive': False},
{'key': '采购计划', 'sensitive': False},
{'key': '加入时间', 'sensitive': False},
],
# 采购已下单
'purchase_backup': [
{'key': '产品类型', 'sensitive': False},
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '下单时间', 'sensitive': False},
{'key': '下单人', 'sensitive': False},
{'key': '生产单号', 'sensitive': False},
{'key': '建议下单数量', 'sensitive': False},
{'key': '正式下单数量', 'sensitive': False},
{'key': '产品分级', 'sensitive': False},
{'key': '供应商名称', 'sensitive': False},
{'key': '下单备注', 'sensitive': False},
{'key': '货期', 'sensitive': False},
{'key': '物流', 'sensitive': False},
{'key': '总支撑天数', 'sensitive': False},
{'key': '采购跟单', 'sensitive': False},
{'key': '采购计划', 'sensitive': False},
{'key': '平台', 'sensitive': False},
{'key': '运营', 'sensitive': False},
{'key': '审核价格', 'sensitive': True},
],
# 国内订单
'order_domestic': [
{'key': '日期', 'sensitive': False},
{'key': '计划下单时间', 'sensitive': False},
{'key': '新旧品', 'sensitive': False},
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '货品类型', 'sensitive': False},
{'key': '下单备注', 'sensitive': False},
{'key': '单价', 'sensitive': True},
{'key': '审核价格', 'sensitive': True},
{'key': '数量', 'sensitive': False},
{'key': '金额', 'sensitive': True},
{'key': '供货商', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '金蝶采购单', 'sensitive': False},
{'key': '采购员', 'sensitive': False},
{'key': '下单人', 'sensitive': False},
{'key': '进度', 'sensitive': False},
{'key': '旺店通单号', 'sensitive': False},
{'key': '采购开发', 'sensitive': False},
{'key': '预计到货日期', 'sensitive': False},
{'key': '金蝶入库', 'sensitive': False},
],
# 海外订单
'order_overseas': [
{'key': '产品编码', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '货品类型', 'sensitive': False},
{'key': '日期', 'sensitive': False},
{'key': '计划下单时间', 'sensitive': False},
{'key': '单价', 'sensitive': True},
{'key': '审核价格', 'sensitive': True},
{'key': '数量', 'sensitive': False},
{'key': '金额', 'sensitive': True},
{'key': '供应商', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '新旧品', 'sensitive': False},
{'key': '下单备注', 'sensitive': False},
{'key': '采购员', 'sensitive': False},
{'key': '下单人', 'sensitive': False},
{'key': '进度', 'sensitive': False},
{'key': '旺店通单号', 'sensitive': False},
{'key': '采购开发', 'sensitive': False},
{'key': '预计到货日期', 'sensitive': False},
],
# 包材订单
'order_packaging': [
{'key': '日期', 'sensitive': False},
{'key': '计划下单时间', 'sensitive': False},
{'key': '产品编码', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '货品类型', 'sensitive': False},
{'key': '单价', 'sensitive': True},
{'key': '审核价格', 'sensitive': True},
{'key': '数量', 'sensitive': False},
{'key': '金额', 'sensitive': True},
{'key': '供应商', 'sensitive': False},
{'key': '成品供应商', 'sensitive': False},
{'key': '仓库', 'sensitive': False},
{'key': '新旧品', 'sensitive': False},
{'key': '采购员', 'sensitive': False},
{'key': '下单人', 'sensitive': False},
{'key': '进度', 'sensitive': False},
{'key': '下单备注', 'sensitive': False},
{'key': '采购开发', 'sensitive': False},
{'key': '旺店通单号', 'sensitive': False},
],
# 成品订单
'order_finished': [
{'key': '品类', 'sensitive': False},
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '货品类型', 'sensitive': False},
{'key': '供应商', 'sensitive': False},
{'key': '可用库存', 'sensitive': False},
{'key': '昨日销量', 'sensitive': False},
{'key': '3天均销', 'sensitive': False},
{'key': '7天均销', 'sensitive': False},
{'key': '15天均销', 'sensitive': False},
{'key': '支撑天数', 'sensitive': False},
{'key': '采购总数量', 'sensitive': False},
{'key': '生产在途', 'sensitive': False},
{'key': '采购在途', 'sensitive': False},
{'key': '产品进度', 'sensitive': False},
{'key': '加工厂库存', 'sensitive': False},
{'key': '跟单小组', 'sensitive': False},
{'key': '采购计划', 'sensitive': False},
{'key': '备注', 'sensitive': False},
],
# 产品基础信息
'products': [
{'key': '货品编号', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '货品类型', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '供应商编码', 'sensitive': False},
{'key': '供应商名称', 'sensitive': False},
{'key': '成本价', 'sensitive': True},
{'key': '货期', 'sensitive': False},
{'key': '物流时间', 'sensitive': False},
{'key': '最低起订量', 'sensitive': False},
{'key': '采购跟单', 'sensitive': False},
{'key': '采购计划', 'sensitive': False},
{'key': '平台', 'sensitive': False},
{'key': '运营', 'sensitive': False},
{'key': '开发专员', 'sensitive': False},
{'key': '创建时间', 'sensitive': False},
{'key': '导入日期', 'sensitive': False},
],
# 新品管理
'product_new': [
{'key': '产品编码', 'sensitive': False},
{'key': '产品名称', 'sensitive': False},
{'key': '品牌', 'sensitive': False},
{'key': '条形码', 'sensitive': False},
{'key': '容量', 'sensitive': False},
{'key': '成品备注', 'sensitive': False},
{'key': '产品总成本', 'sensitive': True},
{'key': '产品总货款', 'sensitive': True},
{'key': '是否含运', 'sensitive': False},
{'key': '产品开发', 'sensitive': False},
{'key': '包材开发', 'sensitive': False},
{'key': '设计师', 'sensitive': False},
{'key': '负责运营', 'sensitive': False},
],
# 新品包材管理(独立于新品成品管理;字段来自 new_product_bom)
'product_packaging': [
{'key': '成品编码', 'sensitive': False},
{'key': '成品名称', 'sensitive': False},
{'key': '物料编码', 'sensitive': False},
{'key': '物料名称', 'sensitive': False},
{'key': '用量', 'sensitive': False},
{'key': '供应商编码', 'sensitive': False},
{'key': '供应商', 'sensitive': False},
{'key': '货期/天', 'sensitive': False},
{'key': '税点', 'sensitive': True},
{'key': '是否含税', 'sensitive': False},
{'key': '单价/元', 'sensitive': True},
{'key': '数量', 'sensitive': False},
{'key': '货款/元', 'sensitive': True},
{'key': '结款方式', 'sensitive': False},
{'key': '是否可下单', 'sensitive': False},
{'key': '下单备注', 'sensitive': False},
{'key': '通用物料编码', 'sensitive': False},
{'key': '通用物料名称', 'sensitive': False},
{'key': '下单日期', 'sensitive': False},
],
# BOM
'bom': [
{'key': '成品编号', 'sensitive': False},
{'key': '成品名称', 'sensitive': False},
{'key': '物料编号', 'sensitive': False},
{'key': '物料名称', 'sensitive': False},
{'key': '用量', 'sensitive': False},
{'key': '供应商', 'sensitive': False},
{'key': '成本价', 'sensitive': True},
{'key': '创建时间', 'sensitive': False},
],
# 仓库
'warehouse': [
{'key': '仓库编号', 'sensitive': False},
{'key': '仓库名称', 'sensitive': False},
{'key': '仓库类型', 'sensitive': False},
{'key': '地址', 'sensitive': False},
{'key': '联系人', 'sensitive': False},
{'key': '联系电话', 'sensitive': False},
{'key': '创建时间', 'sensitive': False},
],
# 供应商
'suppliers': [
{'key': '供应商编码', 'sensitive': False},
{'key': '供应商名称', 'sensitive': False},
{'key': '联系人', 'sensitive': False},
{'key': '联系电话', 'sensitive': False},
{'key': '地址', 'sensitive': False},
{'key': '合作状态', 'sensitive': False},
{'key': '创建时间', 'sensitive': False},
],
# 现货销售
'spot_sales': [
{'key': '商家编码', 'sensitive': False},
{'key': '货品名称', 'sensitive': False},
{'key': '平台', 'sensitive': False},
{'key': '类目', 'sensitive': False},
{'key': '首批成本价', 'sensitive': True},
],
# AI 分析:金额类数据源单独控制(敏感,默认不开放)
'ai_analysis': [
{'key': '库存成本汇总', 'sensitive': True},
{'key': '商品库存金额', 'sensitive': True},
{'key': '品牌汇总', 'sensitive': True},
],
}
# 运行页面ID → 标准权限页面ID 回退映射
_FIELD_REGISTRY_ALIASES = {
'purchase': 'purchase_pending',
'purchase_ready': 'purchase_backup',
'orders_domestic': 'order_domestic',
'orders_overseas': 'order_overseas',
'orders_packaging': 'order_packaging',
'orders_finished': 'order_finished',
}
# 标准页面ID → 存储/历史别名集合(与 routes/auth.py 的 PERMISSION_PAGE_ALIASES 保持一致)
_PAGE_ALIASES = {
'purchase_pending': ['purchase_pending', 'purchase'],
'purchase_backup': ['purchase_backup', 'purchase_ready', 'purchase'],
'order_domestic': ['order_domestic', 'orders_domestic', 'orders'],
'order_overseas': ['order_overseas', 'orders_overseas', 'orders'],
'order_packaging': ['order_packaging', 'orders_packaging', 'orders'],
'order_finished': ['order_finished', 'orders_finished', 'orders'],
'data_summary': ['data_summary', 'data'],
'data_detail': ['data_detail', 'data'],
'inventory_finished': ['inventory_finished', 'inventory'],
'inventory_packaging': ['inventory_packaging', 'inventory'],
'product_new': ['product_new'],
'product_packaging': ['product_packaging'],
'spot_sales': ['spot_sales', 'spot'],
}
# 标准页面ID → 前端运行页面ID(注入前端时使用)
_RUNTIME_PAGE = {
'purchase_pending': 'purchase',
'purchase_backup': 'purchase_ready',
'order_domestic': 'orders_domestic',
'order_overseas': 'orders_overseas',
'order_packaging': 'orders_packaging',
'order_finished': 'orders_finished',
}
# 敏感字段的响应 key 映射(后端过滤时按实际返回字段名剔除)。
# 未列出的字段默认按 key(中文显示名)匹配响应字段。
FIELD_RESP_KEYS = {
# 采购待下单/已下单接口使用英文键名,统一映射到权限字段中文名。
('purchase_pending', '产品类型'): ['product_type'],
('purchase_pending', '货品编号'): ['product_code'],
('purchase_pending', '货品名称'): ['product_name'],
('purchase_pending', '产品分级'): ['product_grade'],
('purchase_pending', '供应商名称'): ['supplier_name'],
('purchase_pending', '建议下单数量'): ['suggested_qty'],
('purchase_pending', '正式下单数量'): ['formal_order_qty'],
('purchase_pending', '下单备注'): ['remark'],
('purchase_pending', '货期'): ['lead_time'],
('purchase_pending', '物流'): ['logistics_time'],
('purchase_pending', '总支撑天数'): ['total_support_days'],
('purchase_pending', '平台'): ['platform'],
('purchase_pending', '运营'): ['operator'],
('purchase_pending', '审核价格'): ['audit_price'],
('purchase_pending', '下单人员'): ['created_by'],
('purchase_pending', '下单时间'): ['created_at'],
('purchase_pending', '采购跟单'): ['procurement_follower'],
('purchase_pending', '采购计划'): ['procurement_planner'],
('purchase_pending', '加入时间'): ['created_at'],
('purchase_backup', '产品类型'): ['product_type'],
('purchase_backup', '货品编号'): ['product_code'],
('purchase_backup', '货品名称'): ['product_name'],
('purchase_backup', '供应商名称'): ['supplier_name'],
('purchase_backup', '建议下单数量'): ['suggested_qty'],
('purchase_backup', '正式下单数量'): ['formal_order_qty'],
('purchase_backup', '下单备注'): ['remark'],
('purchase_backup', '货期'): ['lead_time'],
('purchase_backup', '物流'): ['logistics_time'],
('purchase_backup', '总支撑天数'): ['total_support_days'],
('purchase_backup', '平台'): ['platform'],
('purchase_backup', '运营'): ['operator'],
('purchase_backup', '审核价格'): ['audit_price'],
('purchase_backup', '下单人员'): ['created_by'],
('purchase_backup', '下单时间'): ['ordered_at'],
('purchase_backup', '采购跟单'): ['procurement_follower'],
('purchase_backup', '采购计划'): ['procurement_planner'],
('purchase_backup', '生产单号'): ['production_order_no'],
# 包材订单接口字段与权限字段名称不同。
('order_packaging', '产品编码'): ['编码'],
('order_packaging', '货品名称'): ['名称'],
('order_packaging', '金额'): ['合计'],
('order_packaging', '供应商'): ['供应商'],
('order_packaging', '旺店通单号'): ['旺店通编号'],
('product_packaging', '成品编码'): ['finished_product_code'],
('product_packaging', '成品名称'): ['finished_product_name'],
('product_packaging', '物料编码'): ['material_code'],
('product_packaging', '物料名称'): ['material_name'],
('product_packaging', '供应商编码'): ['supplier_code'],
('product_packaging', '供应商'): ['supplier'],
('product_packaging', '货期/天'): ['lead_time'],
('product_packaging', '税点'): ['tax_rate'],
('product_packaging', '是否含税'): ['tax_included'],
('product_packaging', '单价/元'): ['unit_price'],
('product_packaging', '数量'): ['quantity'],
('product_packaging', '货款/元'): ['amount'],
('product_packaging', '结款方式'): ['payment_method'],
('product_packaging', '是否可下单'): ['can_order'],
('product_packaging', '下单备注'): ['remark'],
('product_packaging', '通用物料编码'): ['common_code'],
('product_packaging', '通用物料名称'): ['common_name'],
('product_packaging', '下单日期'): ['order_date'],
('planning', '成本价'): ['cost_price'],
('planning', '新旧品'): ['planning_new_old'],
('data_summary', '总成本'): ['total_cost'],
('products', '成本价'): ['cost_price'],
('product_new', '产品总成本'): ['total_cost'],
('product_new', '产品总货款'): ['total_payment'],
('product_new', '是否含运'): ['freight_included'],
('bom', '成本价'): ['cost_price'],
}
def _field_resp_keys(page, key):
"""返回某字段应剔除的响应 key 列表(含 key 本身)。"""
canonical = _canonical_page(page)
resp = FIELD_RESP_KEYS.get((canonical, key), [])
keys = [key] + [r for r in resp if r != key]
return keys
def _canonical_page(page):
return _FIELD_REGISTRY_ALIASES.get(page, page)
def _page_aliases(page):
canonical = _canonical_page(page)
return _PAGE_ALIASES.get(canonical, [canonical])
def _runtime_page(page):
canonical = _canonical_page(page)
return _RUNTIME_PAGE.get(canonical, canonical)
def get_field_list(page):
"""返回页面字段清单(list of {key, sensitive}),未注册的页面返回空列表。"""
return FIELD_REGISTRY.get(_canonical_page(page), [])
def is_admin_role(role):
return role in ('admin', 'super_admin')
def _load_field_config(user_id, role, page):
"""读取角色、岗位、用户三级字段权限配置。"""
aliases = _page_aliases(page)
placeholders = ','.join(['%s'] * len(aliases))
role_perm, position_perm, user_perm = {}, {}, {}
explicit = set()
role_rows = query(
f"SELECT operation, permission FROM role_operation_permissions "
f"WHERE role=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'",
tuple(['viewer' if role == '查看者' else role] + aliases)
)
for row in role_rows:
parts = row['operation'].split(':', 2)
if len(parts) == 3:
role_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission'])
explicit.add(parts[1])
ensure_position_permissions()
position = user_position_code(user_id)
if position:
position_rows = query(
f"SELECT operation, permission FROM position_operation_permissions "
f"WHERE position_code=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'",
tuple([position] + aliases)
)
for row in position_rows:
parts = row['operation'].split(':', 2)
if len(parts) == 3:
position_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission'])
explicit.add(parts[1])
user_rows = query(
f"SELECT operation, permission FROM user_permissions "
f"WHERE user_id=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'",
tuple([user_id] + aliases)
)
for row in user_rows:
parts = row['operation'].split(':', 2)
if len(parts) == 3:
user_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission'])
explicit.add(parts[1])
return role_perm, position_perm, user_perm, explicit
def _resolve_field_value(user_perm, position_perm, role_perm, field_key, operation, default_allow):
"""字段权限优先级:用户例外 > 岗位权限 > 角色权限 > 页面默认。"""
user_value = user_perm.get(field_key, {}).get(operation)
if user_value is not None:
return bool(user_value)
position_value = position_perm.get(field_key, {}).get(operation)
if position_value is not None:
return bool(position_value)
role_value = role_perm.get(field_key, {}).get(operation)
if role_value is not None:
return bool(role_value)
return bool(default_allow)
def field_perms_for_page(user_id, role, page, page_view=True, page_edit=False):
"""返回合并角色、岗位、用户后的字段权限。"""
fields = get_field_list(page)
if not fields:
return {}
if is_admin_role(role):
return {field['key']: {'view': True, 'edit': True} for field in fields}
role_perm, position_perm, user_perm, _ = _load_field_config(user_id, role, page)
result = {}
for field in fields:
key = field['key']
result[key] = {
'view': _resolve_field_value(user_perm, position_perm, role_perm, key, 'view', page_view),
'edit': _resolve_field_value(user_perm, position_perm, role_perm, key, 'edit', page_edit),
}
return result
def role_field_perms_for_page(role, page):
"""仅角色级字段权限,用于角色权限矩阵。"""
fields = get_field_list(page)
if not fields:
return {}
if is_admin_role(role):
return {field['key']: {'view': True, 'edit': True} for field in fields}
aliases = _page_aliases(page)
placeholders = ','.join(['%s'] * len(aliases))
role_perm = {}
rows = query(
f"SELECT operation, permission FROM role_operation_permissions "
f"WHERE role=%s AND page IN ({placeholders}) AND operation LIKE 'field:%%'",
tuple(['viewer' if role == '查看者' else role] + aliases)
)
for row in rows:
parts = row['operation'].split(':', 2)
if len(parts) == 3:
role_perm.setdefault(parts[1], {})[parts[2]] = int(row['permission'])
return {
field['key']: {
'view': _resolve_field_value({}, {}, role_perm, field['key'], 'view', True),
'edit': _resolve_field_value({}, {}, role_perm, field['key'], 'edit', False),
}
for field in fields
}
def inherited_field_perms_for_page(user_id, role, page):
"""角色+岗位的字段继承值,不包含用户个人覆盖。"""
fields = get_field_list(page)
if not fields:
return {}
if is_admin_role(role):
return {field['key']: {'view': True, 'edit': True} for field in fields}
role_perm, position_perm, _, _ = _load_field_config(user_id, role, page)
return {
field['key']: {
'view': _resolve_field_value({}, position_perm, role_perm, field['key'], 'view', True),
'edit': _resolve_field_value({}, position_perm, role_perm, field['key'], 'edit', False),
}
for field in fields
}
def filter_row(page, row, user_id, role, page_view=True, page_edit=False):
"""对单个 dict 按字段 view 权限剔除无权查看的字段(原地修改并返回)。"""
if row is None or is_admin_role(role):
return row
perms = field_perms_for_page(user_id, role, page, page_view, page_edit)
if not perms:
return row
for key in list(row.keys()):
# 响应字段可能用中文显示名,也可能用英文 DB 列名;统一按映射剔除
matched = None
for fkey, fperm in perms.items():
if key in _field_resp_keys(page, fkey):
matched = (fkey, fperm)
break
if matched and not matched[1]['view']:
del row[key]
return row
def filter_rows(page, rows, user_id, role, page_view=True, page_edit=False):
"""对 dict 列表按字段 view 权限剔除字段(原地修改并返回)。"""
if not rows or is_admin_role(role):
return rows
perms = field_perms_for_page(user_id, role, page, page_view, page_edit)
if not perms:
return rows
# 预先构建 响应key -> 是否可查看 的映射,避免逐行重复计算
resp_visibility = {}
for fkey, fperm in perms.items():
for rkey in _field_resp_keys(page, fkey):
resp_visibility[rkey] = fperm['view']
for row in rows:
if not isinstance(row, dict):
continue
for key in list(row.keys()):
if key in resp_visibility and not resp_visibility[key]:
del row[key]
return rows
def effective_field_overrides(user_id, role, page_view_by_page=None, page_edit_by_page=None):
"""返回需要注入前端的字段级权限显式值(用于敏感字段默认隐藏 + 显式配置)。
仅返回「敏感字段」和「有显式配置」的字段;其余字段由前端按页面权限默认继承。
返回结构:{runtime_page: {field_key: {'view': 0/1, 'edit': 0/1}}}。
"""
if is_admin_role(role):
return {}
page_view_by_page = page_view_by_page or {}
page_edit_by_page = page_edit_by_page or {}
overrides = {}
for page, fields in FIELD_REGISTRY.items():
role_perm, position_perm, user_perm, explicit = _load_field_config(user_id, role, page)
pv = page_view_by_page.get(page, True)
pe = page_edit_by_page.get(page, False)
page_overrides = {}
for f in fields:
key = f['key']
sensitive = bool(f.get('sensitive'))
if key not in explicit:
continue
page_overrides[key] = {
'view': 1 if _resolve_field_value(user_perm, position_perm, role_perm, key, 'view', pv) else 0,
'edit': 1 if _resolve_field_value(user_perm, position_perm, role_perm, key, 'edit', pe) else 0,
}
if page_overrides:
overrides[_runtime_page(page)] = page_overrides
return overrides

View File

@ -0,0 +1,10 @@
"""Manual new/old choice, independent of the calculated product-age field."""
from services.db import query
def snapshot_new_old(product_code, saved_value=None):
"""Preserve an existing pending snapshot during quantity/remark edits."""
if saved_value in ('是', '否'):
return saved_value
rows = query('SELECT planning_new_old FROM products WHERE product_code=%s', (product_code,))
return '是' if rows and rows[0].get('planning_new_old') == '是' else '否'

View File

@ -8322,7 +8322,8 @@ function loadProductBrandFilter(tab) {
if (!document.body.contains(sel) || !res || res.code !== 0) return;
var brands = (res.data && res.data.brands) || [];
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('');
});
}
@ -13004,6 +13005,7 @@ const PLAN_COLUMNS = [
{field: 'product_name', label: '货品名称', defaultShow: true},
{field: 'brand', label: '品牌', defaultShow: true},
{field: '批注', label: '批注', defaultShow: true},
{field: '新旧品', label: '新旧品', defaultShow: true},
{field: 'bom', label: '下单清单', defaultShow: true, filterable: false},
{field: '产品分级', label: '产品分级', defaultShow: true},
{field: '昨日销量', label: '昨日销量', defaultShow: true},
@ -13090,6 +13092,7 @@ const PLAN_FILTER_OPTIONS = {
'产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'],
'销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'],
'是否新品': ['新品', '返单品'],
'新旧品': ['是', '否'],
'风险把控': ['正常', '新品', '呆滞品', '需启动清库存'],
'波动风险': ['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) {
inlineEditClick(el);
};
@ -13824,6 +13870,9 @@ function renderPlanTable(rows) {
var cls = trendColors[raw] || 'tag-grey';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
}
if (c.field === '新旧品') {
return renderPlanNewOldSelect(r.product_code, raw);
}
if (c.field === '是否新品') {
return (raw === '新品') ? '<span class="tag tag-blue">新品</span>' : '<span class="tag tag-grey">返单品</span>';
}
@ -15158,9 +15207,15 @@ window.loadPlanScheme = function(schemeId) {
});
PLAN_COLUMNS.forEach(function(c) {
if (pendingVisible[c.field] === undefined) {
if (c.field === '新旧品') {
var bomIndex = newOrder.indexOf('bom');
newOrder.splice(bomIndex < 0 ? newOrder.length : bomIndex, 0, c.field);
pendingVisible[c.field] = true;
} else {
newOrder.push(c.field);
pendingVisible[c.field] = false;
}
}
});
// 更新临时勾选状态和拖拽顺序
@ -17533,6 +17588,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
var modalId = 'bom-modal-' + Date.now();
// 批注同步外面列表的批注内容(products.remark,与计划预测批注列同源)
var productRemark = (res.data.product && res.data.product['批注']) || '';
var productNewOld = (res.data.product && res.data.product.planning_new_old) || '否';
var productSummaryHtml =
'<div style="margin-bottom:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' +
'<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📦 成品下单信息</div>' +
@ -17554,6 +17610,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
'</table>' +
'</div>';
var bomContent = productSummaryHtml +
(window._canViewField('planning', '新旧品') ? '<div style="margin-bottom:12px">新旧品:' + renderPlanNewOldSelect(productCode, productNewOld) + '</div>' : '') +
'<div style="margin-top:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' +
'<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📝 下单备注</div>' +
'<textarea class="form-input bom-product-remark" autocomplete="off" style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:vertical;line-height:1.6" data-product="' + escHtml(productCode) + '" placeholder="下单备注"' + (window._canEditField && window._canEditField('planning','下单备注') ? '' : ' readonly style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:none;line-height:1.6;background:#f5f5f5;color:#999"') + '>' + escHtml(orderRemark || '') + '</textarea>' +
@ -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) {
var item = null;
_pendingItems.forEach(function(i) {
@ -18117,7 +18202,13 @@ function pendingDetail(productCode) {
var valHtml = escHtml(String(f.val));
if (f.editable) {
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) {
valHtml = '<span style="font-weight:600;color:#8c8c8c">' + valHtml + '</span>';
} else {
@ -18138,7 +18229,8 @@ function pendingDetail(productCode) {
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无包材物料清单</div>';
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 savedBomRemarks = {};
@ -18171,8 +18263,13 @@ function pendingDetail(productCode) {
'</tr></thead><tbody>';
bom.forEach(function(b) {
var matCode = escHtml(b.material_code || '');
var qty = b.bom_qty || 0;
var orderQty = Math.ceil(formalQty * qty);
var qty = Number(b.bom_qty) || 0;
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),没有保存则用建议值
var actualQty = b.material_code in savedBomActuals ? (savedBomActuals[b.material_code] || 0) : orderQty;
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" 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 + '"><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>' +
'</tr>';
});
@ -19392,25 +19489,13 @@ window.saveBackupProdNo = function(bid, code) {
inp.blur();
// 更新缓存数据
_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();
// 同步更新表格中的生产单号列
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('保存成功', '✅ 生产单号已保存');
showCenteredAlert('保存成功', '✅ ' + (res.msg || '生产单号已保存'));
} else {
showCenteredAlert('保存失败', res.msg || '生产单号保存失败');
inp.style.borderColor = '#f5222d';
setTimeout(function(){ inp.style.borderColor = ''; }, 1500);
}
@ -19519,12 +19604,12 @@ window.saveBackupAll = function(bid, code, saveBtn) {
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; }
var hasError = results.some(function(r) { return r.code !== 0; });
if (hasError) {
showCenteredAlert('保存失败', '❌ 部分数据保存失败,请重试');
showCenteredAlert('保存失败', '❌ ' + results.filter(function(r) { return r.code !== 0; }).map(function(r) { return r.msg || '部分数据保存失败'; }).join(';'));
} else {
// ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ----
var newProdNo = prodNoInp ? prodNoInp.value.trim() : '';
_bkItems().forEach(function(it) {
if (it.product_code === code) {
if (String(it.id) === String(bid)) {
// 更新生产单号
it.production_order_no = newProdNo;
// 更新包材采购单号(bom_items JSON内)
@ -19545,27 +19630,15 @@ window.saveBackupAll = function(bid, code, saveBtn) {
}
}
});
// 同步更新表格中的生产单号列
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 || '-';
}
}
});
}
_renderFilteredBackup();
// 后台刷新列表数据(用户下次查看时拉取最新)
if (typeof loadBackupList === 'function') {
loadBackupList();
loadBackupList(false);
}
_refreshLinkedOrderLists();
// 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据
var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay');
showCenteredAlert('保存成功', '✅ 生产单号、采购单号已保存');
showCenteredAlert('保存成功', '✅ ' + (prodNoInp && results[0] ? results[0].msg : '生产单号、采购单号已保存'));
if (detailOverlay) detailOverlay.remove();
}
}).catch(function() {
@ -19714,6 +19787,10 @@ function loadOrderFollowers() {
_orderFollowersPromise = fetch('/api/users/followers').then(function(r){return r.json()}).then(function(res){
if (res.code === 0) domesticFollowers = res.data || [];
return domesticFollowers;
}).catch(function() {
// 人员接口异常不能阻塞订单列表;下单人下拉框随后使用空列表降级展示。
domesticFollowers = [];
return domesticFollowers;
});
return _orderFollowersPromise;
}
@ -19751,6 +19828,9 @@ window.showSkuInfoModal = function(el, title) {
if (d.product_name) fields.push(['货品名称', d.product_name]);
if (d.brand) fields.push(['品牌', d.brand]);
if (d.product_type) fields.push(['产品类型', d.product_type]);
if (title && title.indexOf('海外') !== -1 && window._canViewField('orders_overseas', '新旧品')) {
fields.push(['新旧品', el.dataset.newOld || '-']);
}
if (d.product_grade) fields.push(['产品分级', d.product_grade]);
if (d.cost_price !== null && d.cost_price !== undefined && d.cost_price !== '') fields.push(['成本价', formatPrice4(d.cost_price, true)]);
if (d.supplier_name) fields.push(['供应商', d.supplier_name]);
@ -19764,6 +19844,7 @@ window.showSkuInfoModal = function(el, title) {
if (d.min_order_qty) fields.push(['最低起订量', d.min_order_qty]);
if (d.created_at) fields.push(['创建时间', d.created_at]);
var bomCount = (res.data.bom_materials || []).length;
var transitOrders = res.data.orders_in_transit || [];
if (bomCount > 0) fields.push(['关联包材', bomCount + ' 种']);
// 调试:记录API返回
@ -19772,51 +19853,62 @@ window.showSkuInfoModal = function(el, title) {
console.log('[SKU] ' + code + ' 在途订单: ' + _debugTransit.length + ' 条');
}
// 构建弹窗HTML
var html = '<div class="modal" style="width:650px;max-height:85vh;display:flex;flex-direction:column;border-radius:12px;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 += '<h3 style="margin:0;font-size:16px">📋 ' + escHtml(code) + '</h3>';
html += '<span class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()" style="font-size:22px;cursor:pointer;color:#999">&times;</span>';
html += '</div><div class="modal-body" style="padding:16px 20px;overflow:auto;flex:1">';
html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
fields.forEach(function(f, idx) {
var k = f[0], v = f[1] !== undefined && f[1] !== null ? String(f[1]) : '-';
if (idx % 2 === 0) html += '<tr>';
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 += '<td style="padding:7px 10px;border:1px solid #eee;color:#333;word-break:break-all">' + escHtml(v) + '</td>';
if (idx % 2 === 1) html += '</tr>';
});
if (fields.length % 2 === 1) html += '<td style="border:1px solid #eee"></td><td style="border:1px solid #eee"></td></tr>';
html += '</table>';
// 构建订单 SKU 信息弹窗:标题、摘要卡片、字段卡片和在途表格分层展示。
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 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 += '<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 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 += '<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">&times;</button>';
html += '</div><div class="modal-body" style="padding:20px 24px;overflow:auto;flex:1;background:#f5f7fa">';
html += '<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:18px">';
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>';
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 += '<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 += '</div>';
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>';
for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex += 2) {
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 += '<div style="margin-top:16px">';
html += '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:8px">📦 订单在途 (' + transitOrders.length + ')</div>';
html += '<table style="width:100%;border-collapse:collapse;font-size:12px">';
html += '<thead><tr style="background:#e6f7ff">';
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 += '<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 += '<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 += '<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="overflow-x:auto"><table class="sku-info-transit-table" style="width:100%;border-collapse:collapse;font-size:12px;min-width:680px">';
html += '<thead><tr style="background:#edf8f0;color:#376548">';
html += '<th style="padding:9px;border:1px solid #b9cdbf;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:9px;border:1px solid #b9cdbf;text-align:left;white-space:nowrap">名称</th>';
html += '<th style="padding:9px;border:1px solid #b9cdbf;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:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">采购计划</th>';
html += '</tr></thead><tbody>';
if (transitOrders.length > 0) {
transitOrders.forEach(function(o) {
html += '<tr>';
html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;white-space:nowrap">' + 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: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:4px 8px;border:1px solid #eee;text-align:center;font-weight:600">' + (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:4px 8px;border:1px solid #eee;text-align:center">' + escHtml(o['采购计划'] || '-') + '</td>';
transitOrders.forEach(function(o, transitIndex) {
html += '<tr style="background:' + (transitIndex % 2 === 0 ? '#ffffff' : '#f6fbf7') + '">';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap;color:#526273">' + escHtml(o['下单时间'] || '-') + '</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: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:9px;border:1px solid #b9cdbf;text-align:center;font-weight:700;color:#27633b">' + (parseInt(o['下单数量']) || 0).toLocaleString() + '</td>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center">' + escHtml(o['采购跟单'] || '-') + '</td>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center">' + escHtml(o['采购计划'] || '-') + '</td>';
html += '</tr>';
});
} 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 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 = [
{label:'预计到货日期', field:'预计到货日期', editable:true, type:'date'},
{label:'货品名称', field:'货品名称', editable:false},
{label:'新旧品', field:'新旧品', editable:false},
{label:'跟单下单日期', field:'日期', editable:true, type:'date'},
{label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'},
{label:'实际下单数量', field:'数量', editable:true},
@ -22130,8 +22223,9 @@ function loadOverseasOrdersPage(container) {
if (fieldAttr === '产品编码') thStyle = 'min-width:120px;position:sticky;left:90px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '货品名称') thStyle = 'min-width:200px;position:sticky;left:210px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '货品类型') thStyle = 'min-width:100px;position:sticky;left:410px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '日期') thStyle = 'width:85px;min-width:85px;position:sticky;left:510px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '计划下单时间' || fieldAttr === '新旧品') thStyle = 'width:1%;white-space:nowrap';
else if (fieldAttr === '新旧品') thStyle = 'width:80px;min-width:80px;position:sticky;left:510px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '日期') thStyle = 'width:85px;min-width:85px;position:sticky;left:590px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '计划下单时间') thStyle = 'width:1%;white-space:nowrap';
else if (fieldAttr === '单价') thStyle = 'min-width:70px';
else if (fieldAttr === '是否审核价格') thStyle = 'min-width:80px';
else if (fieldAttr === '数量') thStyle = 'min-width:60px';
@ -22304,13 +22398,13 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
order.forEach(function(field) {
var cellHtml = '';
if (field === '产品编码') {
cellHtml = '<td style="position:sticky;left:90px;z-index:2;background:var(--card-bg,#fff)"><code style="cursor:pointer;color:#1890ff" onclick="showSkuInfoModal(this,\'海外订单详情\')">' + escHtml(r['产品编码'] || '') + '</code></td>';
cellHtml = '<td style="position:sticky;left:90px;z-index:2;background:var(--card-bg,#fff)"><code data-new-old="' + escHtml(r['新旧品'] || '') + '" style="cursor:pointer;color:#1890ff" onclick="showSkuInfoModal(this,\'海外订单详情\')">' + escHtml(r['产品编码'] || '') + '</code></td>';
} else if (field === '货品名称') {
cellHtml = '<td class="order-product-name-cell" style="position:sticky;left:210px;z-index:2;background:var(--card-bg,#fff)"><span class="order-product-name-text" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</span></td>';
} else if (field === '货品类型') {
cellHtml = '<td style="position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['货品类型'] || '') + '</td>';
} else if (field === '日期') {
cellHtml = '<td style="position:sticky;left:510px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(window._padDateStr(r['日期'] || '')) + '</span></td>';
cellHtml = '<td style="position:sticky;left:590px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(window._padDateStr(r['日期'] || '')) + '</span></td>';
} else if (field === '计划下单时间') {
var planTime = window._formatPlanOrderTime(r['计划下单时间'] || '');
cellHtml = '<td>' + escHtml(planTime) + '</td>';
@ -22330,7 +22424,7 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
} else if (field === '仓库') {
cellHtml = '<td>' + escHtml(r['仓库'] || '') + '</td>';
} else if (field === '新旧品') {
cellHtml = '<td>' + escHtml(r['新旧品'] || '') + '</td>';
cellHtml = '<td style="position:sticky;left:510px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['新旧品'] || '') + '</td>';
} else if (field === '是否已做采购单') {
cellHtml = '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>';
} else if (field === '是否做入库') {
@ -22921,27 +23015,13 @@ function loadPackagingOrdersPage(container) {
renderOrderColCheckboxes('packaging');
}
packagingLoadFilters().then(function() {
// 列表是页面核心内容,必须立即加载,不能被筛选项、人员或分组接口阻塞。
// 辅助接口失败时仅降级对应下拉/标签,不再让表格永远停留在“加载中”。
packagingLoadOrders(1);
packagingLoadFilters();
loadOrderFollowers();
loadPersonnelGroupsForLinkedPages();
loadDefaultOrderScheme('packaging');
// 恢复上次的筛选状态(切页/刷新后保留搜索、筛选、表头筛选、页码)
var restoredPage = 1;
if (_restorePackagingFilterState()) {
restoredPage = packagingOrderPage || 1;
var _si = document.getElementById('packagingSearchInput');
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');
}
@ -22957,7 +23037,7 @@ function packagingLoadFilters() {
opt.value = s; opt.textContent = s;
so.appendChild(opt);
});
});
}).catch(function() { return null; });
}
function packagingDoSearch() {
@ -23010,9 +23090,10 @@ function packagingLoadOrders(page) { packagingOrderPage = page;
})
.then(function(r){return r.json()})
.then(function(res){
if (res.code !== 0) return;
if (res.code !== 0) throw new Error(res.msg || '包材订单加载失败');
var data = res.data;
var tbody = document.getElementById('packagingOrdersTbody');
if (!tbody) return;
if (document.getElementById('packagingTotalInfo'))
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');
}
}
}).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,type:'number'},
@ -24969,7 +25058,6 @@ const OVERSEAS_COLUMNS = [
{field:'金额',label:'金额',defShow:true,type:'number'},
{field:'供应商',label:'供应商名称',defShow:true},
{field:'仓库',label:'仓库',defShow:true},
{field:'新旧品',label:'新旧品',defShow:true},
{field:'是否已做采购单',label:'已做采购单',defShow:true},
{field:'是否做入库',label:'金蝶是否入库',defShow:true},
{field:'采购员',label:'跟单小组',defShow:true},
@ -25389,6 +25477,11 @@ window.loadOrderScheme = function(type, schemeId) {
pendingVisible[c.field] = false;
}
});
if (type === 'overseas') {
newOrder = newOrder.filter(function(field) { return field !== '新旧品'; });
var productTypeIndex = newOrder.indexOf('货品类型');
newOrder.splice(productTypeIndex < 0 ? newOrder.length : productTypeIndex + 1, 0, '新旧品');
}
window[dragVar] = newOrder.slice();
Object.keys(pendingVisible).forEach(function(field) {
window[pendingVar][field] = pendingVisible[field];
@ -27030,7 +27123,7 @@ window.exportBom = function() {
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
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 = {
'新旧品': '新旧品',
'product_type': '产品类型',
'产品种类': '产品种类',
'product_code': '货品编号',
@ -27252,6 +27346,7 @@ function planBatchOrder() {
}
showCenteredConfirm('确认', '确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?').then(function(ok) {
if (!ok) return;
if (planNewOldStillSaving(checkedCodes)) return;
// 清除待下单页面缓存
if (window._pageCache) delete window._pageCache['purchase'];
fetch('/api/purchase/batch_add_pending', {
@ -27463,6 +27558,7 @@ window.planOrder = function(data) {
alert('数据异常,无法下单');
return;
}
if (planNewOldStillSaving([data.product_code])) return;
// 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过)
var fqvSpan = document.getElementById('fqv-' + data.product_code);
if (fqvSpan) {
@ -27635,6 +27731,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) {
document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); };
document.getElementById('planOrderConfirmBtn').onclick = function() {
if (planNewOldStillSaving([data.product_code])) return;
modal.remove();
if (window._pageCache) delete window._pageCache['purchase'];
var orderData = JSON.parse(JSON.stringify(data));
@ -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
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
'新旧品',
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8'];
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
'\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',

662
templates/base.html Normal file
View 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">&#9662;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9662;</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">&#9679;</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">&#9679;</span>
<span>品牌信息管理</span>
</a>
<a href="/bom" class="nav-sub-item {% if page == 'bom' %}active{% endif %}" data-page="bom">
<span class="nav-sub-icon">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9662;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9662;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9662;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9662;</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9662;</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">&#9679;</span>
<span>用户管理</span>
</a>
<a href="/permissions" class="nav-sub-item {% if page == 'permissions' %}active{% endif %}" data-page="permissions">
<span class="nav-sub-icon">&#9679;</span>
<span>权限配置</span>
</a>
<a href="/config" class="nav-sub-item {% if page == 'config' %}active{% endif %}" data-page="config">
<span class="nav-sub-icon">&#9679;</span>
<span>参数配置</span>
</a>
<a href="/logs" class="nav-sub-item {% if page == 'logs' %}active{% endif %}" data-page="logs">
<span class="nav-sub-icon">&#9679;</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">&#9679;</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
View 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>

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

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

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

View File

@ -0,0 +1,121 @@
"""Exercise the manual flag across save, pending and overseas confirmation."""
import unittest
from unittest import mock
from flask import Flask
import routes.planning as planning
import routes.purchase as purchase
import services.field_perms as field_perms
import services.planning_order_flag as flags
class PlanningNewOldTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.app.secret_key = 'unit-test'
def test_save_rejects_invalid_choice_and_missing_permission(self):
for value, allowed, expected in [('新品', True, 400), ('是', False, 403)]:
with self.subTest(value=value, allowed=allowed), self.app.test_request_context(
json={'product_code': 'P1', 'value': value}
), mock.patch.object(planning, 'operation_required', return_value=allowed), \
mock.patch.object(field_perms, 'field_perms_for_page', return_value={'新旧品': {'edit': allowed}}), \
mock.patch.object(planning, 'query') as sql:
response, status = planning.planning_new_old_save.__wrapped__()
self.assertEqual(status, expected)
sql.assert_not_called()
def test_save_both_choices(self):
for value in ('是', '否'):
with self.subTest(value=value), self.app.test_request_context(
json={'product_code': 'P1', 'value': value}
), mock.patch.object(planning, 'operation_required', return_value=True), \
mock.patch.object(field_perms, 'field_perms_for_page', return_value={'新旧品': {'edit': True}}), \
mock.patch.object(planning, 'query', return_value=[{'planning_new_old': '否'}]) as sql, \
mock.patch.object(planning, 'add_operation_log'):
response = planning.planning_new_old_save.__wrapped__()
self.assertEqual(response.get_json()['code'], 0)
self.assertEqual(sql.call_args.args[1], (value, 'P1'))
def test_saved_snapshot_survives_later_planning_changes(self):
for value in ('是', '否'):
with mock.patch.object(flags, 'query') as sql:
self.assertEqual(flags.snapshot_new_old('P1', value), value)
sql.assert_not_called()
with mock.patch.object(flags, 'query', return_value=[{'planning_new_old': '是'}]):
self.assertEqual(flags.snapshot_new_old('P1'), '是')
def test_single_add_and_quantity_edit_capture_and_preserve_choice(self):
for existing, captured in [(None, '是'), ({'id': 5, 'bom_items': None, 'new_old_product': '否'}, '否')]:
writes = []
def sql(query, params=None):
if query.lstrip().startswith('SELECT id, bom_items'):
return [existing] if existing else []
if query.lstrip().startswith('SELECT'):
return []
self.assertEqual(query.count('%s'), len(params))
writes.append((query, params))
return 5
with self.subTest(existing=existing), self.app.test_request_context(json={
'product_code': 'P1', 'product_name': '商品', 'product_type': '成品', 'formal_order_qty': 10
}), mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
mock.patch.object(purchase, 'query', side_effect=sql), \
mock.patch.object(flags, 'query', return_value=[{'planning_new_old': '是'}]):
response = purchase.purchase_add_pending.__wrapped__()
self.assertEqual(response.get_json()['code'], 0)
params = writes[0][1]
self.assertEqual(params[-2] if existing else params[-1], captured)
def test_batch_add_captures_each_product_choice(self):
writes = []
def sql(query, params=None):
if 'SELECT p.product_code' in query:
return [{'product_code': 'P1', 'planning_new_old': '是'},
{'product_code': 'P2', 'planning_new_old': '否'}]
if query.lstrip().startswith('SELECT'):
return []
self.assertEqual(query.count('%s'), len(params))
writes.append(params)
return 1
with self.app.test_request_context(json={'product_codes': ['P1', 'P2']}), \
mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
mock.patch.object(purchase, 'query', side_effect=sql), \
mock.patch.object(purchase, 'add_operation_log'):
response = purchase.purchase_batch_add_pending.__wrapped__()
self.assertEqual(response.get_json()['data']['added'], 2)
self.assertEqual([params[-1] for params in writes], ['是', '否'])
def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self):
for value in ('是', '否'):
writes = []
def sql(query, params=None):
if 'SELECT product_code FROM pending_orders' in query:
return [{'product_code': 'P1'}]
if 'SELECT brand, product_name FROM products' in query:
return [{'brand': 'TEST', 'product_name': '商品'}]
if 'SELECT category FROM brand_information' in query:
return [{'category': '海外'}]
if 'SELECT * FROM pending_orders' in query:
return [{'id': 5, 'product_code': 'P1', 'product_name': '商品',
'formal_order_qty': 10, 'new_old_product': value}]
if query.lstrip().startswith('SELECT'):
return []
self.assertEqual(query.count('%s'), len(params))
writes.append((query, params))
return 1
with self.subTest(value=value), self.app.test_request_context(json={'product_codes': ['P1']}), \
mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
mock.patch.object(purchase, 'query', side_effect=sql), \
mock.patch.object(purchase, 'execute', return_value=1), \
mock.patch.object(purchase, '_resolve_follower_group', return_value=''), \
mock.patch.object(purchase, '_is_overseas_notify_product', return_value=False), \
mock.patch.object(flags, 'query') as current:
response = purchase.purchase_confirm_order.__wrapped__()
self.assertEqual(response.get_json()['code'], 0)
order = next(params for query, params in writes if 'INSERT INTO overseas_orders' in query)
self.assertEqual(order[-1], value)
current.assert_not_called()
if __name__ == '__main__':
unittest.main()

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

View File

@ -18,6 +18,7 @@ class ProductBrandFilterStaticTest(unittest.TestCase):
self.assertIn("loadProductBrandFilter('成品')", APP_JS)
self.assertIn('loadProductBrandFilter(tab)', APP_JS)
self.assertGreaterEqual(APP_JS.count("url += '&brand='"), 2)
self.assertIn("brand === '__EMPTY__' ? '(品牌为空)' : brand", APP_JS)
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.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):
with mock.patch.object(products, 'query', side_effect=[
[{'brand': '回收品牌'}],
[{'category1': '分类'}],
[{'cnt': 2}],
]) as query:
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])
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__':
unittest.main()

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