Compare commits

..

No commits in common. "aad91d223cc3107386549cc6e78dee94686c6dbd" and "8c229bc162a07d52b7aa813d91358d9bc29246ac" have entirely different histories.

16 changed files with 265 additions and 3864 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,7 +5,7 @@ import re
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template
from services.db import query, execute, add_operation_log, get_db from services.db import query, execute, add_operation_log
from services.field_perms import filter_rows as filter_field_rows from services.field_perms import filter_rows as filter_field_rows
from services.sales_calc import (compute_product_sales_cached, from services.sales_calc import (compute_product_sales_cached,
compute_product_sales_extended_cached) compute_product_sales_extended_cached)
@ -957,7 +957,7 @@ def purchase_add_pending():
is_update = bool(keys & update_fields) is_update = bool(keys & update_fields)
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。 # 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。
existing_pending = query("SELECT id, bom_items, new_old_product FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,)) existing_pending = query("SELECT id, bom_items FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
if existing_pending and not is_update: if existing_pending and not is_update:
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"}) return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
@ -1026,8 +1026,6 @@ def purchase_add_pending():
remark_val = data.get('remark', '') remark_val = data.get('remark', '')
audit_price = float(data.get('audit_price', 0)) audit_price = float(data.get('audit_price', 0))
from services.planning_order_flag import snapshot_new_old
new_old_product = snapshot_new_old(pc, existing_pending[0].get('new_old_product') if existing_pending else None)
# 已存在待下单行时按 id 更新,避免依赖 product_code 唯一索引; # 已存在待下单行时按 id 更新,避免依赖 product_code 唯一索引;
# 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。 # 不存在时才插入新行(同一商品可保留多条 ordered/rolled_back 历史)。
if existing_pending: if existing_pending:
@ -1037,14 +1035,13 @@ def purchase_add_pending():
lead_time=%s, logistics_time=%s, total_support_days=%s, lead_time=%s, logistics_time=%s, total_support_days=%s,
procurement_follower=%s, procurement_planner=%s, platform=%s, operator=%s, created_by=%s, procurement_follower=%s, procurement_planner=%s, platform=%s, operator=%s, created_by=%s,
audit_price=%s, formal_order_qty=%s, suggested_qty=%s, actual_order_qty=%s, bom_items=%s, remark=%s, audit_price=%s, formal_order_qty=%s, suggested_qty=%s, actual_order_qty=%s, bom_items=%s, remark=%s,
new_old_product=%s, status='pending', updated_at=NOW() status='pending', updated_at=NOW()
WHERE id=%s WHERE id=%s
""", ( """, (
pn, pt, pg, sn, pn, pt, pg, sn,
int(lt), int(lo), float(ts), int(lt), int(lo), float(ts),
pf, pp, pl, op, created_by, pf, pp, pl, op, created_by,
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val, audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val,
new_old_product,
existing_pending[0]['id'] existing_pending[0]['id']
)) ))
else: else:
@ -1053,15 +1050,15 @@ def purchase_add_pending():
(product_code, product_name, product_type, product_grade, supplier_name, (product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by, procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, remark, new_old_product) 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, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", ( """, (
pc, pc,
pn, pt, pg, sn, pn, pt, pg, sn,
int(lt), int(lo), float(ts), int(lt), int(lo), float(ts),
pf, pp, pl, op, pf, pp, pl, op,
created_by, created_by,
audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val, new_old_product audit_price, formal_qty, suggested_qty, actual_order_qty, bom_json, remark_val
)) ))
if req_bom_items and bom_items_normalized: if req_bom_items and bom_items_normalized:
_sync_pending_pre_packaging(pc, bom_items_normalized) _sync_pending_pre_packaging(pc, bom_items_normalized)
@ -1297,13 +1294,10 @@ def purchase_confirm_order():
has_product_order = True has_product_order = True
if is_overseas and qty > 0: if is_overseas and qty > 0:
from services.planning_order_flag import snapshot_new_old
new_old_product = snapshot_new_old(pc, row.get('new_old_product'))
query('UPDATE pending_orders SET new_old_product=%s WHERE id=%s', (new_old_product, row['id']))
# 写入海外订单表(含全部字段,日期留空由用户自行填写) # 写入海外订单表(含全部字段,日期留空由用户自行填写)
query("""INSERT INTO overseas_orders query("""INSERT INTO overseas_orders
(日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号,新旧品) (日期,计划下单时间,产品编码,状态,product_type,product_grade,lead_time,logistics_time,total_support_days,procurement_planner,platform,operator,单价,audit_price,是否审核价格,suggested_qty,actual_order_qty,数量,金额,供应商,货品名称,采购员,product_name,下单备注,旺店通生产单号)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", ( """, (
None, now_str, None, now_str,
row.get('product_code'), '未完结', row.get('product_code'), '未完结',
@ -1327,8 +1321,7 @@ def purchase_confirm_order():
row.get('procurement_follower'), row.get('procurement_follower'),
row.get('product_name', ''), row.get('product_name', ''),
row.get('remark', ''), row.get('remark', ''),
row.get('production_order_no', ''), row.get('production_order_no', '')
new_old_product
)) ))
elif not is_overseas and qty > 0: elif not is_overseas and qty > 0:
previous_order = _latest_domestic_order_values(row.get('product_code')) previous_order = _latest_domestic_order_values(row.get('product_code'))
@ -1617,7 +1610,7 @@ def purchase_batch_add_pending():
products = query(f""" products = query(f"""
SELECT p.product_code, p.product_name, p.product_type, SELECT p.product_code, p.product_name, p.product_type,
p.lead_time, p.logistics_time, p.supplier_name_match AS supplier_name, p.lead_time, p.logistics_time, p.supplier_name_match AS supplier_name,
p.procurement_follower, p.procurement_planner, p.platform, p.operator, p.planning_new_old p.procurement_follower, p.procurement_planner, p.platform, p.operator
FROM products p WHERE p.product_code IN ({placeholders}) FROM products p WHERE p.product_code IN ({placeholders})
""", product_codes) """, product_codes)
@ -1645,15 +1638,14 @@ def purchase_batch_add_pending():
(product_code, product_name, product_type, product_grade, supplier_name, (product_code, product_name, product_type, product_grade, supplier_name,
lead_time, logistics_time, total_support_days, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, procurement_follower, procurement_planner, platform, operator,
audit_price, formal_order_qty, bom_items, new_old_product) audit_price, formal_order_qty, bom_items)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", ( """, (
pc, p.get('product_name', ''), p.get('product_type', ''), '', pc, p.get('product_name', ''), p.get('product_type', ''), '',
p.get('supplier_name', ''), int(p.get('lead_time', 0) or 0), p.get('supplier_name', ''), int(p.get('lead_time', 0) or 0),
int(p.get('logistics_time', 0) or 0), 0, int(p.get('logistics_time', 0) or 0), 0,
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '', p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '',
p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data, p.get('platform', '') or '', p.get('operator', '') or '', 0, 0, bom_data
p.get('planning_new_old') or '否'
)) ))
added += 1 added += 1
@ -3803,15 +3795,14 @@ def purchase_rollback_order():
lead_time, logistics_time, total_support_days, lead_time, logistics_time, total_support_days,
procurement_follower, procurement_planner, platform, operator, created_by, procurement_follower, procurement_planner, platform, operator, created_by,
audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items, audit_price, formal_order_qty, suggested_qty, actual_order_qty, bom_items,
production_order_no, remark, new_old_product, status, created_at, updated_at, ordered_at) 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, %s, 'pending', NOW(), NOW(), NULL) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'pending', NOW(), NOW(), NULL)
""", ( """, (
row.get('product_code'), row.get('product_name'), row.get('product_type'), row.get('product_grade'), row.get('product_code'), row.get('product_name'), row.get('product_type'), row.get('product_grade'),
row.get('supplier_name'), row.get('lead_time'), row.get('logistics_time'), row.get('total_support_days'), row.get('supplier_name'), row.get('lead_time'), row.get('logistics_time'), row.get('total_support_days'),
row.get('procurement_follower'), row.get('procurement_planner'), row.get('platform'), row.get('operator'), row.get('procurement_follower'), row.get('procurement_planner'), row.get('platform'), row.get('operator'),
row.get('created_by'), row.get('audit_price'), row.get('formal_order_qty'), row.get('suggested_qty'), row.get('created_by'), row.get('audit_price'), row.get('formal_order_qty'), row.get('suggested_qty'),
row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark'), row.get('actual_order_qty'), row.get('bom_items'), row.get('production_order_no'), row.get('remark')
row.get('new_old_product')
)) ))
pending_check = query( pending_check = query(
"SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1", "SELECT id FROM pending_orders WHERE id=%s AND product_code=%s AND status='pending' LIMIT 1",
@ -3879,8 +3870,7 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
其次按传入 ordered_at,最后回退到该商品最近一次下单。 其次按传入 ordered_at,最后回退到该商品最近一次下单。
""" """
if backup_id: if backup_id:
rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND product_code=%s AND status='ordered'", rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,))
(backup_id, product_code))
elif ordered_at: elif ordered_at:
return str(ordered_at)[:19] return str(ordered_at)[:19]
else: else:
@ -3894,97 +3884,38 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19] return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19]
def _backup_linked_order_ids(read, table, product_code, ordered_at):
"""Prefer the exact order time; date-only legacy rows need a unique source."""
code_field, time_field = {
'domestic_orders': ('货品编号', '计划下单时间'),
'overseas_orders': ('产品编码', '计划下单时间'),
'packaging_orders': ('来源产品编码', '下单时间'),
}[table]
rows = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND `{time_field}`=%s',
(product_code, ordered_at))
if rows:
return [row['id'] for row in rows]
day = ordered_at[:10]
legacy = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND TRIM(`{time_field}`)=%s',
(product_code, day))
if not legacy:
return []
sources = read(
"SELECT id FROM pending_orders WHERE product_code=%s "
"AND status IN ('ordered','rolled_back') "
"AND ordered_at >= %s AND ordered_at < DATE_ADD(%s, INTERVAL 1 DAY)",
(product_code, day, day),
)
# 成品订单须唯一;同一备份可以生成多条包材订单。
if len(sources) != 1 or (table != 'packaging_orders' and len(legacy) != 1):
raise ValueError('同一商品当天有多笔记录,日期不足以确定对应订单,请核对计划下单时间')
return [row['id'] for row in legacy]
@purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST']) @purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST'])
@login_required @login_required
def backup_update_prod_no(): def backup_update_prod_no():
denied = _require_purchase_operation('purchase_ready', 'edit') denied = _require_purchase_operation('purchase_ready', 'edit')
if denied: if denied:
return denied return denied
"""Save the backup and uniquely linked order numbers in one transaction.""" """Update only order rows created by this exact backup order time."""
data = request.get_json(silent=True) or {} data = request.json
product_code = data.get('product_code', '') product_code = data.get('product_code', '')
prod_no = data.get('production_order_no', '') prod_no = data.get('production_order_no', '')
backup_id = data.get('backup_id') backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '') ordered_at = data.get('ordered_at', '')
if not product_code: if not product_code:
return jsonify({"code": 400, "msg": "参数错误"}) return jsonify({"code": 400, "msg": "参数错误"})
conn = get_db() ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at)
counts = {} if not ordered_at:
try: return jsonify({"code": 404, "msg": "未找到对应下单时间"})
conn.begin()
with conn.cursor() as cur:
def read(sql, params):
cur.execute(sql, params)
return cur.fetchall()
if backup_id: if backup_id:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE id=%s " query("""UPDATE pending_orders SET production_order_no=%s WHERE id=%s AND status='ordered'""",
"AND product_code=%s AND status='ordered' FOR UPDATE", (backup_id, product_code)) (prod_no, backup_id))
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: else:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s " query("""UPDATE pending_orders SET production_order_no=%s
"AND status='ordered' ORDER BY ordered_at DESC,id DESC LIMIT 1 FOR UPDATE", (product_code,)) WHERE product_code=%s AND status='ordered' AND ordered_at=%s""",
if len(backups) != 1 or not backups[0].get('ordered_at'): (prod_no, product_code, ordered_at))
conn.rollback() query("""UPDATE domestic_orders SET `旺店通生产单号`=%s
return jsonify({"code": 404, "msg": "未找到唯一对应的下单备份"}) WHERE `货品编号`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
backup = backups[0] query("""UPDATE overseas_orders SET `旺店通生产单号`=%s
time = backup['ordered_at'] WHERE `产品编码`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at))
ordered_at = time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(time, 'strftime') else str(time)[:19] query("""UPDATE packaging_orders SET `生产单号`=%s
targets = {table: _backup_linked_order_ids(read, table, product_code, ordered_at) WHERE `来源产品编码`=%s AND `下单时间`=%s""", (prod_no, product_code, ordered_at))
for table in ('domestic_orders', 'overseas_orders', 'packaging_orders')}
cur.execute('UPDATE pending_orders SET production_order_no=%s WHERE id=%s', (prod_no, backup['id']))
for table, ids in targets.items():
counts[table] = len(ids)
if ids:
field = '生产单号' if table == 'packaging_orders' else '旺店通生产单号'
placeholders = ','.join(['%s'] * len(ids))
cur.execute(f'UPDATE {table} SET `{field}`=%s WHERE id IN ({placeholders})',
(prod_no, *ids))
conn.commit()
except ValueError as exc:
conn.rollback()
return jsonify({"code": 409, "msg": str(exc)})
except Exception:
conn.rollback()
raise
finally:
conn.close()
add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}') add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}')
msg = '生产单号已保存,已同步对应订单' return jsonify({"code": 0, "msg": "保存成功"})
if not any(counts.values()):
msg = '生产单号已保存,但未找到可同步的订单,请核对订单记录'
return jsonify({"code": 0, "msg": msg, "data": {"synced": counts}})
@purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST']) @purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST'])

View File

@ -1,28 +0,0 @@
"""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()

View File

@ -1,717 +0,0 @@
"""字段级权限:字段清单单一来源 + 权限解析 + 后端强制过滤。
设计目标(对应「字段级权限」优化):
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

@ -1,10 +0,0 @@
"""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,8 +8322,7 @@ function loadProductBrandFilter(tab) {
if (!document.body.contains(sel) || !res || res.code !== 0) return; if (!document.body.contains(sel) || !res || res.code !== 0) return;
var brands = (res.data && res.data.brands) || []; var brands = (res.data && res.data.brands) || [];
sel.innerHTML = '<option value="">全部品牌</option>' + brands.map(function(brand) { sel.innerHTML = '<option value="">全部品牌</option>' + brands.map(function(brand) {
var label = brand === '__EMPTY__' ? '(品牌为空)' : brand; return '<option value="' + escHtml(brand) + '">' + escHtml(brand) + '</option>';
return '<option value="' + escHtml(brand) + '">' + escHtml(label) + '</option>';
}).join(''); }).join('');
}); });
} }
@ -13005,7 +13004,6 @@ const PLAN_COLUMNS = [
{field: 'product_name', label: '货品名称', defaultShow: true}, {field: 'product_name', label: '货品名称', defaultShow: true},
{field: 'brand', label: '品牌', defaultShow: true}, {field: 'brand', label: '品牌', defaultShow: true},
{field: '批注', label: '批注', defaultShow: true}, {field: '批注', label: '批注', defaultShow: true},
{field: '新旧品', label: '新旧品', defaultShow: true},
{field: 'bom', label: '下单清单', defaultShow: true, filterable: false}, {field: 'bom', label: '下单清单', defaultShow: true, filterable: false},
{field: '产品分级', label: '产品分级', defaultShow: true}, {field: '产品分级', label: '产品分级', defaultShow: true},
{field: '昨日销量', label: '昨日销量', defaultShow: true}, {field: '昨日销量', label: '昨日销量', defaultShow: true},
@ -13092,7 +13090,6 @@ const PLAN_FILTER_OPTIONS = {
'产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'], '产品分级': ['S级', 'A级', 'B级', 'C级', '不参与评级'],
'销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'], '销售趋势': ['开始上升', '开始下跌', '平稳趋势', '上升趋稳', '下跌趋稳', '持续上升', '持续下跌', '急增', '急跌', '近期无销售'],
'是否新品': ['新品', '返单品'], '是否新品': ['新品', '返单品'],
'新旧品': ['是', '否'],
'风险把控': ['正常', '新品', '呆滞品', '需启动清库存'], '风险把控': ['正常', '新品', '呆滞品', '需启动清库存'],
'波动风险': ['X', 'Y', 'Z'], '波动风险': ['X', 'Y', 'Z'],
'极值判断': ['正常', '极大', '极小', '极大/极小'], '极值判断': ['正常', '极大', '极小', '极大/极小'],
@ -13154,49 +13151,6 @@ if (!window._planColDragOrder) {
} }
// 行内编辑功能 // 行内编辑功能
function renderPlanNewOldSelect(code, value) {
var selected = value === '是' ? '是' : '否';
var editable = window._canEditField && window._canEditField('planning', '新旧品');
return '<select class="form-input plan-new-old-select" aria-label="新旧品" style="width:70px;padding:3px 6px" data-code="' + escHtml(code) + '" data-original="' + selected + '" onchange="savePlanNewOld(this)"' + (editable ? '' : ' disabled') + '><option value="是"' + (selected === '是' ? ' selected' : '') + '>是</option><option value="否"' + (selected === '否' ? ' selected' : '') + '>否</option></select>';
}
window.savePlanNewOld = function(input) {
var code = input.dataset.code, value = input.value, previous = input.dataset.original;
if (!window._planNewOldSaving) window._planNewOldSaving = {};
window._planNewOldSaving[code] = true;
document.querySelectorAll('.plan-new-old-select').forEach(function(el) {
if (el.dataset.code === code) el.disabled = true;
});
return fetch('/api/planning/new-old/save', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({product_code: code, value: value})
}).then(function(r) { return r.json(); }).then(function(res) {
if (res.code !== 0) throw new Error(res.msg || '保存失败');
if (window._planAllRows && window._planAllRows[code]) {
window._planAllRows[code]['新旧品'] = value;
window._planAllRows[code].planning_new_old = value;
}
document.querySelectorAll('.plan-new-old-select').forEach(function(el) {
if (el.dataset.code === code) { el.value = value; el.dataset.original = value; }
});
if (window._pageCache) delete window._pageCache.planning;
}).catch(function(err) {
input.value = previous;
showCenteredAlert('保存失败', err.message || '请检查网络后重试');
}).finally(function() {
delete window._planNewOldSaving[code];
document.querySelectorAll('.plan-new-old-select').forEach(function(el) {
if (el.dataset.code === code) el.disabled = !window._canEditField('planning', '新旧品');
});
});
};
function planNewOldStillSaving(codes) {
if (!codes.some(function(code) { return window._planNewOldSaving && window._planNewOldSaving[code]; })) return false;
showCenteredAlert('正在保存', '新旧品选择正在保存,请稍后再下单');
return true;
}
window.prodInlineEditClick = function(el) { window.prodInlineEditClick = function(el) {
inlineEditClick(el); inlineEditClick(el);
}; };
@ -13870,9 +13824,6 @@ function renderPlanTable(rows) {
var cls = trendColors[raw] || 'tag-grey'; var cls = trendColors[raw] || 'tag-grey';
return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>'; return '<span class="tag ' + cls + '">' + (raw || '-') + '</span>';
} }
if (c.field === '新旧品') {
return renderPlanNewOldSelect(r.product_code, raw);
}
if (c.field === '是否新品') { if (c.field === '是否新品') {
return (raw === '新品') ? '<span class="tag tag-blue">新品</span>' : '<span class="tag tag-grey">返单品</span>'; return (raw === '新品') ? '<span class="tag tag-blue">新品</span>' : '<span class="tag tag-grey">返单品</span>';
} }
@ -15207,15 +15158,9 @@ window.loadPlanScheme = function(schemeId) {
}); });
PLAN_COLUMNS.forEach(function(c) { PLAN_COLUMNS.forEach(function(c) {
if (pendingVisible[c.field] === undefined) { if (pendingVisible[c.field] === undefined) {
if (c.field === '新旧品') {
var bomIndex = newOrder.indexOf('bom');
newOrder.splice(bomIndex < 0 ? newOrder.length : bomIndex, 0, c.field);
pendingVisible[c.field] = true;
} else {
newOrder.push(c.field); newOrder.push(c.field);
pendingVisible[c.field] = false; pendingVisible[c.field] = false;
} }
}
}); });
// 更新临时勾选状态和拖拽顺序 // 更新临时勾选状态和拖拽顺序
@ -17588,7 +17533,6 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
var modalId = 'bom-modal-' + Date.now(); var modalId = 'bom-modal-' + Date.now();
// 批注同步外面列表的批注内容(products.remark,与计划预测批注列同源) // 批注同步外面列表的批注内容(products.remark,与计划预测批注列同源)
var productRemark = (res.data.product && res.data.product['批注']) || ''; var productRemark = (res.data.product && res.data.product['批注']) || '';
var productNewOld = (res.data.product && res.data.product.planning_new_old) || '否';
var productSummaryHtml = var productSummaryHtml =
'<div style="margin-bottom:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' + '<div style="margin-bottom:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' +
'<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📦 成品下单信息</div>' + '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📦 成品下单信息</div>' +
@ -17610,7 +17554,6 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
'</table>' + '</table>' +
'</div>'; '</div>';
var bomContent = productSummaryHtml + var bomContent = productSummaryHtml +
(window._canViewField('planning', '新旧品') ? '<div style="margin-bottom:12px">新旧品:' + renderPlanNewOldSelect(productCode, productNewOld) + '</div>' : '') +
'<div style="margin-top:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' + '<div style="margin-top:16px;background:#fafafa;border-radius:8px;padding:12px 16px">' +
'<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📝 下单备注</div>' + '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:10px">📝 下单备注</div>' +
'<textarea class="form-input bom-product-remark" autocomplete="off" style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:vertical;line-height:1.6" data-product="' + escHtml(productCode) + '" placeholder="下单备注"' + (window._canEditField && window._canEditField('planning','下单备注') ? '' : ' readonly style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:none;line-height:1.6;background:#f5f5f5;color:#999"') + '>' + escHtml(orderRemark || '') + '</textarea>' + '<textarea class="form-input bom-product-remark" autocomplete="off" style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:vertical;line-height:1.6" data-product="' + escHtml(productCode) + '" placeholder="下单备注"' + (window._canEditField && window._canEditField('planning','下单备注') ? '' : ' readonly style="width:100%;box-sizing:border-box;font-family:Consolas,Menlo,monospace;font-size:13px;min-height:80px;resize:none;line-height:1.6;background:#f5f5f5;color:#999"') + '>' + escHtml(orderRemark || '') + '</textarea>' +
@ -18100,34 +18043,6 @@ function updateFormalQty(productCode, val) {
}); });
} }
// 待下单详情:在不改变原输入框宽度的情况下,完整查看成品/包材下单备注。
window.openPendingRemarkViewer = function(trigger, title) {
var wrapper = trigger && trigger.closest ? trigger.closest('.pending-remark-field') : null;
var input = wrapper ? wrapper.querySelector('input, textarea') : null;
var remark = input ? input.value : '';
var oldViewer = document.getElementById('pendingRemarkViewer');
if (oldViewer) oldViewer.remove();
var overlay = document.createElement('div');
overlay.id = 'pendingRemarkViewer';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100002;display:flex;align-items:center;justify-content:center;padding:20px';
overlay.innerHTML =
'<div role="dialog" aria-modal="true" aria-labelledby="pendingRemarkViewerTitle" style="background:#fff;border-radius:10px;width:620px;max-width:92vw;box-shadow:0 10px 36px rgba(0,0,0,.2);overflow:hidden">' +
'<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid #eee">' +
'<h3 id="pendingRemarkViewerTitle" style="margin:0;font-size:16px">' + escHtml(title || '下单备注') + '</h3>' +
'<button type="button" aria-label="关闭" style="border:0;background:none;color:#888;font-size:22px;line-height:1;cursor:pointer;padding:2px 5px" onclick="document.getElementById(\'pendingRemarkViewer\').remove()">×</button>' +
'</div>' +
'<div style="padding:18px">' +
'<textarea readonly aria-label="完整备注内容" style="width:100%;min-height:240px;max-height:55vh;box-sizing:border-box;padding:12px;border:1px solid #d9d9d9;border-radius:6px;background:#fafafa;color:#333;font-size:14px;line-height:1.7;resize:vertical;white-space:pre-wrap" placeholder="暂无备注内容"></textarea>' +
'</div>' +
'<div style="padding:0 18px 16px;text-align:right"><button type="button" class="btn" onclick="document.getElementById(\'pendingRemarkViewer\').remove()">关闭</button></div>' +
'</div>';
overlay.querySelector('textarea').value = remark;
overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
document.body.appendChild(overlay);
overlay.querySelector('textarea').focus();
};
function pendingDetail(productCode) { function pendingDetail(productCode) {
var item = null; var item = null;
_pendingItems.forEach(function(i) { _pendingItems.forEach(function(i) {
@ -18202,13 +18117,7 @@ function pendingDetail(productCode) {
var valHtml = escHtml(String(f.val)); var valHtml = escHtml(String(f.val));
if (f.editable) { if (f.editable) {
var _extraCls = (f.label === '实际下单数量') ? ' pend-product-actual-qty' : ''; var _extraCls = (f.label === '实际下单数量') ? ' pend-product-actual-qty' : '';
var _isRemark = f.label === '下单备注'; 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 _inputStyle = 'width:100%;box-sizing:border-box;padding:3px ' + (_isRemark ? '30px' : '6px') + ' 3px 6px;font-size:13px';
valHtml = '<input type="text" class="form-input' + _extraCls + '" style="' + _inputStyle + '" value="' + valHtml + '" onchange="updatePendingProdField(\'' + item.product_code + '\',\'' + f.label + '\',this.value)">';
if (_isRemark) {
valHtml = '<span class="pending-remark-field" style="display:block;position:relative;width:100%">' + valHtml +
'<button type="button" aria-label="查看完整成品下单备注" title="查看全部" onclick="openPendingRemarkViewer(this,\'成品下单备注\')" style="position:absolute;right:4px;bottom:3px;width:21px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:14px;cursor:pointer">⛶</button></span>';
}
} else if (f.readonly) { } else if (f.readonly) {
valHtml = '<span style="font-weight:600;color:#8c8c8c">' + valHtml + '</span>'; valHtml = '<span style="font-weight:600;color:#8c8c8c">' + valHtml + '</span>';
} else { } else {
@ -18229,8 +18138,7 @@ function pendingDetail(productCode) {
container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无包材物料清单</div>'; container.innerHTML = '<div style="text-align:center;padding:24px;color:var(--text-light)">暂无包材物料清单</div>';
return; return;
} }
var productSuggestedQty = Number(item.suggested_qty) || 0; var formalQty = item.formal_order_qty || 0;
var productInTransit = (Number(p['生产在途']) || 0) + (Number(p['采购在途']) || 0);
// 读取已保存的包材实际下单数量 // 读取已保存的包材实际下单数量
var savedBomActuals = {}; var savedBomActuals = {};
var savedBomRemarks = {}; var savedBomRemarks = {};
@ -18263,13 +18171,8 @@ function pendingDetail(productCode) {
'</tr></thead><tbody>'; '</tr></thead><tbody>';
bom.forEach(function(b) { bom.forEach(function(b) {
var matCode = escHtml(b.material_code || ''); var matCode = escHtml(b.material_code || '');
var qty = Number(b.bom_qty) || 0; var qty = b.bom_qty || 0;
var orderQty = Math.max(0, Math.ceil( var orderQty = Math.ceil(formalQty * qty);
(productSuggestedQty - productInTransit) * qty
- (Number(b.available_qty) || 0)
- (Number(b.production_in_transit) || 0)
- (Number(b.purchase_in_transit) || 0)
));
// 使用已保存的实际下单数量(包括0),没有保存则用建议值 // 使用已保存的实际下单数量(包括0),没有保存则用建议值
var actualQty = b.material_code in savedBomActuals ? (savedBomActuals[b.material_code] || 0) : orderQty; var actualQty = b.material_code in savedBomActuals ? (savedBomActuals[b.material_code] || 0) : orderQty;
tableHtml += '<tr>' + tableHtml += '<tr>' +
@ -18280,7 +18183,7 @@ function pendingDetail(productCode) {
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-col="actual_qty"></td>' + '<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600;color:#d4380d" value="' + actualQty + '" data-material="' + matCode + '" data-col="actual_qty"></td>' +
'<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px" value="' + (b.pre_pack_qty || 0) + '" data-material="' + matCode + '" data-col="pre_pack"></td>' + '<td style="' + tdBase + '"><input type="number" class="form-input pend-detail-editable" style="width:70px;text-align:center;font-size:12px;padding:3px 4px" value="' + (b.pre_pack_qty || 0) + '" data-material="' + matCode + '" data-col="pre_pack"></td>' +
'<td style="' + tdBase + '"><span class="bom-post-pack-qty" style="display:inline-block;width:70px;text-align:center;font-size:12px;padding:3px 4px;color:#666;background:#f5f5f5" data-material="' + matCode + '">' + (b.post_pack_qty || 0) + '</span></td>' + '<td style="' + tdBase + '"><span class="bom-post-pack-qty" style="display:inline-block;width:70px;text-align:center;font-size:12px;padding:3px 4px;color:#666;background:#f5f5f5" data-material="' + matCode + '">' + (b.post_pack_qty || 0) + '</span></td>' +
'<td style="' + tdBase + '"><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 + '"><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 + '"><select class="form-input bom-manuscript pend-detail-editable" style="width:50px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600" data-material="' + matCode + '" data-col="manuscript" onchange="this.style.color=this.value===\'是\' ? \'#f5222d\' : \'#333\'"><option value="">否</option><option value="是">是</option></select></td>' + '<td style="' + tdBase + '"><select class="form-input bom-manuscript pend-detail-editable" style="width:50px;text-align:center;font-size:12px;padding:3px 4px;font-weight:600" data-material="' + matCode + '" data-col="manuscript" onchange="this.style.color=this.value===\'是\' ? \'#f5222d\' : \'#333\'"><option value="">否</option><option value="是">是</option></select></td>' +
'</tr>'; '</tr>';
}); });
@ -19489,13 +19392,25 @@ window.saveBackupProdNo = function(bid, code) {
inp.blur(); inp.blur();
// 更新缓存数据 // 更新缓存数据
_bkItems().forEach(function(it) { _bkItems().forEach(function(it) {
if (String(it.id) === String(bid)) it.production_order_no = val; if (it.product_code === code) it.production_order_no = val;
}); });
_renderFilteredBackup();
_refreshLinkedOrderLists(); _refreshLinkedOrderLists();
showCenteredAlert('保存成功', '✅ ' + (res.msg || '生产单号已保存')); // 同步更新表格中的生产单号列
var tbody = _bkEl('Tbody');
if (tbody) {
tbody.querySelectorAll('tr').forEach(function(tr) {
var codeEl = tr.querySelector('code');
if (codeEl && codeEl.textContent.trim() === code) {
var tds = tr.querySelectorAll('td');
// 生产单号是第7列(序号0,产品类型1,货品编号2,货品名称3,下单时间4,下单人5,生产单号6)
if (tds.length > 6) {
tds[6].textContent = val || '-';
}
}
});
}
showCenteredAlert('保存成功', '✅ 生产单号已保存');
} else { } else {
showCenteredAlert('保存失败', res.msg || '生产单号保存失败');
inp.style.borderColor = '#f5222d'; inp.style.borderColor = '#f5222d';
setTimeout(function(){ inp.style.borderColor = ''; }, 1500); setTimeout(function(){ inp.style.borderColor = ''; }, 1500);
} }
@ -19604,12 +19519,12 @@ window.saveBackupAll = function(bid, code, saveBtn) {
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; } if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; }
var hasError = results.some(function(r) { return r.code !== 0; }); var hasError = results.some(function(r) { return r.code !== 0; });
if (hasError) { if (hasError) {
showCenteredAlert('保存失败', '❌ ' + results.filter(function(r) { return r.code !== 0; }).map(function(r) { return r.msg || '部分数据保存失败'; }).join(';')); showCenteredAlert('保存失败', '❌ 部分数据保存失败,请重试');
} else { } else {
// ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ---- // ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ----
var newProdNo = prodNoInp ? prodNoInp.value.trim() : ''; var newProdNo = prodNoInp ? prodNoInp.value.trim() : '';
_bkItems().forEach(function(it) { _bkItems().forEach(function(it) {
if (String(it.id) === String(bid)) { if (it.product_code === code) {
// 更新生产单号 // 更新生产单号
it.production_order_no = newProdNo; it.production_order_no = newProdNo;
// 更新包材采购单号(bom_items JSON内) // 更新包材采购单号(bom_items JSON内)
@ -19630,15 +19545,27 @@ window.saveBackupAll = function(bid, code, saveBtn) {
} }
} }
}); });
_renderFilteredBackup(); // 同步更新表格中的生产单号列
var tbody = _bkEl('Tbody');
if (tbody) {
tbody.querySelectorAll('tr').forEach(function(tr) {
var codeEl = tr.querySelector('code');
if (codeEl && codeEl.textContent.trim() === code) {
var tds = tr.querySelectorAll('td');
if (tds.length > 6) {
tds[6].textContent = newProdNo || '-';
}
}
});
}
// 后台刷新列表数据(用户下次查看时拉取最新) // 后台刷新列表数据(用户下次查看时拉取最新)
if (typeof loadBackupList === 'function') { if (typeof loadBackupList === 'function') {
loadBackupList(false); loadBackupList();
} }
_refreshLinkedOrderLists(); _refreshLinkedOrderLists();
// 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据 // 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据
var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay'); var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay');
showCenteredAlert('保存成功', '✅ ' + (prodNoInp && results[0] ? results[0].msg : '生产单号、采购单号已保存')); showCenteredAlert('保存成功', '✅ 生产单号、采购单号已保存');
if (detailOverlay) detailOverlay.remove(); if (detailOverlay) detailOverlay.remove();
} }
}).catch(function() { }).catch(function() {
@ -19787,10 +19714,6 @@ function loadOrderFollowers() {
_orderFollowersPromise = fetch('/api/users/followers').then(function(r){return r.json()}).then(function(res){ _orderFollowersPromise = fetch('/api/users/followers').then(function(r){return r.json()}).then(function(res){
if (res.code === 0) domesticFollowers = res.data || []; if (res.code === 0) domesticFollowers = res.data || [];
return domesticFollowers; return domesticFollowers;
}).catch(function() {
// 人员接口异常不能阻塞订单列表;下单人下拉框随后使用空列表降级展示。
domesticFollowers = [];
return domesticFollowers;
}); });
return _orderFollowersPromise; return _orderFollowersPromise;
} }
@ -19828,9 +19751,6 @@ window.showSkuInfoModal = function(el, title) {
if (d.product_name) fields.push(['货品名称', d.product_name]); if (d.product_name) fields.push(['货品名称', d.product_name]);
if (d.brand) fields.push(['品牌', d.brand]); if (d.brand) fields.push(['品牌', d.brand]);
if (d.product_type) fields.push(['产品类型', d.product_type]); if (d.product_type) fields.push(['产品类型', d.product_type]);
if (title && title.indexOf('海外') !== -1 && window._canViewField('orders_overseas', '新旧品')) {
fields.push(['新旧品', el.dataset.newOld || '-']);
}
if (d.product_grade) fields.push(['产品分级', d.product_grade]); if (d.product_grade) fields.push(['产品分级', d.product_grade]);
if (d.cost_price !== null && d.cost_price !== undefined && d.cost_price !== '') fields.push(['成本价', formatPrice4(d.cost_price, true)]); if (d.cost_price !== null && d.cost_price !== undefined && d.cost_price !== '') fields.push(['成本价', formatPrice4(d.cost_price, true)]);
if (d.supplier_name) fields.push(['供应商', d.supplier_name]); if (d.supplier_name) fields.push(['供应商', d.supplier_name]);
@ -19844,7 +19764,6 @@ window.showSkuInfoModal = function(el, title) {
if (d.min_order_qty) fields.push(['最低起订量', d.min_order_qty]); if (d.min_order_qty) fields.push(['最低起订量', d.min_order_qty]);
if (d.created_at) fields.push(['创建时间', d.created_at]); if (d.created_at) fields.push(['创建时间', d.created_at]);
var bomCount = (res.data.bom_materials || []).length; var bomCount = (res.data.bom_materials || []).length;
var transitOrders = res.data.orders_in_transit || [];
if (bomCount > 0) fields.push(['关联包材', bomCount + ' 种']); if (bomCount > 0) fields.push(['关联包材', bomCount + ' 种']);
// 调试:记录API返回 // 调试:记录API返回
@ -19853,62 +19772,51 @@ window.showSkuInfoModal = function(el, title) {
console.log('[SKU] ' + code + ' 在途订单: ' + _debugTransit.length + ' 条'); console.log('[SKU] ' + code + ' 在途订单: ' + _debugTransit.length + ' 条');
} }
// 构建订单 SKU 信息弹窗:标题、摘要卡片、字段卡片和在途表格分层展示。 // 构建弹窗HTML
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">'; var html = '<div class="modal" style="width:650px;max-height:85vh;display:flex;flex-direction:column;border-radius:12px;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 class="modal-header" style="padding:14px 20px;border-bottom:1px solid #eee;display:flex;align-items:center;justify-content:space-between;background:#fafafa">';
html += '<div style="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 += '<h3 style="margin:0;font-size:16px">📋 ' + escHtml(code) + '</h3>';
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 += '<span class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()" style="font-size:22px;cursor:pointer;color:#999">&times;</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:16px 20px;overflow:auto;flex:1">';
html += '</div><div class="modal-body" style="padding:20px 24px;overflow:auto;flex:1;background:#f5f7fa">'; html += '<table style="width:100%;border-collapse:collapse;font-size:13px">';
html += '<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:18px">'; fields.forEach(function(f, idx) {
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>'; var k = f[0], v = f[1] !== undefined && f[1] !== null ? String(f[1]) : '-';
html += '<div style="padding:12px 14px;background:#fff;border:1px solid #e8edf3;border-radius:9px"><div style="font-size:12px;color:#8c9bab">订单在途</div><strong style="display:block;margin-top:5px;font-size:18px;color:#13a673">' + transitOrders.length + ' <small style="font-size:12px;font-weight:400;color:#8c9bab">笔</small></strong></div>'; if (idx % 2 === 0) html += '<tr>';
html += '<div style="padding:12px 14px;background:#fff;border:1px solid #e8edf3;border-radius:9px"><div style="font-size:12px;color:#8c9bab">产品类型</div><strong style="display:block;margin-top:5px;font-size:15px;color:#334155;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + escHtml(d.product_type || '-') + '</strong></div>'; html += '<td style="padding:7px 10px;border:1px solid #eee;font-size:12px;color:#888;background:#fafafa;white-space:nowrap;width:150px">' + escHtml(k) + '</td>';
html += '</div>'; html += '<td style="padding:7px 10px;border:1px solid #eee;color:#333;word-break:break-all">' + escHtml(v) + '</td>';
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">'; if (idx % 2 === 1) html += '</tr>';
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) { if (fields.length % 2 === 1) html += '<td style="border:1px solid #eee"></td><td style="border:1px solid #eee"></td></tr>';
var leftField = fields[fieldIndex]; html += '</table>';
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>';
// 订单在途列表 // 订单在途列表
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>'; var transitOrders = res.data.orders_in_transit || [];
html += '<div style="overflow-x:auto"><table class="sku-info-transit-table" style="width:100%;border-collapse:collapse;font-size:12px;min-width:680px">'; html += '<div style="margin-top:16px">';
html += '<thead><tr style="background:#edf8f0;color:#376548">'; html += '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:8px">📦 订单在途 (' + transitOrders.length + ')</div>';
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">下单时间</th>'; html += '<table style="width:100%;border-collapse:collapse;font-size:12px">';
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">编号</th>'; html += '<thead><tr style="background:#e6f7ff">';
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:left;white-space:nowrap">名称</th>'; html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">下单时间</th>';
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">下单数量</th>'; html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">编号</th>';
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">采购跟单</th>'; html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">名称</th>';
html += '<th style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap">采购计划</th>'; html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">下单数量</th>';
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">采购跟单</th>';
html += '<th style="padding:5px 8px;border:1px solid #d9d9d9;text-align:center;white-space:nowrap">采购计划</th>';
html += '</tr></thead><tbody>'; html += '</tr></thead><tbody>';
if (transitOrders.length > 0) { if (transitOrders.length > 0) {
transitOrders.forEach(function(o, transitIndex) { transitOrders.forEach(function(o) {
html += '<tr style="background:' + (transitIndex % 2 === 0 ? '#ffffff' : '#f6fbf7') + '">'; html += '<tr>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center;white-space:nowrap;color:#526273">' + escHtml(o['下单时间'] || '-') + '</td>'; html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;white-space:nowrap">' + 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:4px 8px;border:1px solid #eee;text-align:center"><code style="font-size:11px">' + escHtml(o['编码'] || '') + '</code></td>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:left;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escHtml(o['名称'] || '') + '">' + escHtml(o['名称'] || '-') + '</td>'; html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;max-width:180px;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(o['名称'] || '') + '">' + escHtml(o['名称'] || '-') + '</td>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center;font-weight:700;color:#27633b">' + (parseInt(o['下单数量']) || 0).toLocaleString() + '</td>'; html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center;font-weight:600">' + (parseInt(o['下单数量']) || 0).toLocaleString() + '</td>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center">' + escHtml(o['采购跟单'] || '-') + '</td>'; html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center">' + escHtml(o['采购跟单'] || '-') + '</td>';
html += '<td style="padding:9px;border:1px solid #b9cdbf;text-align:center">' + escHtml(o['采购计划'] || '-') + '</td>'; html += '<td style="padding:4px 8px;border:1px solid #eee;text-align:center">' + escHtml(o['采购计划'] || '-') + '</td>';
html += '</tr>'; html += '</tr>';
}); });
} else { } else {
html += '<tr><td colspan="6" style="padding:24px 8px;border:1px solid #b9cdbf;text-align:center;color:#8ca397;background:#fbfdfb">暂无在途订单</td></tr>'; 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 += '</tbody></table></div></section>'; html += '</tbody></table></div>';
html += '</div>'; html += '</div>';
html += '<div class="modal-footer" style="padding:10px 20px;border-top:1px solid #eee;text-align:right;background:#fafafa">'; html += '<div class="modal-footer" style="padding:10px 20px;border-top:1px solid #eee;text-align:right;background:#fafafa">';
@ -19968,7 +19876,6 @@ function showOrderDetail(encodedStr, title) {
col2 = [ col2 = [
{label:'预计到货日期', field:'预计到货日期', editable:true, type:'date'}, {label:'预计到货日期', field:'预计到货日期', editable:true, type:'date'},
{label:'货品名称', field:'货品名称', editable:false}, {label:'货品名称', field:'货品名称', editable:false},
{label:'新旧品', field:'新旧品', editable:false},
{label:'跟单下单日期', field:'日期', editable:true, type:'date'}, {label:'跟单下单日期', field:'日期', editable:true, type:'date'},
{label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'}, {label:'计划下单日期', field:'计划下单时间', editable:true, type:'date'},
{label:'实际下单数量', field:'数量', editable:true}, {label:'实际下单数量', field:'数量', editable:true},
@ -22223,9 +22130,8 @@ function loadOverseasOrdersPage(container) {
if (fieldAttr === '产品编码') thStyle = 'min-width:120px;position:sticky;left:90px;z-index:3;background:var(--bg,#f5f5f5)'; if (fieldAttr === '产品编码') thStyle = 'min-width:120px;position:sticky;left:90px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '货品名称') thStyle = 'min-width:200px;position:sticky;left:210px;z-index:3;background:var(--bg,#f5f5f5)'; else if (fieldAttr === '货品名称') thStyle = 'min-width:200px;position:sticky;left:210px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '货品类型') thStyle = 'min-width:100px;position:sticky;left:410px;z-index:3;background:var(--bg,#f5f5f5)'; else if (fieldAttr === '货品类型') thStyle = 'min-width:100px;position:sticky;left:410px;z-index:3;background:var(--bg,#f5f5f5)';
else if (fieldAttr === '新旧品') thStyle = 'width: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: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 === '计划下单时间' || fieldAttr === '新旧品') thStyle = 'width:1%;white-space:nowrap';
else if (fieldAttr === '计划下单时间') thStyle = 'width:1%;white-space:nowrap';
else if (fieldAttr === '单价') thStyle = 'min-width:70px'; else if (fieldAttr === '单价') thStyle = 'min-width:70px';
else if (fieldAttr === '是否审核价格') thStyle = 'min-width:80px'; else if (fieldAttr === '是否审核价格') thStyle = 'min-width:80px';
else if (fieldAttr === '数量') thStyle = 'min-width:60px'; else if (fieldAttr === '数量') thStyle = 'min-width:60px';
@ -22398,13 +22304,13 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
order.forEach(function(field) { order.forEach(function(field) {
var cellHtml = ''; var cellHtml = '';
if (field === '产品编码') { if (field === '产品编码') {
cellHtml = '<td style="position:sticky;left:90px;z-index:2;background:var(--card-bg,#fff)"><code data-new-old="' + escHtml(r['新旧品'] || '') + '" 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 style="cursor:pointer;color:#1890ff" onclick="showSkuInfoModal(this,\'海外订单详情\')">' + escHtml(r['产品编码'] || '') + '</code></td>';
} else if (field === '货品名称') { } else if (field === '货品名称') {
cellHtml = '<td class="order-product-name-cell" style="position:sticky;left:210px;z-index:2;background:var(--card-bg,#fff)"><span class="order-product-name-text" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</span></td>'; cellHtml = '<td class="order-product-name-cell" style="position:sticky;left:210px;z-index:2;background:var(--card-bg,#fff)"><span class="order-product-name-text" title="' + escHtml(r['货品名称'] || '') + '">' + escHtml(r['货品名称'] || '') + '</span></td>';
} else if (field === '货品类型') { } else if (field === '货品类型') {
cellHtml = '<td style="position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['货品类型'] || '') + '</td>'; cellHtml = '<td style="position:sticky;left:410px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['货品类型'] || '') + '</td>';
} else if (field === '日期') { } else if (field === '日期') {
cellHtml = '<td style="position:sticky;left:590px;z-index:2;background:var(--card-bg,#fff)"><span class="date-text">' + escHtml(window._padDateStr(r['日期'] || '')) + '</span></td>'; 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>';
} else if (field === '计划下单时间') { } else if (field === '计划下单时间') {
var planTime = window._formatPlanOrderTime(r['计划下单时间'] || ''); var planTime = window._formatPlanOrderTime(r['计划下单时间'] || '');
cellHtml = '<td>' + escHtml(planTime) + '</td>'; cellHtml = '<td>' + escHtml(planTime) + '</td>';
@ -22424,7 +22330,7 @@ function overseasLoadOrders(page) { overseasOrderPage = page;
} else if (field === '仓库') { } else if (field === '仓库') {
cellHtml = '<td>' + escHtml(r['仓库'] || '') + '</td>'; cellHtml = '<td>' + escHtml(r['仓库'] || '') + '</td>';
} else if (field === '新旧品') { } else if (field === '新旧品') {
cellHtml = '<td style="position:sticky;left:510px;z-index:2;background:var(--card-bg,#fff)">' + escHtml(r['新旧品'] || '') + '</td>'; cellHtml = '<td>' + escHtml(r['新旧品'] || '') + '</td>';
} else if (field === '是否已做采购单') { } else if (field === '是否已做采购单') {
cellHtml = '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>'; cellHtml = '<td>' + escHtml(r['是否已做采购单'] || '') + '</td>';
} else if (field === '是否做入库') { } else if (field === '是否做入库') {
@ -23015,13 +22921,27 @@ function loadPackagingOrdersPage(container) {
renderOrderColCheckboxes('packaging'); renderOrderColCheckboxes('packaging');
} }
// 列表是页面核心内容,必须立即加载,不能被筛选项、人员或分组接口阻塞。 packagingLoadFilters().then(function() {
// 辅助接口失败时仅降级对应下拉/标签,不再让表格永远停留在“加载中”。
packagingLoadOrders(1);
packagingLoadFilters();
loadOrderFollowers();
loadPersonnelGroupsForLinkedPages();
loadDefaultOrderScheme('packaging'); 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'); prefetchOrderColumnValues('packaging');
} }
@ -23037,7 +22957,7 @@ function packagingLoadFilters() {
opt.value = s; opt.textContent = s; opt.value = s; opt.textContent = s;
so.appendChild(opt); so.appendChild(opt);
}); });
}).catch(function() { return null; }); });
} }
function packagingDoSearch() { function packagingDoSearch() {
@ -23090,10 +23010,9 @@ function packagingLoadOrders(page) { packagingOrderPage = page;
}) })
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(res){ .then(function(res){
if (res.code !== 0) throw new Error(res.msg || '包材订单加载失败'); if (res.code !== 0) return;
var data = res.data; var data = res.data;
var tbody = document.getElementById('packagingOrdersTbody'); var tbody = document.getElementById('packagingOrdersTbody');
if (!tbody) return;
if (document.getElementById('packagingTotalInfo')) if (document.getElementById('packagingTotalInfo'))
document.getElementById('packagingTotalInfo').textContent = '共 ' + data.total + ' 条'; document.getElementById('packagingTotalInfo').textContent = '共 ' + data.total + ' 条';
@ -23199,13 +23118,6 @@ function packagingLoadOrders(page) { packagingOrderPage = page;
renderPaginationWithSize(pel, page, data.total || 0, PACKAGING_PAGE_SIZE, 'packagingLoadOrders', 'PACKAGING_PAGE_SIZE'); renderPaginationWithSize(pel, page, data.total || 0, PACKAGING_PAGE_SIZE, 'packagingLoadOrders', 'PACKAGING_PAGE_SIZE');
} }
} }
}).catch(function(error) {
var tbody = document.getElementById('packagingOrdersTbody');
if (tbody) {
tbody.innerHTML = '<tr><td colspan="22" style="text-align:center;padding:50px;color:#8c8c8c">' +
'<div style="margin-bottom:10px;color:#f5222d">加载失败:' + escHtml(error.message || '网络异常') + '</div>' +
'<button class="btn btn-sm btn-primary" onclick="packagingLoadOrders(' + (page || 1) + ')">重新加载</button></td></tr>';
}
}); });
} }
@ -25049,7 +24961,6 @@ const OVERSEAS_COLUMNS = [
{field:'产品编码',label:'货品编号',defShow:true}, {field:'产品编码',label:'货品编号',defShow:true},
{field:'货品名称',label:'货品名称',defShow:true}, {field:'货品名称',label:'货品名称',defShow:true},
{field:'货品类型',label:'货品类型',defShow:true}, {field:'货品类型',label:'货品类型',defShow:true},
{field:'新旧品',label:'新旧品',defShow:true},
{field:'日期',label:'跟单下单日期',defShow:true}, {field:'日期',label:'跟单下单日期',defShow:true},
{field:'计划下单时间',label:'计划下单日期',defShow:true}, {field:'计划下单时间',label:'计划下单日期',defShow:true},
{field:'单价',label:'单价',defShow:true,type:'number'}, {field:'单价',label:'单价',defShow:true,type:'number'},
@ -25058,6 +24969,7 @@ const OVERSEAS_COLUMNS = [
{field:'金额',label:'金额',defShow:true,type:'number'}, {field:'金额',label:'金额',defShow:true,type:'number'},
{field:'供应商',label:'供应商名称',defShow:true}, {field:'供应商',label:'供应商名称',defShow:true},
{field:'仓库',label:'仓库',defShow:true}, {field:'仓库',label:'仓库',defShow:true},
{field:'新旧品',label:'新旧品',defShow:true},
{field:'是否已做采购单',label:'已做采购单',defShow:true}, {field:'是否已做采购单',label:'已做采购单',defShow:true},
{field:'是否做入库',label:'金蝶是否入库',defShow:true}, {field:'是否做入库',label:'金蝶是否入库',defShow:true},
{field:'采购员',label:'跟单小组',defShow:true}, {field:'采购员',label:'跟单小组',defShow:true},
@ -25477,11 +25389,6 @@ window.loadOrderScheme = function(type, schemeId) {
pendingVisible[c.field] = false; pendingVisible[c.field] = false;
} }
}); });
if (type === 'overseas') {
newOrder = newOrder.filter(function(field) { return field !== '新旧品'; });
var productTypeIndex = newOrder.indexOf('货品类型');
newOrder.splice(productTypeIndex < 0 ? newOrder.length : productTypeIndex + 1, 0, '新旧品');
}
window[dragVar] = newOrder.slice(); window[dragVar] = newOrder.slice();
Object.keys(pendingVisible).forEach(function(field) { Object.keys(pendingVisible).forEach(function(field) {
window[pendingVar][field] = pendingVisible[field]; window[pendingVar][field] = pendingVisible[field];
@ -27123,7 +27030,7 @@ window.exportBom = function() {
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)─── // ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
var POPUP_VISIBLE_FIELDS = [ var POPUP_VISIBLE_FIELDS = [
'product_type', '产品种类', 'product_code', 'product_name', '新旧品', '产品分级', 'product_type', '产品种类', 'product_code', 'product_name', '产品分级',
'昨日销量', '销售趋势', '昨日销量', '销售趋势',
'库存', '加工厂库存', '生产在途', '采购在途', '库存', '加工厂库存', '生产在途', '采购在途',
'日销量预测', '日销量预测',
@ -27138,7 +27045,6 @@ var POPUP_VISIBLE_FIELDS = [
/** 字段显示名称映射 */ /** 字段显示名称映射 */
var POPUP_FIELD_LABELS = { var POPUP_FIELD_LABELS = {
'新旧品': '新旧品',
'product_type': '产品类型', 'product_type': '产品类型',
'产品种类': '产品种类', '产品种类': '产品种类',
'product_code': '货品编号', 'product_code': '货品编号',
@ -27346,7 +27252,6 @@ function planBatchOrder() {
} }
showCenteredConfirm('确认', '确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?').then(function(ok) { showCenteredConfirm('确认', '确认将选中的 ' + checkedCodes.length + ' 个商品加入待下单列表?').then(function(ok) {
if (!ok) return; if (!ok) return;
if (planNewOldStillSaving(checkedCodes)) return;
// 清除待下单页面缓存 // 清除待下单页面缓存
if (window._pageCache) delete window._pageCache['purchase']; if (window._pageCache) delete window._pageCache['purchase'];
fetch('/api/purchase/batch_add_pending', { fetch('/api/purchase/batch_add_pending', {
@ -27558,7 +27463,6 @@ window.planOrder = function(data) {
alert('数据异常,无法下单'); alert('数据异常,无法下单');
return; return;
} }
if (planNewOldStillSaving([data.product_code])) return;
// 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过) // 从页面读取当前最新的正式下单数量(用户可能已通过 ✏️ 编辑修改过)
var fqvSpan = document.getElementById('fqv-' + data.product_code); var fqvSpan = document.getElementById('fqv-' + data.product_code);
if (fqvSpan) { if (fqvSpan) {
@ -27731,7 +27635,6 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) {
document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); }; document.getElementById('planOrderCancelBtn').onclick = function() { modal.remove(); };
document.getElementById('planOrderConfirmBtn').onclick = function() { document.getElementById('planOrderConfirmBtn').onclick = function() {
if (planNewOldStillSaving([data.product_code])) return;
modal.remove(); modal.remove();
if (window._pageCache) delete window._pageCache['purchase']; if (window._pageCache) delete window._pageCache['purchase'];
var orderData = JSON.parse(JSON.stringify(data)); var orderData = JSON.parse(JSON.stringify(data));
@ -27817,7 +27720,6 @@ window.showProductDetail = function(productCode) {
// \u5206\u7ec4\u5b9a\u4e49\uff1a\u57fa\u7840\u4fe1\u606f\uff082\u5217\uff09/ \u9500\u91cf\u4fe1\u606f\uff084\u5217\uff09 // \u5206\u7ec4\u5b9a\u4e49\uff1a\u57fa\u7840\u4fe1\u606f\uff082\u5217\uff09/ \u9500\u91cf\u4fe1\u606f\uff084\u5217\uff09
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name', var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
'新旧品',
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8']; '\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8'];
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014', var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
'\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf', '\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',

View File

@ -1,662 +0,0 @@
<!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>

View File

@ -1,202 +0,0 @@
<!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

@ -1,120 +0,0 @@
"""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

@ -1,45 +0,0 @@
"""包材订单首屏加载与订单 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

@ -1,26 +0,0 @@
"""待下单详情下单备注完整查看功能回归测试。"""
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

@ -1,121 +0,0 @@
"""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

@ -1,41 +0,0 @@
"""计划预测支撑天数统一口径回归测试。"""
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,7 +18,6 @@ class ProductBrandFilterStaticTest(unittest.TestCase):
self.assertIn("loadProductBrandFilter('成品')", APP_JS) self.assertIn("loadProductBrandFilter('成品')", APP_JS)
self.assertIn('loadProductBrandFilter(tab)', APP_JS) self.assertIn('loadProductBrandFilter(tab)', APP_JS)
self.assertGreaterEqual(APP_JS.count("url += '&brand='"), 2) self.assertGreaterEqual(APP_JS.count("url += '&brand='"), 2)
self.assertIn("brand === '__EMPTY__' ? '(品牌为空)' : brand", APP_JS)
class ProductBrandFilterApiTest(unittest.TestCase): class ProductBrandFilterApiTest(unittest.TestCase):
@ -36,43 +35,16 @@ class ProductBrandFilterApiTest(unittest.TestCase):
self.assertIn('TRIM(p.brand)=%s', query.call_args_list[0].args[0]) self.assertIn('TRIM(p.brand)=%s', query.call_args_list[0].args[0])
self.assertEqual(query.call_args_list[0].args[1], ['无']) self.assertEqual(query.call_args_list[0].args[1], ['无'])
def test_active_products_filter_empty_brand(self):
with mock.patch.object(products, 'query', side_effect=[[], [{'cnt': 0}], []]) as query:
response = self.client.get('/api/products/list?brand=__EMPTY__')
self.assertEqual(response.get_json()['code'], 0)
self.assertIn("(p.brand IS NULL OR TRIM(p.brand)='')", query.call_args_list[1].args[0])
self.assertEqual(query.call_args_list[1].args[1], [])
def test_recycle_bin_filters_empty_brand(self):
with mock.patch.object(products, 'query', side_effect=[[{'cnt': 0}], []]) as query:
response = self.client.get('/api/products/recycle_bin?brand=__EMPTY__')
self.assertEqual(response.get_json()['code'], 0)
self.assertIn("(p.brand IS NULL OR TRIM(p.brand)='')", query.call_args_list[0].args[0])
self.assertEqual(query.call_args_list[0].args[1], [])
def test_filter_options_can_be_scoped_to_recycle_bin(self): def test_filter_options_can_be_scoped_to_recycle_bin(self):
with mock.patch.object(products, 'query', side_effect=[ with mock.patch.object(products, 'query', side_effect=[
[{'brand': '回收品牌'}], [{'brand': '回收品牌'}],
[{'category1': '分类'}], [{'category1': '分类'}],
[{'cnt': 2}],
]) as query: ]) as query:
response = self.client.get('/api/products/filters?scope=recycle') response = self.client.get('/api/products/filters?scope=recycle')
self.assertEqual(response.get_json()['data']['brands'], ['__EMPTY__', '回收品牌']) self.assertEqual(response.get_json()['data']['brands'], ['回收品牌'])
self.assertIn('deleted_at IS NOT NULL', query.call_args_list[0].args[0]) self.assertIn('deleted_at IS NOT NULL', query.call_args_list[0].args[0])
def test_filter_options_omit_empty_brand_when_scope_has_none(self):
with mock.patch.object(products, 'query', side_effect=[
[{'brand': '正常品牌'}],
[],
[{'cnt': 0}],
]):
response = self.client.get('/api/products/filters?scope=active')
self.assertEqual(response.get_json()['data']['brands'], ['正常品牌'])
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -1,24 +0,0 @@
"""登录页与系统导航名称回归测试。"""
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()