Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

40 changed files with 277 additions and 2688 deletions

3
.gitignore vendored
View File

@ -26,6 +26,3 @@ tmp_cache/
# 合同生成输出文件 # 合同生成输出文件
static/uploads/contract_output/ static/uploads/contract_output/
ai_config.py ai_config.py
# 本机/云端运行密钥(不进入版本库)
.secrets/

2
app.py
View File

@ -137,7 +137,7 @@ def spa_fallback(path):
SPA_PATHS = { SPA_PATHS = {
'knowledge', 'planning', 'products', 'bom', 'purchase', 'knowledge', 'planning', 'products', 'bom', 'purchase',
'orders', 'inventory', 'warehouse', 'suppliers', 'orders', 'inventory', 'warehouse', 'suppliers',
'users', 'organization', 'department-matching', 'data', 'config', 'logs', 'permissions', 'users', 'data', 'config', 'logs', 'permissions',
'login-records', 'login-records',
'contract-templates', 'personnel', 'contract-templates', 'personnel',
'collaboration', 'spot', 'ai-analysis', 'product-order', 'collaboration', 'spot', 'ai-analysis', 'product-order',

View File

@ -1,17 +0,0 @@
[Unit]
Description=Synchronize DingTalk organization structure
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=oneshot
User=ubuntu
Group=ubuntu
WorkingDirectory=/opt/dy-dashboard
Environment=HOME=/home/ubuntu
Environment=TZ=Asia/Shanghai
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/opt/dy-dashboard/venv/bin/python /opt/dy-dashboard/scripts/sync_dingtalk_organization.py
TimeoutStartSec=30min
UMask=0077
NoNewPrivileges=true

View File

@ -1,10 +0,0 @@
[Unit]
Description=Synchronize DingTalk organization structure every day at 02:00
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
Unit=dy-dingtalk-org-sync.service
[Install]
WantedBy=timers.target

View File

@ -14,8 +14,6 @@ from routes.contract import contract_bp
from routes.ai import ai_bp from routes.ai import ai_bp
from routes.sku_forecast import sku_forecast_bp from routes.sku_forecast import sku_forecast_bp
from routes.spot import spot_bp from routes.spot import spot_bp
from routes.organization import organization_bp
from routes.department_matching import department_matching_bp
def register_blueprints(app): def register_blueprints(app):
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@ -32,10 +30,7 @@ def register_blueprints(app):
app.register_blueprint(ai_bp) app.register_blueprint(ai_bp)
app.register_blueprint(sku_forecast_bp) app.register_blueprint(sku_forecast_bp)
app.register_blueprint(spot_bp) app.register_blueprint(spot_bp)
app.register_blueprint(organization_bp)
app.register_blueprint(department_matching_bp)
__all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp', __all__ = ['register_blueprints', 'auth_bp', 'dashboard_bp', 'products_bp',
'inventory_bp', 'planning_bp', 'purchase_bp', 'warehouse_bp', 'inventory_bp', 'planning_bp', 'purchase_bp', 'warehouse_bp',
'users_bp', 'data_bp', 'knowledge_bp', 'contract_bp', 'ai_bp', 'sku_forecast_bp', 'spot_bp', 'users_bp', 'data_bp', 'knowledge_bp', 'contract_bp', 'ai_bp', 'sku_forecast_bp', 'spot_bp']
'organization_bp']

View File

@ -40,7 +40,7 @@ def _save_history(user_id, question, summary, datasource, result_type, content=N
# 数据源使用说明(供 AI 参考参数格式) # 数据源使用说明(供 AI 参考参数格式)
AI_PARAMS_GUIDE = """ AI_PARAMS_GUIDE = """
数据源参数说明(params 为 JSON 对象,可选字段): 数据源参数说明(params 为 JSON 对象,可选字段):
- product_code: 按产品编码/名称筛选(模糊匹配);operation_history 也支持 product_name 按产品名称检索旧日志文本 - product_code: 按产品编码/名称筛选(模糊匹配)
- brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit / - brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit /
material_inventory / inventory_value / brand_overview 均支持) material_inventory / inventory_value / brand_overview 均支持)
- sort_by: 排序字段(必须是该数据源返回的字段名) - sort_by: 排序字段(必须是该数据源返回的字段名)
@ -48,8 +48,6 @@ AI_PARAMS_GUIDE = """
- limit: 返回行数上限(默认50,最大200) - limit: 返回行数上限(默认50,最大200)
- kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品 - kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品
- days: 出库统计排行和按日出库统计均支持,最近 N 天(如问"最近30天"传 30) - days: 出库统计排行和按日出库统计均支持,最近 N 天(如问"最近30天"传 30)
- operation_history 专用参数:module=板块名称,field=修改字段,operator=操作人,start_date/end_date=YYYY-MM-DD。
- 查询修改记录时必须提供 product_code 或明确的板块/人员/日期条件;不做无条件全库扫描。
""" """
@ -88,51 +86,6 @@ def _can_view_datasource(uid, role, key):
return False return False
def _filter_operation_history_permissions(uid, role, rows):
"""日志查询逐条遵守原业务页面查看权限及字段查看权限。"""
if is_admin_role(role):
return rows
from routes.auth import _has_effective_page_permission
from services.field_perms import field_perms_for_page
page_fields = {
'planning': 'planning', '产品信息管理': 'products', 'products': 'products',
'计划预测': 'planning', '国内订单管理': 'order_domestic', 'order_domestic': 'order_domestic',
'海外订单管理': 'order_overseas', 'order_overseas': 'order_overseas',
'包材订单管理': 'order_packaging', 'order_packaging': 'order_packaging',
'待下单管理': 'purchase_pending', '下单备份管理': 'purchase_backup',
'物料清单管理': 'bom', 'bom': 'bom', '供应商管理': 'suppliers', 'suppliers': 'suppliers',
'现货采样申请': 'spot_application', '现货采样确认': 'spot_confirmation',
'现货销量管理': 'spot_sales',
}
page_ids = set(page_fields.values())
fields_cache = {}
filtered = []
for row in rows:
page = str(row.get('板块') or '')
page_id = page_fields.get(page, page if page in page_ids else '')
if not page_id:
row = dict(row, **{'修改内容': '该历史日志所属菜单无法确认,修改内容已隐藏'})
filtered.append(row)
continue
if page_id not in fields_cache:
fields_cache[page_id] = field_perms_for_page(uid, role, page_id)
field_perms = fields_cache[page_id]
content = row.get('修改内容') or ''
# 修改内容由“字段: 旧值 → 新值”组成;无结构化字段时隐藏原文,避免泄露未授权字段。
if ' → ' in content and field_perms:
visible_changes = []
for change in content.split(';'):
key = re.split(r'[::]\s*', change, maxsplit=1)[0]
if field_perms.get(key, {}).get('view', False):
visible_changes.append(change)
content = ';'.join(visible_changes) or '修改字段内容因字段权限不可见'
else:
content = '修改内容未提供字段级权限定义,已隐藏'
row = dict(row, **{'修改内容': content})
filtered.append(row)
return filtered
def _can_export_ai(uid, role): def _can_export_ai(uid, role):
"""导出权限:显式配置优先,未配置时跟随页面查看权限""" """导出权限:显式配置优先,未配置时跟随页面查看权限"""
if is_admin_role(role): if is_admin_role(role):
@ -482,28 +435,7 @@ def ai_ask():
denied.append(ds.get('name') or key) denied.append(ds.get('name') or key)
continue continue
try: try:
params = dict(task.get('params') or {}) result = ds['handler'](task.get('params') or {}) or {}
if key == 'operation_history' and not is_admin_role(role):
from routes.auth import _has_effective_page_permission
page_ids = ['planning', 'products', 'order_domestic', 'order_overseas', 'order_packaging',
'purchase_pending', 'purchase_backup', 'bom', 'suppliers', 'spot_application',
'spot_confirmation', 'spot_sales']
allowed_ids = {page for page in page_ids if _has_effective_page_permission(uid, role, page)}
params['_allowed_pages'] = [page for page, page_id in {
'planning': 'planning', '计划预测': 'planning', 'products': 'products', '产品信息管理': 'products',
'order_domestic': 'order_domestic', '国内订单管理': 'order_domestic',
'order_overseas': 'order_overseas', '海外订单管理': 'order_overseas',
'order_packaging': 'order_packaging', '包材订单管理': 'order_packaging',
'purchase_pending': 'purchase_pending', '待下单管理': 'purchase_pending',
'purchase_backup': 'purchase_backup', '下单备份管理': 'purchase_backup',
'bom': 'bom', '物料清单管理': 'bom', 'suppliers': 'suppliers', '供应商管理': 'suppliers',
'spot_application': 'spot_application', '现货采样申请': 'spot_application',
'spot_confirmation': 'spot_confirmation', '现货采样确认': 'spot_confirmation',
'spot_sales': 'spot_sales', '现货销量管理': 'spot_sales',
}.items() if page_id in allowed_ids]
result = ds['handler'](params) or {}
if key == 'operation_history':
result['rows'] = _filter_operation_history_permissions(uid, role, result.get('rows') or [])
meta = datasource_meta(key) meta = datasource_meta(key)
datasets.append({ datasets.append({
'key': key, 'key': key,
@ -536,34 +468,7 @@ def ai_ask():
json.dumps(payload, ensure_ascii=False, default=str)) json.dumps(payload, ensure_ascii=False, default=str))
return jsonify({"code": 200, "data": payload}) return jsonify({"code": 200, "data": payload})
# 操作日志结果直接确定性总结,避免模型改写或混淆精确匹配与历史文本匹配。
if all(d['key'] == 'operation_history' for d in datasets):
ds = datasets[0]
total = int((ds.get('summary') or {}).get('row_count') or 0)
returned = len(ds.get('rows') or [])
exact = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '精确对象匹配')
fallback = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '历史文本匹配')
subject = ((ds.get('params') or {}).get('product_code')
or (ds.get('params') or {}).get('product_name') or '指定条件')
summary = '找到%s条与%s相关的操作记录。' % (total, subject) if total else '没有找到符合条件的操作记录。'
limitations = []
if total > returned:
limitations.append('当前明细最多展示%s条,匹配总数为%s条。' % (returned, total))
if fallback:
limitations.append('%s条为历史文本匹配,旧日志可能未保存完整的修改前值。' % fallback)
if exact:
limitations.append('%s条为精确业务对象匹配。' % exact)
analysis = {
'title': '操作记录查询', 'summary': summary,
'findings': [{'title': '匹配结果', 'detail': '符合条件%s条,返回%s条;其中精确对象匹配%s条,历史文本匹配%s条。' % (total, returned, exact, fallback), 'level': '信息'}],
'risks': [], 'actions': [], 'evidence': [], 'follow_ups': [], 'confidence': '高' if not fallback else '中',
'limitations': limitations, 'chart': {'type': 'none'},
}
else:
analysis = None
# 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。 # 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。
if analysis is None:
analysis = _apply_authoritative_inventory_summary(None, datasets, question) analysis = _apply_authoritative_inventory_summary(None, datasets, question)
if analysis is None: if analysis is None:
try: try:
@ -650,7 +555,7 @@ def _normalize_tasks(raw_tasks):
# 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。 # 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。
allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days', allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days',
'supplier', 'status', 'product_type', 'planner', 'follower', 'operator', 'supplier', 'status', 'product_type', 'planner', 'follower', 'operator',
'material_code', 'module', 'field', 'start_date', 'end_date', 'product_name'} 'material_code'}
params = {k: v for k, v in params.items() if k in allowed} params = {k: v for k, v in params.items() if k in allowed}
try: try:
params['limit'] = max(1, min(int(params.get('limit') or 50), 200)) params['limit'] = max(1, min(int(params.get('limit') or 50), 200))
@ -661,10 +566,6 @@ def _normalize_tasks(raw_tasks):
seen.add(key) seen.add(key)
if len(tasks) >= 6: if len(tasks) >= 6:
break break
# 查询操作日志时仅允许读取该数据源,避免同一问题额外触发业务数据查询。
history_tasks = [task for task in tasks if task['datasource'] == 'operation_history']
if history_tasks:
return history_tasks[:1]
return tasks return tasks
@ -738,9 +639,6 @@ def _plan_analysis(question, history, last_result):
'7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,' '7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,'
'所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。' '所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。'
'全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n' '全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n'
'8. 问某产品或板块的手动编辑、谁修改过、修改前后、操作历史时,使用operation_history;'
'优先传入product_code;用户只提供产品名称时传product_name,指定板块/人员/日期时传module/operator/start_date/end_date;'
'不要为查询操作记录调用其他业务数据源。\n'
'只输出JSON对象,格式:' '只输出JSON对象,格式:'
'{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",' '{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",'
'"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],' '"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],'

View File

@ -109,8 +109,6 @@ PAGE_FEATURES = {
'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}], 'warehouse': [{'id':'view','name':'查看'},{'id':'create','name':'新增仓库'},{'id':'edit','name':'编辑仓库'},{'id':'delete','name':'删除仓库'}],
'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}], 'suppliers': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'},{'id':'create','name':'新增供应商'}],
'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}], 'knowledge': [{'id':'view','name':'查看'},{'id':'create','name':'新建文章'},{'id':'edit','name':'编辑文章'},{'id':'delete','name':'删除文章'},{'id':'import','name':'导入'},{'id':'export','name':'导出'},{'id':'lint','name':'运行检查'},{'id':'graph','name':'关系图谱'}],
'organization': [{'id':'view','name':'查看'},{'id':'sync','name':'同步钉钉组织架构'}],
'department_matching': [{'id':'view','name':'查看与匹配测试'},{'id':'create','name':'新增规则'},{'id':'edit','name':'编辑规则'},{'id':'delete','name':'删除规则'}],
'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}], 'users': [{'id':'view','name':'查看'},{'id':'create','name':'新增'},{'id':'edit','name':'编辑'},{'id':'delete','name':'删除'},{'id':'import','name':'导入'}],
'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}], 'permissions': [{'id':'view','name':'查看'},{'id':'edit','name':'编辑'}],
'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}], 'logs': [{'id':'view','name':'查看'},{'id':'export','name':'导出'},{'id':'verify','name':'完整性校验'},{'id':'manage','name':'日志治理'}],

View File

@ -6,11 +6,7 @@ from flask import Blueprint, render_template, request, jsonify, session, redirec
from services.db import query from services.db import query
from routes.auth import login_required, role_required, operation_required from routes.auth import login_required, role_required, operation_required
from services.simple_cache import get as cache_get, set as cache_set, delete as cache_del from services.simple_cache import get as cache_get, set as cache_set, delete as cache_del
from services.inventory_calc import ( from services.inventory_calc import resolve_cost_price, compute_inventory_cost_summary
active_warehouse_condition,
compute_inventory_cost_summary,
resolve_cost_price,
)
from services.field_perms import filter_rows as _filter_field_rows from services.field_perms import filter_rows as _filter_field_rows
from services.audit_schema import ensure_audit_schema from services.audit_schema import ensure_audit_schema
import config as app_config import config as app_config
@ -49,14 +45,6 @@ def settings_page():
return render_template('base.html', page='settings') return render_template('base.html', page='settings')
@dashboard_bp.route('/organization')
@login_required
def organization_page():
if not role_required('organization'):
return redirect(url_for('auth.index'))
return render_template('base.html', page='organization')
@dashboard_bp.route('/favicon.ico') @dashboard_bp.route('/favicon.ico')
def favicon(): def favicon():
from flask import redirect as _rd from flask import redirect as _rd
@ -680,12 +668,12 @@ def _inventory_board_core(month, force_system_refresh=False):
口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料 口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料
板块2 经营效率: 板块2 经营效率:
- 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径) - 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径)
- 月销货成本:daily_outbound 所选自然月 SUM(货品成本) - 月销货成本:daily_outbound 近 30 天 SUM(货品成本)
- 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位 - 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位
- 月采购入库:arrival_batches 所选自然月 SUM(arrival_qty) - 月采购入库:arrival_batches 近 30 天 SUM(arrival_qty)
- 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率 - 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率
- 动销SKU(≥30/≥100):所选月累计出库达到 30/100 件的成品口径 SKU(排除内料) - 动销SKU(>30/>100):近 30/100 天有出库记录的成品口径 SKU(排除内料)
- 总SKU:月末启用仓库库存分别达到 1/200 件的成品口径去重 SKU - 总SKU:products 成品/清库存/现货/赠品计数;动销率 = 动销SKU(30天)/总SKU
板块3 呆滞治理: 板块3 呆滞治理:
- 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天 - 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天
首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量 首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量
@ -704,11 +692,8 @@ def _inventory_board_core(month, force_system_refresh=False):
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1) next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
# 此看板包含按月份人工保存的数据。Gunicorn 多 worker 下进程内缓存无法同步, # 此看板包含按月份人工保存的数据。Gunicorn 多 worker 下进程内缓存无法同步,
# 会导致保存后请求落到其他 worker 时回显旧值,因此该接口始终读取数据库最新值。 # 会导致保存后请求落到其他 worker 时回显旧值,因此该接口始终读取数据库最新值。
# 所选月内最后一个库存快照。该月没有快照时不借用上月/未来月份数据,避免月份与数据日期错位。 # 月末可用库存快照。若所选月份早于数据源起始日期,不再回退到最新月份,全部系统计算值按 0 处理。
latest_date = query( latest_date = query("SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date < %s", (next_month,))[0]['d']
"SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date >= %s AND data_date < %s",
(month_start, next_month),
)[0]['d']
if not latest_date: if not latest_date:
result = { result = {
'all_amount': 0, 'wh01_amount': 0, 'wh15_amount': 0, 'cloud_amount': 0, 'overseas_amount': 0, 'domestic_self_amount': 0, 'all_amount': 0, 'wh01_amount': 0, 'wh15_amount': 0, 'cloud_amount': 0, 'overseas_amount': 0, 'domestic_self_amount': 0,
@ -771,22 +756,19 @@ def _inventory_board_core(month, force_system_refresh=False):
'stockout_sku_lingdong': '缺货SKU次数(国际-灵动)', 'stockout_sku_lingdong': '缺货SKU次数(国际-灵动)',
'stockout_sku_chunyue': '缺货SKU次数(国际-春跃)', 'stockout_sku_chunyue': '缺货SKU次数(国际-春跃)',
} }
# 有外部录入时覆盖系统值;未录入或清空时回退系统计算值。 # 这6个输入框以月份外部录入表为唯一数据源,不能被系统计算值或“人工新建”旧值覆盖。
for key in external_fields: for key in external_fields:
if key not in external_map or external_map.get(key) in ('', None): value = external_map.get(key)
if value in ('', None):
result[key] = 0
continue continue
value = external_map[key]
try: try:
result[key] = float(value) result[key] = float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
continue result[key] = 0
# 根据当月手工值实时计算派生指标。 # 根据当月手工值实时计算派生指标。
result['active_sku_30'] = result['active_sku'] result['active_sku_30'] = result['active_sku']
result['return_rate'] = ( result['return_rate'] = round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2) if result['monthly_ship_cost'] else 0
round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2)
if result.get('monthly_return_cost') is not None and result.get('monthly_ship_cost')
else None
)
result['turnover_rate'] = round(result['monthly_ship_cost'] / result['inventory_total_amount'], 4) if result['inventory_total_amount'] else 0 result['turnover_rate'] = round(result['monthly_ship_cost'] / result['inventory_total_amount'], 4) if result['inventory_total_amount'] else 0
month_days = (next_month - month_start).days month_days = (next_month - month_start).days
result['turnover_days'] = round(month_days / result['turnover_rate'], 1) if result['turnover_rate'] else 0 result['turnover_days'] = round(month_days / result['turnover_rate'], 1) if result['turnover_rate'] else 0
@ -851,20 +833,10 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
_inventory_signature = query( _inventory_signature = query(
"SELECT COUNT(*) AS row_count, COALESCE(SUM(available_qty), 0) AS qty_sum, " "SELECT COUNT(*) AS row_count, COALESCE(SUM(available_qty), 0) AS qty_sum, "
"DATE_FORMAT(MAX(updated_at), '%%Y%%m%%d%%H%%i%%s%%f') AS updated_signature " "DATE_FORMAT(MAX(updated_at), '%%Y%%m%%d%%H%%i%%s%%f') AS updated_signature "
f"FROM inventory_detail WHERE data_date=%s AND {active_warehouse_condition('inventory_detail')}", "FROM inventory_detail WHERE data_date=%s", (latest_date,))[0]
(latest_date,), _cache_key = 'inventory_board_system:%s:%s:%s:%s:%s' % (
)[0] month, latest_date, _inventory_signature['row_count'],
# 仓库启停/属性变化也必须使缓存失效;使用短摘要避免 cache_key 超出表字段长度。 _inventory_signature['qty_sum'], _inventory_signature['updated_signature'] or '')
import hashlib
_warehouse_signature = query(
"SELECT warehouse_code, warehouse_property, disabled FROM warehouses ORDER BY warehouse_code"
)
_signature_text = json.dumps({
'inventory': _inventory_signature,
'warehouses': _warehouse_signature,
}, ensure_ascii=False, default=str, sort_keys=True)
_cache_digest = hashlib.sha1(_signature_text.encode('utf-8')).hexdigest()[:20]
_cache_key = f'inventory_board_system:{month}:{latest_date}:{_cache_digest}'
if not force_refresh: if not force_refresh:
_cached = cache_get(_cache_key) _cached = cache_get(_cache_key)
if _cached is not None: if _cached is not None:
@ -951,65 +923,42 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
rows = query(f""" rows = query(f"""
SELECT id.product_code AS code, SUM(id.available_qty) AS qty SELECT id.product_code AS code, SUM(id.available_qty) AS qty
FROM inventory_detail id FROM inventory_detail id
WHERE id.data_date = %s LEFT JOIN products p ON id.product_code = p.product_code
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20) WHERE id.data_date = %s AND id.warehouse_code != '20' AND {wh_cond}
AND {active_warehouse_condition('id')} AND {wh_cond} AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
AND (
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
)
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
GROUP BY id.product_code GROUP BY id.product_code
""", (target_date,) + tuple(params) + ('%-NL', '%内料%')) """, (target_date,) + tuple(params) + ('%-NL', '%内料%'))
return round(sum(float(r['qty'] or 0) * _cmap.get(r['code'], 0) for r in rows), 2) return round(sum(float(r['qty'] or 0) * _cmap.get(r['code'], 0) for r in rows), 2)
def _warehouse_match(codes):
numeric = sorted({int(str(code)) for code in codes if str(code).isdigit()})
text = sorted({str(code) for code in codes if not str(code).isdigit()})
clauses, params = [], []
if numeric:
clauses.append("(id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) IN (%s))" %
','.join(['%s'] * len(numeric)))
params.extend(numeric)
if text:
clauses.append("id.warehouse_code IN (%s)" % ','.join(['%s'] * len(text)))
params.extend(text)
return ('(' + ' OR '.join(clauses) + ')', params) if clauses else ('0=1', [])
amounts = { amounts = {
'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品 'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品
'wh01_amount': _wh_finished_amount( 'wh01_amount': _wh_finished_amount("id.warehouse_code = %s", ('01',)), # 蝶雅仓成品
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (1,)), 'wh15_amount': _wh_finished_amount("id.warehouse_code = %s", ('15',)), # 加工厂仓成品
'wh15_amount': _wh_finished_amount(
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (15,)),
} }
_cloud_codes = [r['warehouse_code'] for r in query( _cloud_codes = [r['warehouse_code'] for r in query(
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('云仓', warehouse_property) > 0 " "SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('云仓', warehouse_property) > 0 "
"AND COALESCE(disabled, '否') != '是' " "AND warehouse_code != '01'")]
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)")]
if _cloud_codes: if _cloud_codes:
_cond, _params = _warehouse_match(_cloud_codes) _ph = ','.join(['%s'] * len(_cloud_codes))
amounts['cloud_amount'] = _wh_finished_amount(_cond, _params) amounts['cloud_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _cloud_codes)
else: else:
amounts['cloud_amount'] = 0.0 amounts['cloud_amount'] = 0.0
_overseas_codes = [r['warehouse_code'] for r in query( _overseas_codes = [r['warehouse_code'] for r in query(
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0 " "SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0")]
"AND COALESCE(disabled, '否') != '是'")]
if _overseas_codes: if _overseas_codes:
_cond, _params = _warehouse_match(_overseas_codes) _ph = ','.join(['%s'] * len(_overseas_codes))
amounts['overseas_amount'] = _wh_finished_amount(_cond, _params) amounts['overseas_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _overseas_codes)
else: else:
amounts['overseas_amount'] = 0.0 amounts['overseas_amount'] = 0.0
# 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓) # 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓)
_self_codes = [r['warehouse_code'] for r in query( _self_codes = [r['warehouse_code'] for r in query(
"SELECT warehouse_code FROM warehouses WHERE (warehouse_name LIKE %s OR warehouse_code IN ('13', '14')) " "SELECT warehouse_code FROM warehouses WHERE (warehouse_name LIKE %s OR warehouse_code IN ('13', '14')) "
"AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND COALESCE(disabled, '否') != '是' " "AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND warehouse_code != '01'",
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)",
('%自营%',))] ('%自营%',))]
if _self_codes: if _self_codes:
_cond, _params = _warehouse_match(_self_codes) _ph = ','.join(['%s'] * len(_self_codes))
amounts['domestic_self_amount'] = _wh_finished_amount(_cond, _params) amounts['domestic_self_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _self_codes)
else: else:
amounts['domestic_self_amount'] = 0.0 amounts['domestic_self_amount'] = 0.0
return amounts return amounts
@ -1038,38 +987,25 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
turnover_rate = round(monthly_ship_cost / inventory_total_amount, 4) if inventory_total_amount else 0 turnover_rate = round(monthly_ship_cost / inventory_total_amount, 4) if inventory_total_amount else 0
turnover_days = round(30 / turnover_rate, 1) if turnover_rate else None turnover_days = round(30 / turnover_rate, 1) if turnover_rate else None
# 9. 动销SKU数量:所选自然月累计出库分别达到 30 / 100 件的 SKU 数。 # 9. 动销SKU数量:>30天 / >100天 两个口径(近 N 天有出库、成品口径、排除内料)
_monthly_sales = query(""" def _active_sku(days):
SELECT d.货品编号 AS code, SUM(d.货品数量) AS qty return int(query("""
SELECT /*+ INDEX(d idx_date_product) */ COUNT(DISTINCT d.货品编号) AS v
FROM daily_outbound d FROM daily_outbound d
LEFT JOIN products p ON d.货品编号 = p.product_code
WHERE d.日期 >= %s AND d.日期 < %s WHERE d.日期 >= %s AND d.日期 < %s
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s
AND ( """, (max(month_start, latest_date - timedelta(days=days - 1)), next_month, '%内料%', '%-NL'))[0]['v'])
EXISTS (SELECT 1 FROM products p WHERE p.product_code=d.货品编号
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=d.货品编号 AND p0.deleted_at IS NULL)
)
GROUP BY d.货品编号
""", (month_start, next_month, '%内料%', '%-NL'))
active_sku_30 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 30)
active_sku_100 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 100)
# 10. 总SKU数量:月末启用仓库的成品库存分别达到 1 / 200 件的去重 SKU 数。 active_sku_30 = _active_sku(30) # 动销SKU数量(>30天)
_month_end_sku_rows = query(f""" active_sku_100 = _active_sku(100) # 动销SKU数量(>100天)
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
FROM inventory_detail id # 10. 总SKU数量
WHERE id.data_date = %s AND {active_warehouse_condition('id')} total_sku = int(query(
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20) "SELECT COUNT(*) AS v FROM products p "
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s "WHERE p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL"
AND ( )[0]['v'])
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
)
GROUP BY id.product_code
""", (latest_date, '%-NL', '%内料%'))
total_sku = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 1)
total_sku_200 = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 200)
# 11. 动销率(30天动销口径) # 11. 动销率(30天动销口径)
active_rate = round(active_sku_30 / total_sku * 100, 2) if total_sku else 0 active_rate = round(active_sku_30 / total_sku * 100, 2) if total_sku else 0
@ -1079,7 +1015,6 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
# 注:不能加内料过滤 WHERE,否则无法走 idx_product_date 覆盖索引,全表扫描 180万行需 30s+ # 注:不能加内料过滤 WHERE,否则无法走 idx_product_date 覆盖索引,全表扫描 180万行需 30s+
_first_date = {r['product_code']: r['first_date'] for r in query(""" _first_date = {r['product_code']: r['first_date'] for r in query("""
SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail
WHERE """ + active_warehouse_condition('inventory_detail') + """
GROUP BY product_code GROUP BY product_code
""")} """)}
# 近30天销量(日均销量分母) # 近30天销量(日均销量分母)
@ -1090,15 +1025,12 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
GROUP BY 货品编号 GROUP BY 货品编号
""", (max(month_start, latest_date - timedelta(days=29)), next_month, '%内料%', '%-NL'))} """, (max(month_start, latest_date - timedelta(days=29)), next_month, '%内料%', '%-NL'))}
# 最新库存日:按商品聚合可得库存数量(成品口径、排除内料) # 最新库存日:按商品聚合可得库存数量(成品口径、排除内料)
_inv_by_code = query(f""" _inv_by_code = query("""
SELECT id.product_code AS code, SUM(id.available_qty) AS qty SELECT id.product_code AS code, SUM(id.available_qty) AS qty
FROM inventory_detail id FROM inventory_detail id
WHERE id.data_date = %s AND {active_warehouse_condition('id')} LEFT JOIN products p ON id.product_code = p.product_code
AND ( WHERE id.data_date = %s
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
)
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
GROUP BY id.product_code GROUP BY id.product_code
""", (latest_date, '%-NL', '%内料%')) """, (latest_date, '%-NL', '%内料%'))
@ -1128,20 +1060,15 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
# 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。 # 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。
# 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。 # 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。
_stock_rows = query(f""" _stock_rows = query("""
SELECT id.product_code AS code, id.data_date AS dt, SUM(id.available_qty) AS qty SELECT id.product_code AS code, id.data_date AS dt, SUM(id.available_qty) AS qty
FROM inventory_detail id FROM inventory_detail id
LEFT JOIN products p ON id.product_code = p.product_code
WHERE id.data_date >= %s AND id.data_date < %s WHERE id.data_date >= %s AND id.data_date < %s
AND {active_warehouse_condition('id')} AND id.available_qty = 0
AND ( AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
)
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
GROUP BY id.product_code, id.data_date GROUP BY id.product_code, id.data_date ORDER BY id.product_code, id.data_date
HAVING SUM(id.available_qty) <= 0
ORDER BY id.product_code, id.data_date
""", (month_start, next_month, '%-NL', '%内料%')) """, (month_start, next_month, '%-NL', '%内料%'))
_stockout_codes = set() _stockout_codes = set()
_stockout_by_code = {} _stockout_by_code = {}
@ -1161,8 +1088,6 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
_stockout_codes.add(_prev_code) _stockout_codes.add(_prev_code)
_run = 0 _run = 0
_prev_code, _prev_date = code, dt _prev_code, _prev_date = code, dt
# 缺货监控只针对统计窗口内存在实际出库需求的 SKU,排除长期零库存且无销售的停销档案。
_stockout_codes.intersection_update(_sales_30.keys())
_prod_platform = {r['code']: (r['platform'] or '') for r in query(""" _prod_platform = {r['code']: (r['platform'] or '') for r in query("""
SELECT product_code AS code, platform FROM products SELECT product_code AS code, platform FROM products
WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL
@ -1221,7 +1146,7 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
'active_sku_100': active_sku_100, # 动销SKU数量(>100天) 'active_sku_100': active_sku_100, # 动销SKU数量(>100天)
'active_sku': active_sku_30, # 兼容旧字段(外部录入覆盖) 'active_sku': active_sku_30, # 兼容旧字段(外部录入覆盖)
'total_sku': total_sku, 'total_sku': total_sku,
'total_sku_200': total_sku_200, 'total_sku_200': 0,
'active_rate': active_rate, 'active_rate': active_rate,
'active_rate_100': 0, 'active_rate_100': 0,
# 板块3 呆滞治理 # 板块3 呆滞治理

View File

@ -1,82 +0,0 @@
"""部门匹配规则维护和只读试算。"""
import json
from flask import Blueprint, jsonify, request
from routes.auth import login_required, operation_required, role_required
from services.db import query, add_operation_log
from services.department_matching import records, validate_record, match_department, check_samples
department_matching_bp = Blueprint('department_matching', __name__)
@department_matching_bp.route('/api/department-matching/records')
@login_required
def list_records():
if not role_required('department_matching'):
return jsonify(code=403, msg='无部门匹配管理查看权限'), 403
return jsonify(code=0, data=records())
@department_matching_bp.route('/api/department-matching/save', methods=['POST'])
@login_required
def save_record():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify(code=400, msg='请求格式不正确'), 400
if not operation_required('department_matching', 'edit' if data.get('id') else 'create'):
return jsonify(code=403, msg='无规则维护权限'), 403
try:
kind, payload, priority, enabled = validate_record(data)
row_id = int(data.get('id') or 0)
old = query('SELECT * FROM department_matching_records WHERE id=%s', (row_id,)) if row_id else []
if row_id and (not old or old[0]['kind'] != kind):
return jsonify(code=404, msg='记录不存在或类型不正确'), 404
if row_id:
query('UPDATE department_matching_records SET payload=%s,priority=%s,enabled=%s WHERE id=%s',
(json.dumps(payload, ensure_ascii=False), priority, enabled, row_id))
else:
row_id = query('INSERT INTO department_matching_records (kind,payload,priority,enabled) VALUES (%s,%s,%s,%s)',
(kind, json.dumps(payload, ensure_ascii=False), priority, enabled))
add_operation_log('编辑部门匹配规则' if old else '新增部门匹配规则', f'记录{row_id},类型{kind}',
page='department_matching', object_id=str(row_id),
old_values=old[0] if old else None, new_values=data)
return jsonify(code=0, msg='保存成功', id=row_id)
except (ValueError, TypeError) as exc:
return jsonify(code=400, msg=str(exc)), 400
@department_matching_bp.route('/api/department-matching/delete', methods=['POST'])
@login_required
def delete_record():
if not operation_required('department_matching', 'delete'):
return jsonify(code=403, msg='无删除权限'), 403
try:
row_id = int((request.get_json(silent=True) or {}).get('id', 0))
except (ValueError, TypeError):
return jsonify(code=400, msg='记录编号不正确'), 400
old = query('SELECT * FROM department_matching_records WHERE id=%s', (row_id,))
if not old:
return jsonify(code=404, msg='记录不存在'), 404
query('DELETE FROM department_matching_records WHERE id=%s', (row_id,))
add_operation_log('删除部门匹配规则', f'记录{row_id}', page='department_matching',
object_id=str(row_id), old_values=old[0])
return jsonify(code=0, msg='删除成功')
@department_matching_bp.route('/api/department-matching/test', methods=['POST'])
@login_required
def test_match():
if not role_required('department_matching'):
return jsonify(code=403, msg='无查看权限'), 403
data = request.get_json(silent=True) or {}
if not isinstance(data, dict) or any(not isinstance(data.get(k, ''), str) or len(data.get(k, '')) > 4000 for k in ('shop', 'source')):
return jsonify(code=400, msg='店铺和来源必须为4000字以内的文本'), 400
return jsonify(code=0, data=match_department(data.get('shop', ''), data.get('source', '')))
@department_matching_bp.route('/api/department-matching/check', methods=['POST'])
@login_required
def check_matching():
if not role_required('department_matching'):
return jsonify(code=403, msg='无查看权限'), 403
return jsonify(code=0, data=check_samples())

View File

@ -13,7 +13,6 @@ from flask import Blueprint, request, jsonify, session, redirect, url_for, rende
from services.db import query, query_iter, get_db from services.db import query, query_iter, get_db
from routes.auth import login_required, role_required, operation_required from routes.auth import login_required, role_required, operation_required
from services.field_perms import filter_rows as _filter_field_rows from services.field_perms import filter_rows as _filter_field_rows
from services.inventory_calc import active_warehouse_condition
inventory_bp = Blueprint('inventory', __name__) inventory_bp = Blueprint('inventory', __name__)
@ -412,7 +411,6 @@ def inventory_list():
{join_type} {join_type}
{warehouse_join} {warehouse_join}
WHERE {where_detail_clause} WHERE {where_detail_clause}
AND {active_warehouse_condition('d')}
""", params)[0] """, params)[0]
# 获取采购跟单、采购计划、供应商信息 # 获取采购跟单、采购计划、供应商信息
prod_fields = ", p.procurement_follower AS 采购跟单, p.procurement_planner AS 采购计划" prod_fields = ", p.procurement_follower AS 采购跟单, p.procurement_planner AS 采购计划"

View File

@ -1,37 +0,0 @@
"""钉钉组织架构页面 API。"""
from flask import Blueprint, jsonify
from routes.auth import login_required, operation_required, role_required
from services.db import add_operation_log
from services.dingtalk_organization import organization_snapshot, sync_organization
organization_bp = Blueprint('organization', __name__)
@organization_bp.route('/api/organization/tree')
@login_required
def organization_tree():
if not role_required('organization'):
return jsonify({'code': 403, 'msg': '无组织架构查看权限'}), 403
try:
return jsonify({'code': 0, 'data': organization_snapshot()})
except Exception as exc:
return jsonify({'code': 500, 'msg': str(exc)}), 500
@organization_bp.route('/api/organization/sync', methods=['POST'])
@login_required
def organization_sync():
if not operation_required('organization', 'sync'):
return jsonify({'code': 403, 'msg': '无组织架构同步权限'}), 403
try:
result = sync_organization('manual')
add_operation_log(
'同步组织架构',
f"部门{result['department_count']}个,人员{result['user_count']}人,关联账号{result['matched_count']}人",
page='organization',
)
return jsonify({'code': 0, 'msg': '组织架构同步成功', 'data': result})
except Exception as exc:
return jsonify({'code': 500, 'msg': str(exc)}), 500

View File

@ -19,7 +19,6 @@ from services.forecast import (is_new_product, get_product_grade,
from routes.auth import login_required, role_required, operation_required, _ensure_personnel_table from routes.auth import login_required, role_required, operation_required, _ensure_personnel_table
from services.field_perms import filter_rows as _filter_field_rows from services.field_perms import filter_rows as _filter_field_rows
from services.planning_snapshot import consecutive_days_map, attach_consecutive_days from services.planning_snapshot import consecutive_days_map, attach_consecutive_days
from services.order_progress import attach_unfinished_order_progress, unfinished_order_progress_map
import json import json
planning_bp = Blueprint('planning', __name__) planning_bp = Blueprint('planning', __name__)
@ -337,13 +336,11 @@ def _merge_pending_formal_qty(rows):
if not codes: if not codes:
return return
placeholders = ','.join(['%s'] * len(codes)) placeholders = ','.join(['%s'] * len(codes))
pending = query(f"""SELECT product_code, formal_order_qty FROM pending_orders pending = query(f"SELECT product_code, formal_order_qty FROM pending_orders WHERE status IN ('pending','draft') AND product_code IN ({placeholders})", codes)
WHERE status IN ('pending','draft') AND product_code IN ({placeholders})
ORDER BY CASE status WHEN 'draft' THEN 0 ELSE 1 END, updated_at, id""", codes)
qty_map = {r['product_code']: int(r['formal_order_qty'] or 0) for r in pending} qty_map = {r['product_code']: int(r['formal_order_qty'] or 0) for r in pending}
for row in rows: for row in rows:
pc = row.get('product_code', '') pc = row.get('product_code', '')
if pc in qty_map: if pc in qty_map and qty_map[pc] > 0:
row['正式下单数量'] = qty_map[pc] row['正式下单数量'] = qty_map[pc]
@ -723,9 +720,6 @@ def planning_list(filters=None):
extended_sales=extended_sales) extended_sales=extended_sales)
row['连续建议天数'] = snapshot_map.get(str(row.get('product_code') or ''), 0) row['连续建议天数'] = snapshot_map.get(str(row.get('product_code') or ''), 0)
attach_unfinished_order_progress(all_rows)
for row in all_rows:
# 筛选 # 筛选
match = True match = True
if match and type_filter and type_filter.lower() not in (row.get('product_type') or '').lower(): if match and type_filter and type_filter.lower() not in (row.get('product_type') or '').lower():
@ -942,7 +936,6 @@ def planning_list(filters=None):
cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map, cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map,
extended_sales=extended_sales) extended_sales=extended_sales)
attach_consecutive_days(rows) attach_consecutive_days(rows)
attach_unfinished_order_progress(rows)
# 获取所有匹配的产品编号(用于全选) # 获取所有匹配的产品编号(用于全选)
all_codes_rows = query(f"SELECT product_code FROM products p WHERE {where_clause}", params) all_codes_rows = query(f"SELECT product_code FROM products p WHERE {where_clause}", params)
@ -977,7 +970,7 @@ def planning_export():
return jsonify({"code": 400, "msg": "请先在字段设置中选择要导出的字段"}), 400 return jsonify({"code": 400, "msg": "请先在字段设置中选择要导出的字段"}), 400
# 只允许计划预测字段,排除操作列及未知字段。 # 只允许计划预测字段,排除操作列及未知字段。
allowed_fields = { allowed_fields = {
'product_type','产品种类','product_code','product_name','brand','批注','产品分级','在途进度', 'product_type','产品种类','product_code','product_name','brand','批注','产品分级',
'昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存', '昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存',
'云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨', '云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨',
'生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数', '生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数',
@ -1310,18 +1303,16 @@ def planning_column_values():
'库存工厂待生产可支撑天数','总支撑天数', '库存工厂待生产可支撑天数','总支撑天数',
'最低销量偏差率','最高销量偏差率','14天波动率', '最低销量偏差率','最高销量偏差率','14天波动率',
} }
COMPUTED_TEXT_FIELDS = {'在途进度'} if field in COMPUTED_NUMERIC_FIELDS:
COMPUTED_FIELDS = COMPUTED_NUMERIC_FIELDS | COMPUTED_TEXT_FIELDS
if field in COMPUTED_FIELDS:
# 计算字段:走全量查询+enrichment 后提取唯一值 # 计算字段:走全量查询+enrichment 后提取唯一值
need_full_fetch = True need_full_fetch = True
if not db_col and field not in COMPUTED_FIELDS: if not db_col and field not in COMPUTED_NUMERIC_FIELDS:
return jsonify({"code": 0, "data": []}) return jsonify({"code": 0, "data": []})
from services.db import query from services.db import query
if field in COMPUTED_FIELDS: if field in COMPUTED_NUMERIC_FIELDS:
# 计算字段:获取全量产品,走 enrichment 提取唯一值 # 计算字段:获取全量产品,走 enrichment 提取唯一值
try: try:
all_rows = query(f""" all_rows = query(f"""
@ -1353,7 +1344,6 @@ def planning_column_values():
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()}
gc = _ensure_global_config() gc = _ensure_global_config()
snapshot_map = consecutive_days_map([r.get('product_code') for r in all_rows]) snapshot_map = consecutive_days_map([r.get('product_code') for r in all_rows])
progress_map = unfinished_order_progress_map(r.get('product_code') for r in all_rows)
seen = {} seen = {}
values = [] values = []
@ -1372,7 +1362,6 @@ def planning_column_values():
extended_sales=extended_sales extended_sales=extended_sales
) )
enriched['连续建议天数'] = snapshot_map.get(str(enriched.get('product_code') or ''), 0) enriched['连续建议天数'] = snapshot_map.get(str(enriched.get('product_code') or ''), 0)
enriched['在途进度'] = progress_map.get(str(enriched.get('product_code') or ''), '')
# 联动筛选:有 plan_filter 时过滤其他字段的条件 # 联动筛选:有 plan_filter 时过滤其他字段的条件
if plan_filter_str: if plan_filter_str:
try: try:

View File

@ -1,6 +1,5 @@
"""产品管理路由""" """产品管理路由"""
import os import os
import json
import uuid import uuid
from datetime import datetime from datetime import datetime
from flask import (Blueprint, current_app, request, jsonify, session, redirect, from flask import (Blueprint, current_app, request, jsonify, session, redirect,
@ -20,7 +19,6 @@ from services.forecast import (is_new_product, get_product_grade,
sales_predict_config_cached) sales_predict_config_cached)
from routes.auth import login_required, role_required, operation_required from routes.auth import login_required, role_required, operation_required
from services.field_perms import filter_rows as _filter_field_rows from services.field_perms import filter_rows as _filter_field_rows
from services.order_progress import unfinished_order_progress_map
products_bp = Blueprint('products', __name__) products_bp = Blueprint('products', __name__)
@ -1010,16 +1008,6 @@ def product_detail_with_bom():
SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark
FROM bom_packaging WHERE product_code=%s FROM bom_packaging WHERE product_code=%s
""", (code,)) """, (code,))
pending_snapshot_rows = query("""SELECT formal_order_qty, remark, bom_items, status
FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft')
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC
LIMIT 1""", (code,))
pending_snapshot = pending_snapshot_rows[0] if pending_snapshot_rows else None
if pending_snapshot and isinstance(pending_snapshot.get('bom_items'), str):
try:
pending_snapshot['bom_items'] = json.loads(pending_snapshot['bom_items'])
except (TypeError, ValueError):
pending_snapshot['bom_items'] = []
pkg_map = {} pkg_map = {}
for pr in pkg_rows: for pr in pkg_rows:
mc = str(pr['material_code'] or '').strip() mc = str(pr['material_code'] or '').strip()
@ -1101,7 +1089,6 @@ def product_detail_with_bom():
"total_suggested_qty": int(order_data['total_suggested_qty'] or 0) "total_suggested_qty": int(order_data['total_suggested_qty'] or 0)
}, },
"bom_materials": bom_materials, "bom_materials": bom_materials,
"pending_order": pending_snapshot,
"sales": sales_data, "sales": sales_data,
"orders_in_transit": orders_in_transit "orders_in_transit": orders_in_transit
} }
@ -1165,7 +1152,6 @@ def product_planning_detail():
ext_inv_map=ext_inv_map, ext_inv_map=ext_inv_map,
extended_sales=extended_sales, extended_sales=extended_sales,
) )
row['在途进度'] = unfinished_order_progress_map([pc]).get(pc, '')
inv15_val = float(row.get('加工厂库存', 0) or 0) inv15_val = float(row.get('加工厂库存', 0) or 0)
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。 # 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。

View File

@ -23,7 +23,6 @@ from services.product_type import get_product_type_cached
from routes.planning import (_enrich_row_with_planning_data, _ensure_global_config, from routes.planning import (_enrich_row_with_planning_data, _ensure_global_config,
_personnel_group_members, _personnel_group_for_value) _personnel_group_members, _personnel_group_for_value)
from services.planning_order_flag import normalize_new_old from services.planning_order_flag import normalize_new_old
from services.order_progress import unfinished_order_progress_map
purchase_bp = Blueprint('purchase', __name__) purchase_bp = Blueprint('purchase', __name__)
@ -37,7 +36,7 @@ PLANNING_SALES_SNAPSHOT_FIELDS = (
def _build_planning_sales_snapshot(product_code, product_name='', product_type=''): def _build_planning_sales_snapshot(product_code, product_name='', product_type=''):
"""按计划预测“云仓数据”页签口径生成一次性销量快照。""" """按计划预测默认数据口径生成一次性销量快照。"""
where = ['p.product_code=%s', 'p.deleted_at IS NULL'] where = ['p.product_code=%s', 'p.deleted_at IS NULL']
params = [product_code] params = [product_code]
if product_name: if product_name:
@ -69,7 +68,6 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type='
planning_row.get('product_name', ''), planning_row.get('brand', ''), planning_row.get('product_name', ''), planning_row.get('brand', ''),
planning_row.get('product_type')) planning_row.get('product_type'))
planning_row['批注'] = planning_row.get('remark') or '' planning_row['批注'] = planning_row.get('remark') or ''
extended_sales = compute_product_sales_extended_cached()
planning_row = _enrich_row_with_planning_data( planning_row = _enrich_row_with_planning_data(
planning_row, planning_row,
compute_product_sales_cached(), compute_product_sales_cached(),
@ -80,42 +78,9 @@ def _build_planning_sales_snapshot(product_code, product_name='', product_type='
get_global_config(), get_global_config(),
cloud_inv_map=compute_cloud_inventory_cached(), cloud_inv_map=compute_cloud_inventory_cached(),
ext_inv_map=compute_external_inventory_cached(), ext_inv_map=compute_external_inventory_cached(),
extended_sales=extended_sales, extended_sales=compute_product_sales_extended_cached(),
) )
# 与“计划预测 → 详情 → 销量信息 → 云仓数据”保持同一取数口径。 return {field: planning_row.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS}
cloud_sales = extended_sales.get('cloud', {}).get(product_code, {})
cloud_inventory = planning_row.get('云仓库存', 0)
cloud_3 = cloud_sales.get('3天平均销量', 0)
cloud_7 = cloud_sales.get('7天平均销量', 0)
cloud_14 = cloud_sales.get('14天平均销量', 0)
cloud_forecast = planning_row.get('云仓日销量预测', 0)
try:
cloud_forecast_number = float(cloud_forecast) if cloud_forecast not in ('近期无销售', '', None) else 0
except (TypeError, ValueError):
cloud_forecast_number = 0
cloud_snapshot = {
'产品分级': planning_row.get('产品分级', ''),
'昨日销量': cloud_sales.get('昨日销量', 0),
'销售趋势': _determine_trend(cloud_3, cloud_7, cloud_14),
'库存': cloud_inventory,
'加工厂库存': planning_row.get('加工厂库存', 0),
'生产在途': planning_row.get('生产在途', 0),
'采购在途': planning_row.get('采购在途', 0),
'日销量预测': cloud_forecast,
'3天平均销量': cloud_3,
'7天平均销量': cloud_7,
'14天平均销量': cloud_14,
'库存可支撑天数': round(float(cloud_inventory or 0) / cloud_forecast_number, 1)
if cloud_forecast_number else 999,
'加工厂库存支撑天数': round(float(planning_row.get('加工厂库存', 0) or 0) / cloud_forecast_number, 1)
if cloud_forecast_number else 999,
'总支撑天数': planning_row.get('云仓支撑天数', 999),
'30天平均销量': cloud_sales.get('30天平均销量', 0),
'极值判断': '-',
}
return {'__scope': 'cloud', **{
field: cloud_snapshot.get(field, '-') for field in PLANNING_SALES_SNAPSHOT_FIELDS
}}
# 海外产品清单专属备货通知:名单是非敏感业务配置,Webhook/加签只从运行环境读取, # 海外产品清单专属备货通知:名单是非敏感业务配置,Webhook/加签只从运行环境读取,
@ -591,17 +556,13 @@ def _normalize_backup_bom(items, enrich=True, source_product_code='', source_ord
if source_product_code and source_ordered_at: if source_product_code and source_ordered_at:
orderer_rows = query( orderer_rows = query(
"""SELECT GROUP_CONCAT(DISTINCT NULLIF(`下单人`, '') """SELECT GROUP_CONCAT(DISTINCT NULLIF(`下单人`, '')
ORDER BY id SEPARATOR '、') AS packaging_orderer, ORDER BY id SEPARATOR '、') AS packaging_orderer
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(`进度`, '')
ORDER BY id DESC SEPARATOR '|||DY_PROGRESS|||'), '|||DY_PROGRESS|||', 1) AS order_progress
FROM packaging_orders FROM packaging_orders
WHERE BINARY `编码`=BINARY %s AND BINARY `来源产品编码`=BINARY %s AND `下单时间`=%s""", WHERE BINARY `编码`=BINARY %s AND BINARY `来源产品编码`=BINARY %s AND `下单时间`=%s""",
(code, source_product_code, source_ordered_at) (code, source_product_code, source_ordered_at)
) )
current['packaging_orderer'] = (orderer_rows[0].get('packaging_orderer') current['packaging_orderer'] = (orderer_rows[0].get('packaging_orderer')
if orderer_rows else '') or '' if orderer_rows else '') or ''
current['order_progress'] = (orderer_rows[0].get('order_progress')
if orderer_rows else '') or ''
qty = int(current.get('actual_quantity') or 0) qty = int(current.get('actual_quantity') or 0)
current.setdefault('price_audit_status', '未审核') current.setdefault('price_audit_status', '未审核')
current['amount'] = round(qty * float(current.get('unit_price') or 0), 2) current['amount'] = round(qty * float(current.get('unit_price') or 0), 2)
@ -958,8 +919,6 @@ def purchase_backup_detail():
row['sales_info'] = {} row['sales_info'] = {}
except (TypeError, ValueError, json.JSONDecodeError): except (TypeError, ValueError, json.JSONDecodeError):
row['sales_info'] = {} row['sales_info'] = {}
# 旧历史快照没有口径标记,按原“默认数据”解释;新快照明确标记为云仓。
row['sales_snapshot_scope'] = row['sales_info'].pop('__scope', 'default') if row['sales_info'] else ''
row['sales_snapshot_missing'] = not bool(row['sales_info']) row['sales_snapshot_missing'] = not bool(row['sales_info'])
return jsonify({"code": 0, "data": row}) return jsonify({"code": 0, "data": row})
@ -1105,12 +1064,9 @@ def purchase_add_pending():
update_fields = {'formal_order_qty', 'audit_price', 'actual_order_qty', 'suggested_qty', 'remark', 'bom_items'} update_fields = {'formal_order_qty', 'audit_price', 'actual_order_qty', 'suggested_qty', 'remark', 'bom_items'}
is_update = bool(keys & update_fields) is_update = bool(keys & update_fields)
# 先复用当前待下单行,否则复用计划预测保存的草稿;ordered/rolled_back 历史不参与。 # 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。
existing_pending = query("""SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at, status existing_pending = query("SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft') if existing_pending and not is_update:
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC
LIMIT 1""", (pc,))
if existing_pending and existing_pending[0].get('status') == 'pending' and not is_update:
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"}) return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
# 查询该产品的BOM物料清单 # 查询该产品的BOM物料清单
@ -1235,44 +1191,20 @@ def purchase_add_pending():
@purchase_bp.route('/api/purchase/update_pending', methods=['POST']) @purchase_bp.route('/api/purchase/update_pending', methods=['POST'])
@login_required @login_required
def purchase_update_pending(): def purchase_update_pending():
"""保存计划预测下单信息或更新待下单记录;未下单的内容保存在隐藏草稿中。""" """更新待下单记录的备注等字段"""
if not (operation_required('purchase', 'edit') or operation_required('planning', 'edit')): denied = _require_purchase_operation('purchase', 'edit')
return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"}) if denied:
data = request.get_json(silent=True) or {} return denied
data = request.json
product_code = data.get('product_code', '') product_code = data.get('product_code', '')
if not product_code: if not product_code:
return jsonify({"code": 400, "msg": "参数错误"}) return jsonify({"code": 400, "msg": "参数错误"})
current_rows = query( current_rows = query(
"SELECT id, remark, bom_items, formal_order_qty, status FROM pending_orders " "SELECT remark, bom_items FROM pending_orders WHERE product_code=%s AND status='pending' LIMIT 1",
"WHERE product_code=%s AND status IN ('pending','draft') "
"ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC LIMIT 1",
(product_code,) (product_code,)
) )
current = current_rows[0] if current_rows else {} current = current_rows[0] if current_rows else {}
if not current:
product_rows = query("SELECT product_name, product_type FROM products WHERE product_code=%s AND deleted_at IS NULL LIMIT 1",
(product_code,))
if not product_rows:
return jsonify({"code": 404, "msg": "未找到该产品,无法保存下单草稿"})
requested_bom = data.get('bom_items')
normalized_for_insert = _normalize_pending_bom_items(requested_bom) if requested_bom is not None else []
product_actual_qty = _extract_product_actual_qty(normalized_for_insert, product_code)
if product_actual_qty is None:
try:
product_actual_qty = int(data.get('formal_order_qty') or 0)
except (TypeError, ValueError):
product_actual_qty = 0
bom_json = json.dumps(normalized_for_insert, ensure_ascii=False) if requested_bom is not None else None
draft_id = query("""INSERT INTO pending_orders
(product_code, product_name, product_type, formal_order_qty, status, created_by, bom_items, remark)
VALUES (%s, %s, %s, %s, 'draft', %s, %s, %s)""",
(product_code, product_rows[0].get('product_name') or '',
product_rows[0].get('product_type') or '', product_actual_qty,
session.get('real_name', '') or session.get('username', ''), bom_json,
data.get('remark') or ''))
current = {'id': draft_id, 'remark': '', 'bom_items': None,
'formal_order_qty': 0, 'status': 'draft'}
changes = [] changes = []
# 支持更新的字段 # 支持更新的字段
@ -1281,10 +1213,11 @@ def purchase_update_pending():
old_remark = current.get('remark') or '' old_remark = current.get('remark') or ''
if old_remark != remark: if old_remark != remark:
changes.append(f'备注:{old_remark or "(空)"} → {remark or "(空)"}') changes.append(f'备注:{old_remark or "(空)"} → {remark or "(空)"}')
query("UPDATE pending_orders SET remark=%s, updated_at=NOW() WHERE id=%s", (remark, current['id'])) query("UPDATE pending_orders SET remark=%s WHERE product_code=%s AND status='pending'", (remark, product_code))
bom_items = data.get('bom_items') bom_items = data.get('bom_items')
if bom_items is not None: if bom_items is not None:
import json
normalized_bom_items = _normalize_pending_bom_items(bom_items) normalized_bom_items = _normalize_pending_bom_items(bom_items)
product_actual_qty = _extract_product_actual_qty(normalized_bom_items, product_code) product_actual_qty = _extract_product_actual_qty(normalized_bom_items, product_code)
bom_str = json.dumps(normalized_bom_items, ensure_ascii=False) bom_str = json.dumps(normalized_bom_items, ensure_ascii=False)
@ -1353,13 +1286,12 @@ def purchase_update_pending():
f'{display_code} {label}:{old_display} → {new_display}' f'{display_code} {label}:{old_display} → {new_display}'
) )
if product_actual_qty is None: if product_actual_qty is None:
query("UPDATE pending_orders SET bom_items=%s, updated_at=NOW() WHERE id=%s", (bom_str, current['id'])) query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='pending'", (bom_str, product_code))
else: else:
old_formal_qty = int((current.get('formal_order_qty') or 0)) old_formal_qty = int((current.get('formal_order_qty') or 0))
if old_formal_qty != product_actual_qty: if old_formal_qty != product_actual_qty:
changes.append(f'成品实际下单数量:{old_formal_qty} → {product_actual_qty}') changes.append(f'成品实际下单数量:{old_formal_qty} → {product_actual_qty}')
query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s, updated_at=NOW() WHERE id=%s", query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s WHERE product_code=%s AND status='pending'", (product_actual_qty, bom_str, product_code))
(product_actual_qty, bom_str, current['id']))
# 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。 # 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。
_sync_pending_pre_packaging(product_code, normalized_bom_items) _sync_pending_pre_packaging(product_code, normalized_bom_items)
@ -1844,9 +1776,6 @@ def purchase_batch_add_pending():
if existing: if existing:
skipped += 1 skipped += 1
continue continue
saved_draft = query("""SELECT id, new_old_product, sales_snapshot, sales_snapshot_at
FROM pending_orders WHERE product_code=%s AND status='draft'
ORDER BY updated_at DESC, id DESC LIMIT 1""", (pc,))
p = prod_map.get(pc, {}) p = prod_map.get(pc, {})
# 查询BOM物料清单 # 查询BOM物料清单
bom_rows = query("SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s", (pc,)) bom_rows = query("SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s", (pc,))
@ -1856,10 +1785,6 @@ def purchase_batch_add_pending():
'material_name': r['material_name'], 'material_name': r['material_name'],
'quantity': float(r['quantity']) 'quantity': float(r['quantity'])
} for r in bom_rows], ensure_ascii=False) if bom_rows else None } for r in bom_rows], ensure_ascii=False) if bom_rows else None
if saved_draft and saved_draft[0].get('sales_snapshot'):
sales_snapshot_json = saved_draft[0]['sales_snapshot']
sales_snapshot_at = saved_draft[0].get('sales_snapshot_at') or datetime.now()
else:
try: try:
sales_snapshot = _build_planning_sales_snapshot( sales_snapshot = _build_planning_sales_snapshot(
pc, p.get('product_name', ''), p.get('product_type', '')) pc, p.get('product_name', ''), p.get('product_type', ''))
@ -1867,19 +1792,6 @@ def purchase_batch_add_pending():
return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"}) return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"})
sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str) sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str)
sales_snapshot_at = datetime.now() sales_snapshot_at = datetime.now()
if saved_draft:
new_old_product = normalize_new_old(saved_draft[0].get('new_old_product') or p.get('planning_new_old'))
query("""UPDATE pending_orders SET product_name=%s, product_type=%s, supplier_name=%s,
lead_time=%s, logistics_time=%s, procurement_follower=%s, procurement_planner=%s,
platform=%s, operator=%s, new_old_product=%s, sales_snapshot=%s,
sales_snapshot_at=%s, status='pending', updated_at=NOW() WHERE id=%s""",
(p.get('product_name', ''), p.get('product_type', ''), p.get('supplier_name', ''),
int(p.get('lead_time', 0) or 0), int(p.get('logistics_time', 0) or 0),
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '',
p.get('platform', '') or '', p.get('operator', '') or '', new_old_product,
sales_snapshot_json, sales_snapshot_at, saved_draft[0]['id']))
added += 1
continue
query(""" query("""
INSERT INTO pending_orders INSERT INTO pending_orders
(product_code, product_name, product_type, product_grade, supplier_name, (product_code, product_name, product_type, product_grade, supplier_name,
@ -2558,7 +2470,7 @@ def _arrival_operator_name():
def _ensure_arrival_batches_table(): def _ensure_arrival_batches_table():
"""Ensure arrival batch storage records which immutable fields were actually filled.""" """Ensure arrival batch storage includes the defective quantity field."""
global _ARRIVAL_BATCHES_SCHEMA_READY global _ARRIVAL_BATCHES_SCHEMA_READY
if _ARRIVAL_BATCHES_SCHEMA_READY: if _ARRIVAL_BATCHES_SCHEMA_READY:
return return
@ -2571,9 +2483,6 @@ def _ensure_arrival_batches_table():
defective_qty INT NOT NULL DEFAULT 0, defective_qty INT NOT NULL DEFAULT 0,
operator_name VARCHAR(100) NOT NULL DEFAULT '', operator_name VARCHAR(100) NOT NULL DEFAULT '',
operated_at DATETIME NULL, operated_at DATETIME NULL,
arrival_date_filled TINYINT(1) NOT NULL DEFAULT 0,
arrival_qty_filled TINYINT(1) NOT NULL DEFAULT 0,
defective_qty_filled TINYINT(1) NOT NULL DEFAULT 0,
remark TEXT, remark TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""") )""")
@ -2591,41 +2500,21 @@ def _ensure_arrival_batches_table():
query("""ALTER TABLE arrival_batches query("""ALTER TABLE arrival_batches
ADD COLUMN operated_at DATETIME NULL AFTER operator_name""") ADD COLUMN operated_at DATETIME NULL AFTER operator_name""")
query("UPDATE arrival_batches SET operated_at=created_at WHERE operated_at IS NULL") query("UPDATE arrival_batches SET operated_at=created_at WHERE operated_at IS NULL")
# 已保存字段一旦填写即锁定;空字段允许后续补填一次。历史 0 按“未填写”迁移,
# 因为旧前端会把空输入强制保存为 0,无法再可靠区分显式 0 与空值。
if 'arrival_date_filled' not in column_names:
query("""ALTER TABLE arrival_batches
ADD COLUMN arrival_date_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER operated_at""")
query("UPDATE arrival_batches SET arrival_date_filled=IF(arrival_date IS NULL,0,1)")
if 'arrival_qty_filled' not in column_names:
query("""ALTER TABLE arrival_batches
ADD COLUMN arrival_qty_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER arrival_date_filled""")
query("UPDATE arrival_batches SET arrival_qty_filled=IF(arrival_qty IS NULL OR arrival_qty=0,0,1)")
if 'defective_qty_filled' not in column_names:
query("""ALTER TABLE arrival_batches
ADD COLUMN defective_qty_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER arrival_qty_filled""")
query("UPDATE arrival_batches SET defective_qty_filled=IF(defective_qty IS NULL OR defective_qty=0,0,1)")
_ARRIVAL_BATCHES_SCHEMA_READY = True _ARRIVAL_BATCHES_SCHEMA_READY = True
def _optional_int(data, key, label): def _arrival_batch_quantities(data):
"""Return (value, filled), preserving the distinction between blank and zero.""" """Parse and validate gross/defective arrival quantities."""
raw = data.get(key)
if raw in (None, ''):
return 0, False
try: try:
return int(raw), True arrival_qty = int(data.get('arrival_qty') or 0)
defective_qty = int(data.get('defective_qty') or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError(f'{label}必须为整数') raise ValueError('到货数量和不良品数量必须为整数')
if arrival_qty < 0 or defective_qty < 0:
raise ValueError('到货数量和不良品数量不能为负数')
def _arrival_batch_quantities(data, order_type='domestic'): if defective_qty > arrival_qty:
"""Parse quantities; defective quantity may be any integer for all order types.""" raise ValueError('不良品数量不能大于到货数量')
arrival_qty, arrival_qty_filled = _optional_int(data, 'arrival_qty', '到货数量') return arrival_qty, defective_qty
defective_qty, defective_qty_filled = _optional_int(data, 'defective_qty', '不良品数量')
if arrival_qty_filled and arrival_qty < 0:
raise ValueError('到货数量不能为负数')
return arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled
def _domestic_order_pre_packaging(order_id): def _domestic_order_pre_packaging(order_id):
@ -2728,8 +2617,7 @@ def domestic_orders_detail_extras():
_ensure_arrival_batches_table() _ensure_arrival_batches_table()
arrival_batches = list(query(""" arrival_batches = list(query("""
SELECT id, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark, SELECT id, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark
arrival_date_filled, arrival_qty_filled, defective_qty_filled
FROM arrival_batches FROM arrival_batches
WHERE order_id=%s AND order_type=%s WHERE order_id=%s AND order_type=%s
ORDER BY arrival_date DESC, id DESC ORDER BY arrival_date DESC, id DESC
@ -2770,28 +2658,22 @@ def domestic_orders_add_batch():
return jsonify({"code": 400, "msg": "订单类型错误"}) return jsonify({"code": 400, "msg": "订单类型错误"})
_ensure_arrival_batches_table() _ensure_arrival_batches_table()
try: try:
arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled = _arrival_batch_quantities(data, order_type) arrival_qty, defective_qty = _arrival_batch_quantities(data)
except ValueError as exc: except ValueError as exc:
return jsonify({"code": 400, "msg": str(exc)}) return jsonify({"code": 400, "msg": str(exc)})
operator_name = _arrival_operator_name() operator_name = _arrival_operator_name()
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
row_id = query("""INSERT INTO arrival_batches row_id = query("""INSERT INTO arrival_batches
(order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, (order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark)
arrival_date_filled, arrival_qty_filled, defective_qty_filled, remark) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", (
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (
order_id, order_type, data.get('arrival_date') or None, order_id, order_type, data.get('arrival_date') or None,
arrival_qty, defective_qty, operator_name, operated_at, arrival_qty, defective_qty, operator_name, operated_at, data.get('remark') or ''
1 if data.get('arrival_date') not in (None, '') else 0,
int(arrival_qty_filled), int(defective_qty_filled), data.get('remark') or ''
)) ))
sync_order_arrival_totals(order_id, order_type) sync_order_arrival_totals(order_id, order_type)
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type) page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
add_operation_log('到货批次及数量', f"新增到货批次:到货日期={data.get('arrival_date') or '空'},数量={arrival_qty},不良品数量={defective_qty},备注={data.get('remark') or '空'}(订单ID={order_id})", page=page_name, field='到货批次及数量') add_operation_log('到货批次及数量', f"新增到货批次:到货日期={data.get('arrival_date') or '空'},数量={arrival_qty},不良品数量={defective_qty},备注={data.get('remark') or '空'}(订单ID={order_id})", page=page_name, field='到货批次及数量')
return jsonify({"code": 0, "msg": "添加到货批次成功", "data": { return jsonify({"code": 0, "msg": "添加到货批次成功", "data": {
"id": row_id, "operator_name": operator_name, "operated_at": operated_at, "id": row_id, "operator_name": operator_name, "operated_at": operated_at
"arrival_date_filled": 1 if data.get('arrival_date') not in (None, '') else 0,
"arrival_qty_filled": int(arrival_qty_filled),
"defective_qty_filled": int(defective_qty_filled),
}}) }})
@ -2809,56 +2691,26 @@ def domestic_orders_update_batch():
return jsonify({"code": 400, "msg": "缺少批次ID"}) return jsonify({"code": 400, "msg": "缺少批次ID"})
_ensure_arrival_batches_table() _ensure_arrival_batches_table()
batch_rows = query( batch_rows = query(
"SELECT order_id, arrival_date, arrival_qty, defective_qty, remark, operator_name, operated_at, " "SELECT order_id, arrival_date, arrival_qty, defective_qty, remark, operator_name, operated_at "
"arrival_date_filled, arrival_qty_filled, defective_qty_filled "
"FROM arrival_batches WHERE id=%s AND order_type=%s", "FROM arrival_batches WHERE id=%s AND order_type=%s",
(batch_id, order_type), (batch_id, order_type),
) )
if not batch_rows: if not batch_rows:
return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"}) return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"})
current = batch_rows[0] current = batch_rows[0]
# 已填写字段不可修改;首次保存时留空的字段允许补填一次,补填后立即锁定。 # 已保存批次的核心字段不可修改,只允许在备注末尾追加留痕。
update_fields = []
update_params = []
supplemented = []
if data.get('arrival_date') not in (None, ''): if data.get('arrival_date') not in (None, ''):
current_date = current.get('arrival_date') current_date = current.get('arrival_date')
if hasattr(current_date, 'isoformat'): if hasattr(current_date, 'isoformat'):
current_date = current_date.isoformat() current_date = current_date.isoformat()
requested_date = str(data.get('arrival_date') or '')[:10] if str(current_date or '')[:10] != str(data.get('arrival_date') or '')[:10]:
date_locked = order_type != 'packaging' or current.get('arrival_date_filled')
if date_locked and str(current_date or '')[:10] != requested_date:
return jsonify({"code": 400, "msg": "到货日期保存后不允许修改"}), 400 return jsonify({"code": 400, "msg": "到货日期保存后不允许修改"}), 400
if order_type == 'packaging' and not current.get('arrival_date_filled'):
update_fields.extend(['arrival_date=%s', 'arrival_date_filled=1'])
update_params.append(requested_date)
supplemented.append(f'到货日期={requested_date}')
if data.get('arrival_qty') not in (None, ''): if data.get('arrival_qty') not in (None, ''):
try: if int(data.get('arrival_qty') or 0) != int(current.get('arrival_qty') or 0):
requested_qty = int(data.get('arrival_qty'))
except (TypeError, ValueError):
return jsonify({"code": 400, "msg": "到货数量必须为整数"}), 400
if requested_qty < 0:
return jsonify({"code": 400, "msg": "到货数量不能为负数"}), 400
qty_locked = order_type != 'packaging' or current.get('arrival_qty_filled')
if qty_locked and requested_qty != int(current.get('arrival_qty') or 0):
return jsonify({"code": 400, "msg": "到货数量保存后不允许修改"}), 400 return jsonify({"code": 400, "msg": "到货数量保存后不允许修改"}), 400
if order_type == 'packaging' and not current.get('arrival_qty_filled'):
update_fields.extend(['arrival_qty=%s', 'arrival_qty_filled=1'])
update_params.append(requested_qty)
supplemented.append(f'到货数量={requested_qty}')
if data.get('defective_qty') not in (None, ''): if data.get('defective_qty') not in (None, ''):
try: if int(data.get('defective_qty') or 0) != int(current.get('defective_qty') or 0):
requested_defective = int(data.get('defective_qty'))
except (TypeError, ValueError):
return jsonify({"code": 400, "msg": "不良品数量必须为整数"}), 400
defective_locked = current.get('defective_qty_filled')
if defective_locked and requested_defective != int(current.get('defective_qty') or 0):
return jsonify({"code": 400, "msg": "不良品数量保存后不允许修改"}), 400 return jsonify({"code": 400, "msg": "不良品数量保存后不允许修改"}), 400
if not current.get('defective_qty_filled'):
update_fields.extend(['defective_qty=%s', 'defective_qty_filled=1'])
update_params.append(requested_defective)
supplemented.append(f'不良品数量={requested_defective}')
operator_name = _arrival_operator_name() operator_name = _arrival_operator_name()
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
old_remark = str(current.get('remark') or '') old_remark = str(current.get('remark') or '')
@ -2871,14 +2723,10 @@ def domestic_orders_update_batch():
if added: if added:
trace = f"[{operated_at} {operator_name or '未知账号'}] {added}" trace = f"[{operated_at} {operator_name or '未知账号'}] {added}"
final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace
update_fields.append('remark=%s')
update_params.append(final_remark)
if update_fields:
query( query(
"UPDATE arrival_batches SET " + ', '.join(update_fields) + " WHERE id=%s AND order_type=%s", "UPDATE arrival_batches SET remark=%s WHERE id=%s AND order_type=%s",
tuple(update_params) + (batch_id, order_type), (final_remark, batch_id, order_type),
) )
sync_order_arrival_totals(current['order_id'], order_type)
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type) page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
if final_remark != old_remark: if final_remark != old_remark:
add_operation_log( add_operation_log(
@ -2887,21 +2735,11 @@ def domestic_orders_update_batch():
page=page_name, page=page_name,
field='到货批次及数量', field='到货批次及数量',
) )
if supplemented:
add_operation_log(
'到货批次及数量',
f"补填到货批次字段:{';'.join(supplemented)}(批次ID={batch_id},订单ID={current['order_id']})",
page=page_name,
field='到货批次及数量',
)
return jsonify({"code": 0, "msg": "更新成功", "data": { return jsonify({"code": 0, "msg": "更新成功", "data": {
"remark": final_remark, "remark": final_remark,
"operator_name": current.get('operator_name') or '', "operator_name": current.get('operator_name') or '',
"operated_at": current.get('operated_at').strftime('%Y-%m-%d %H:%M:%S') "operated_at": current.get('operated_at').strftime('%Y-%m-%d %H:%M:%S')
if hasattr(current.get('operated_at'), 'strftime') else str(current.get('operated_at') or '')[:19], if hasattr(current.get('operated_at'), 'strftime') else str(current.get('operated_at') or '')[:19]
"arrival_date_filled": 1 if current.get('arrival_date_filled') or data.get('arrival_date') not in (None, '') else 0,
"arrival_qty_filled": 1 if current.get('arrival_qty_filled') or data.get('arrival_qty') not in (None, '') else 0,
"defective_qty_filled": 1 if current.get('defective_qty_filled') or data.get('defective_qty') not in (None, '') else 0,
}}) }})
@ -3665,7 +3503,44 @@ def packaging_orders_delete():
# ─── 成品提货表 API ─── # ─── 成品提货表 API ───
def _finished_order_progress_map(): def _finished_order_progress_map():
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。""" """读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
return unfinished_order_progress_map() rows = []
for source, sql in (
('domestic', """
SELECT `货品编号` AS product_code, `进度`,
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
id
FROM domestic_orders
WHERE `状态`='未完结'
"""),
('overseas', """
SELECT `产品编码` AS product_code, `进度`,
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
id
FROM overseas_orders
WHERE `状态`='未完结'
"""),
):
for row in query(sql):
row['_source_order'] = 0 if source == 'domestic' else 1
rows.append(row)
def sort_key(row):
order_date = row.get('order_date')
if order_date and hasattr(order_date, 'strftime'):
date_text = order_date.strftime('%Y-%m-%d %H:%M:%S')
else:
date_text = str(order_date or '').strip()
return (0 if date_text else 1, date_text, row.get('_source_order', 0), row.get('id') or 0)
rows.sort(key=sort_key)
progress_map = {}
for row in rows:
product_code = str(row.get('product_code') or '').strip()
if product_code and product_code not in progress_map:
progress_map[product_code] = row.get('进度') or ''
return progress_map
def _finished_inventory_maps(): def _finished_inventory_maps():

View File

@ -80,19 +80,18 @@ def warehouse_save():
if not code or not name: if not code or not name:
return jsonify({"code": 1, "msg": "仓库编号和名称不能为空"}) return jsonify({"code": 1, "msg": "仓库编号和名称不能为空"})
exists = query("SELECT id, warehouse_property, disabled FROM warehouses WHERE warehouse_code=%s", (code,)) exists = query("SELECT id, warehouse_property FROM warehouses WHERE warehouse_code=%s", (code,))
property_changed = not exists or (exists[0].get('warehouse_property') or '').strip() != warehouse_property property_changed = not exists or (exists[0].get('warehouse_property') or '').strip() != warehouse_property
disabled_changed = not exists or (exists[0].get('disabled') or '否').strip() != disabled
if exists: if exists:
query("UPDATE warehouses SET warehouse_name=%s, category=%s, remark=%s, warehouse_property=%s, disabled=%s WHERE warehouse_code=%s", query("UPDATE warehouses SET warehouse_name=%s, category=%s, remark=%s, warehouse_property=%s, disabled=%s WHERE warehouse_code=%s",
(name, category, remark, warehouse_property, disabled, code)) (name, category, remark, warehouse_property, disabled, code))
else: else:
query("INSERT INTO warehouses (warehouse_code, warehouse_name, category, remark, warehouse_property, disabled) VALUES (%s,%s,%s,%s,%s,%s)", query("INSERT INTO warehouses (warehouse_code, warehouse_name, category, remark, warehouse_property, disabled) VALUES (%s,%s,%s,%s,%s,%s)",
(code, name, category, remark, warehouse_property, disabled)) (code, name, category, remark, warehouse_property, disabled))
if property_changed or disabled_changed: if property_changed:
from services.cache import refresh_all_caches from services.cache import refresh_all_caches
refresh_all_caches() refresh_all_caches()
return jsonify({"code": 0, "msg": "保存成功", "cache_refreshed": property_changed or disabled_changed}) return jsonify({"code": 0, "msg": "保存成功", "cache_refreshed": property_changed})
@warehouse_bp.route('/api/warehouse/delete', methods=['POST']) @warehouse_bp.route('/api/warehouse/delete', methods=['POST'])
@ -126,17 +125,16 @@ def warehouse_update_field():
if field not in allowed: if field not in allowed:
return jsonify({"code": 400, "msg": "不允许的字段"}) return jsonify({"code": 400, "msg": "不允许的字段"})
property_changed = False property_changed = False
disabled_changed = field == 'disabled'
if field == 'warehouse_property': if field == 'warehouse_property':
old_rows = query("SELECT warehouse_property FROM warehouses WHERE warehouse_code=%s", (code,)) old_rows = query("SELECT warehouse_property FROM warehouses WHERE warehouse_code=%s", (code,))
old_value = (old_rows[0].get('warehouse_property') or '').strip() if old_rows else '' old_value = (old_rows[0].get('warehouse_property') or '').strip() if old_rows else ''
property_changed = old_value != str(value or '').strip() property_changed = old_value != str(value or '').strip()
try: try:
query(f"UPDATE warehouses SET {field}=%s WHERE warehouse_code=%s", (value, code)) query(f"UPDATE warehouses SET {field}=%s WHERE warehouse_code=%s", (value, code))
if property_changed or disabled_changed: if property_changed:
from services.cache import refresh_all_caches from services.cache import refresh_all_caches
refresh_all_caches() refresh_all_caches()
return jsonify({"code": 0, "msg": "更新成功", "cache_refreshed": property_changed or disabled_changed}) return jsonify({"code": 0, "msg": "更新成功", "cache_refreshed": property_changed})
except Exception as e: except Exception as e:
return jsonify({"code": 500, "msg": str(e)}) return jsonify({"code": 500, "msg": str(e)})

View File

@ -1,29 +0,0 @@
"""首次初始化部门规则,默认只检查,--apply 才写入。"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('workbook')
parser.add_argument('--apply', action='store_true')
parser.add_argument('--local', action='store_true')
args = parser.parse_args()
if args.local:
import config
config.DB_CONFIG.update(user='dy_test', password=Path('.local-test-db-password').read_text().strip())
from services.department_matching import workbook_records, import_workbook, check_samples
rows = workbook_records(args.workbook)
for i, row in enumerate(rows, 1):
row['id'] = i
print(json.dumps({'records': len(rows), 'validation': check_samples(rows)}, ensure_ascii=False))
if args.apply:
print(json.dumps({'imported': import_workbook(args.workbook)}, ensure_ascii=False))
if __name__ == '__main__':
main()

View File

@ -1,29 +0,0 @@
#!/usr/bin/env python3
"""每日同步钉钉组织架构。"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import config
local_password = ROOT / '.local-test-db-password'
if local_password.exists():
config.DB_CONFIG.update({
'user': 'dy_test',
'password': local_password.read_text(encoding='ascii').strip(),
'unix_socket': '/run/mysqld/mysqld.sock',
})
from services.dingtalk_organization import sync_organization
if __name__ == '__main__':
try:
print(json.dumps(sync_organization('scheduled'), ensure_ascii=False))
except Exception as exc:
print(json.dumps({'status': 'failed', 'message': str(exc)}, ensure_ascii=False), file=sys.stderr)
raise SystemExit(1)

View File

@ -769,256 +769,6 @@ def _h_supplier_delivery(params):
rows = _apply_filters(rows, params) rows = _apply_filters(rows, params)
return {'columns': fields, 'rows': rows} return {'columns': fields, 'rows': rows}
def _h_operation_history(params):
"""按板块及业务对象检索结构化审计日志,旧记录允许文本兼容匹配。"""
import json
import re
code = str(params.get('product_code') or '').strip()
product_name = str(params.get('product_name') or '').strip()
identity = code or product_name
module = str(params.get('module') or '').strip()
field = str(params.get('field') or '').strip()
operator = str(params.get('operator') or '').strip()
start_date = str(params.get('start_date') or '').strip()
end_date = str(params.get('end_date') or '').strip()
try:
limit = max(1, min(int(params.get('limit') or 100), 200))
except (TypeError, ValueError):
limit = 100
where = ["l.action <> '登录'",
"COALESCE(l.event_type,'') NOT IN ('import','export','ai_analysis','ai_report','ai_simulation','login')",
"COALESCE(l.action,'') NOT LIKE '%%导入%%'"]
values = []
allowed_pages = params.get('_allowed_pages')
if allowed_pages is not None:
allowed_pages = [str(page) for page in allowed_pages if str(page).strip()]
if not allowed_pages:
return {'columns': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
'rows': [], 'summary': {'row_count': 0, 'returned_rows': 0,
'exact_object_matches': 0, 'text_fallback_matches': 0, 'limit': limit}}
where.append("COALESCE(NULLIF(l.page,''),l.action) IN (" + ','.join(['%s'] * len(allowed_pages)) + ")")
values.extend(allowed_pages)
if code:
boundary_pattern = r'(^|[^A-Za-z0-9-])' + re.escape(code) + r'($|[^A-Za-z0-9-])'
where.append("(l.object_id=%s OR l.new_values REGEXP %s OR l.old_values REGEXP %s OR l.detail REGEXP %s)")
values.extend((code, boundary_pattern, boundary_pattern, boundary_pattern))
elif product_name:
where.append("(l.new_values LIKE %s OR l.old_values LIKE %s OR l.detail LIKE %s)")
values.extend(['%' + product_name + '%'] * 3)
if module:
where.append("COALESCE(NULLIF(l.page,''),l.action) LIKE %s")
values.append('%' + module + '%')
if field:
where.append("(COALESCE(l.field,'') LIKE %s OR l.new_values LIKE %s OR l.old_values LIKE %s)")
values.extend(['%' + field + '%'] * 3)
if operator:
where.append("(u.real_name LIKE %s OR u.username LIKE %s)")
values.extend(['%' + operator + '%'] * 2)
if start_date:
where.append('l.created_at >= %s')
values.append(start_date + ' 00:00:00')
if end_date:
where.append('l.created_at < DATE_ADD(%s, INTERVAL 1 DAY)')
values.append(end_date)
where_sql = ' AND '.join(where)
total_rows = query('SELECT COUNT(*) AS total FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id WHERE ' + where_sql,
values)[0].get('total', 0)
rows = query("""SELECT l.id,l.created_at,l.user_id,l.page,l.action,l.field,l.object_type,l.object_id,
l.old_values,l.new_values,l.detail,l.result_status,l.request_id,
u.real_name,u.username
FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id
WHERE """ + where_sql + ' ORDER BY l.created_at DESC,l.id DESC LIMIT %s',
values + [limit])
def decode(value):
if not value:
return {}
try:
parsed = json.loads(value) if isinstance(value, str) else value
return scrub(parsed) if isinstance(parsed, (dict, list)) else {'内容': parsed}
except (TypeError, ValueError):
return {'历史原文': str(value)[:1000]}
def scrub(value):
sensitive = ('password', 'token', 'secret', 'authorization', 'cookie', 'api_key',
'密码', '令牌', '密钥', '授权信息')
if isinstance(value, dict):
return {key: ('[已隐藏]' if any(mark in str(key).lower() for mark in sensitive)
else scrub(item)) for key, item in value.items()}
if isinstance(value, list):
return [scrub(item) for item in value]
return value
field_labels = {
'product_code': '货品编号', 'product_name': '货品名称', 'product_type': '货品类型',
'brand': '品牌', 'supplier': '供应商', 'supplier_code': '供应商编码', 'supplier_name': '供应商名称',
'planner': '采购计划', 'purchase_planner': '采购计划', 'follower': '采购跟单',
'purchase_follower': '采购跟单', 'operator': '运营', 'developer': '开发专员',
'barcode': '条形码', 'category': '类目', 'platform': '平台', 'department': '部门',
'group_name': '组别', 'status': '状态', 'progress': '进度', 'remark': '备注',
'order_remark': '下单备注', 'purchase_plan': '采购计划', 'lead_time': '生产货期',
'logistics_time': '物流时间', 'moq': '最低起订量', 'cost_price': '成本价',
'price': '单价', 'quantity': '数量', 'order_quantity': '下单数量',
'inventory': '库存', 'stock': '库存', 'arrival_date': '到货日期',
'completion_date': '完结日期', 'new_old': '新旧品', 'is_new': '新旧品',
'id': '记录编号', 'product_codes': '货品编号列表', 'materials': '物料列表',
'bom_data': 'BOM数据', 'material_code': '物料编号', 'material_name': '物料名称',
'use_quantity': '用量', 'unit_quantity': '用量', 'old_values': '修改前内容',
'new_values': '修改后内容', 'value': '字段值', 'field': '字段', 'data': '提交数据',
'warehouse': '仓库', 'warehouse_code': '仓库编号', 'warehouse_name': '仓库名称',
'supplier_contract': '供应商合同', 'contract_code': '合同编号', 'purchase_order': '采购单',
'purchase_plan_name': '采购计划', 'purchase_group': '跟单小组',
'view': '视图', 'columns': '字段列', 'sort_by': '排序字段', 'sort_order': '排序顺序',
'width': '列宽', 'visible': '是否显示', 'default_show': '默认显示', 'order': '顺序',
'settings': '设置', 'config': '配置', 'filters': '筛选条件', 'page': '页码',
'size': '每页条数', 'limit': '数量上限', 'enabled': '是否启用', 'deleted': '是否删除',
}
page_labels = {
'planning': '计划预测', 'products': '产品信息管理', 'product': '产品信息管理',
'order_domestic': '国内订单管理', 'order_overseas': '海外订单管理',
'order_packaging': '包材订单管理', 'purchase_pending': '待下单管理',
'purchase_backup': '下单备份管理', 'bom': '物料清单管理', 'suppliers': '供应商管理',
'spot_application': '现货采样申请', 'spot_confirmation': '现货采样确认',
'spot_sales': '现货销量管理', 'success': '成功', 'failed': '失败', 'denied': '已拒绝',
}
def label_for(key):
key = str(key)
if key in field_labels:
return field_labels[key]
if key in ('货品编号', '货品名称', '产品编码', '产品名称'):
return key
# 常见 camelCase key 先拆分,再保留语义清晰的中文前缀。
readable = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', key).replace('-', '_')
parts = readable.split('_')
if len(parts) > 1 and parts[0] in ('product', 'material', 'supplier', 'order'):
roots = {'product': '产品', 'material': '物料', 'supplier': '供应商', 'order': '订单'}
tails = {'code': '编号', 'name': '名称', 'type': '类型', 'id': '编号', 'quantity': '数量',
'status': '状态', 'remark': '备注', 'price': '单价', 'date': '日期'}
return roots[parts[0]] + ''.join(tails.get(part, part) for part in parts[1:])
return key
def format_value(value, field_key=''):
if field_key in ('field', 'sort_by') and isinstance(value, str):
return label_for(value)
if field_key == 'sort_order' and value in ('asc', 'desc'):
return '升序' if value == 'asc' else '降序'
if field_key == 'view' and value == 'default':
return '默认视图'
if isinstance(value, (dict, list)):
return json.dumps(translate_data(value), ensure_ascii=False, default=str)
return str(value)
def translate_data(value):
if isinstance(value, dict):
translated = {}
for key, item in value.items():
if key in ('field', 'sort_by') and isinstance(item, str):
translated[label_for(key)] = label_for(item)
else:
translated[label_for(key)] = translate_data(item)
return translated
if isinstance(value, list):
return [translate_data(item) for item in value]
return value
def diff_values(old_value, new_value, path):
"""Return leaf-level changes so large submitted objects do not expose unchanged siblings."""
if isinstance(old_value, dict) and isinstance(new_value, dict):
changes = []
for key in dict.fromkeys(list(old_value) + list(new_value)):
child_path = path + [str(key)]
if key not in old_value:
changes.append((child_path, None, new_value[key]))
elif key not in new_value:
changes.append((child_path, old_value[key], None))
else:
changes.extend(diff_values(old_value[key], new_value[key], child_path))
return changes
if isinstance(old_value, list) and isinstance(new_value, list):
identity_keys = ('product_code', 'material_code', 'product_id', 'id', 'code',
'货品编号', '物料编号', '产品编码')
if all(isinstance(item, dict) for item in old_value + new_value):
def item_key(item, index):
for identity_key in identity_keys:
if item.get(identity_key) not in (None, ''):
return str(item[identity_key])
return '#%s' % index
old_items = {item_key(item, index): item for index, item in enumerate(old_value)}
new_items = {item_key(item, index): item for index, item in enumerate(new_value)}
changes = []
for item_id in dict.fromkeys(list(old_items) + list(new_items)):
item_path = path + [item_id]
if item_id not in old_items:
changes.append((item_path, None, new_items[item_id]))
elif item_id not in new_items:
changes.append((item_path, old_items[item_id], None))
else:
changes.extend(diff_values(old_items[item_id], new_items[item_id], item_path))
return changes
if old_value == new_value:
return []
return [(path, old_value, new_value)]
return [] if old_value == new_value else [(path, old_value, new_value)]
def display_value(value, field_key=''):
if value is None:
return '(空)'
if isinstance(value, (dict, list)):
return json.dumps(translate_data(value), ensure_ascii=False, default=str)
return format_value(value, field_key)
result = []
exact_count = 0
text_fallback_count = 0
for row in rows:
exact = bool(code and str(row.get('object_id') or '').casefold() == code.casefold())
if identity and not exact:
pattern = (re.compile(r'(?<![A-Za-z0-9-])' + re.escape(code) + r'(?![A-Za-z0-9-])', re.I)
if code else re.compile(re.escape(product_name), re.I))
if not any(pattern.search(str(row.get(key) or '')) for key in ('new_values', 'old_values', 'detail')):
continue
exact_count += int(exact)
text_fallback_count += int(bool(identity and not exact))
old_values = decode(row.get('old_values'))
new_values = decode(row.get('new_values'))
changed = []
changed_fields = []
if isinstance(new_values, dict):
old_map = old_values if isinstance(old_values, dict) else {}
for key, new_value in new_values.items():
# A new-only request payload is not evidence that every submitted field changed.
if key not in old_map:
continue
for path, old_value, current_value in diff_values(old_map[key], new_value, [str(key)]):
label = ' · '.join(label_for(part) for part in path)
changed_fields.append(label)
changed.append('%s:%s → %s' % (
label, display_value(old_value, path[-1]), display_value(current_value, path[-1])))
created = row.get('created_at')
if hasattr(created, 'strftime'):
created = created.strftime('%Y-%m-%d %H:%M:%S')
result.append({
'操作时间': str(created or ''),
'操作人': row.get('real_name') or row.get('username') or ('用户#%s' % row.get('user_id')),
'板块': page_labels.get(str(row.get('page') or ''), row.get('page') or ''),
'操作': page_labels.get(str(row.get('action') or ''), row.get('action') or ''),
'字段': '、'.join(dict.fromkeys(changed_fields)) or label_for(row.get('field') or '请求字段(旧日志未结构化)'),
'修改内容': ';'.join(changed) if changed else '该记录未保存可比较的字段前后值',
'匹配方式': '精确对象匹配' if exact else ('历史文本匹配' if identity else '条件匹配'),
'结果': page_labels.get(str(row.get('result_status') or ''), row.get('result_status') or ''),
'请求编号': row.get('request_id') or '',
'日志ID': row.get('id'),
})
return {'columns': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
'rows': result,
'summary': {'row_count': int(total_rows or 0), 'returned_rows': len(result),
'exact_object_matches': exact_count,
'text_fallback_matches': text_fallback_count, 'limit': limit}}
def _latest_outbound_date(): def _latest_outbound_date():
"""最新出库日期(销量/出库类数据源的核对日期)""" """最新出库日期(销量/出库类数据源的核对日期)"""
rows = query("SELECT MAX(日期) AS d FROM daily_outbound") rows = query("SELECT MAX(日期) AS d FROM daily_outbound")
@ -1185,15 +935,6 @@ AI_DATASOURCES = {
'date_kind': '', 'date_kind': '',
'keywords': ['供应商表现', '供应商交付', '供应商排名', '交付率', '不良品率', '供应商风险'], 'keywords': ['供应商表现', '供应商交付', '供应商排名', '交付率', '不良品率', '供应商风险'],
}, },
'operation_history': {
'name': '手动修改记录',
'desc': '只读检索结构化操作日志,按板块、货品编号、字段、操作人和日期查询;精确对象匹配与旧日志文本匹配会分别标注。需要操作日志菜单查看权限。可回答某商品谁在何时修改了哪些内容。',
'fields': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
'handler': _h_operation_history,
'permission_pages': ['logs'],
'date_kind': '',
'keywords': ['操作日志','修改记录','编辑记录','手动修改','修改历史','变更历史','谁修改','修改过','修改前','修改后'],
},
'brand_overview': { 'brand_overview': {
'name': '品牌汇总', 'name': '品牌汇总',
'desc': '按品牌汇总的 SKU数、库存数量、库存金额、昨日销量、30天平均销量与近30/100天动销SKU数(默认按库存金额倒序)。可回答:哪个品牌库存金额最高、某品牌有多少个动销SKU、各品牌销量对比。', 'desc': '按品牌汇总的 SKU数、库存数量、库存金额、昨日销量、30天平均销量与近30/100天动销SKU数(默认按库存金额倒序)。可回答:哪个品牌库存金额最高、某品牌有多少个动销SKU、各品牌销量对比。',

View File

@ -20,8 +20,6 @@ from services.audit_schema import ensure_audit_schema, infer_page, infer_action,
# 明确非数据变更的接口(登录/退出/导出/知识库自检/合同生成/查询),不做审计 # 明确非数据变更的接口(登录/退出/导出/知识库自检/合同生成/查询),不做审计
EXCLUDED_PATHS = frozenset({ EXCLUDED_PATHS = frozenset({
'/api/department-matching/test', # 只读匹配试算
'/api/department-matching/check', # 只读对照表核验
'/api/login', # 登录(无会话上下文,且登录日志单独直接写入) '/api/login', # 登录(无会话上下文,且登录日志单独直接写入)
'/api/logout', # 退出(仅清理会话,不产生数据变更) '/api/logout', # 退出(仅清理会话,不产生数据变更)
'/api/inventory/export', # 库存导出(生成文件,不修改业务数据) '/api/inventory/export', # 库存导出(生成文件,不修改业务数据)

View File

@ -59,36 +59,9 @@ def classify_risk(action='',field='',path=''):
return 'normal' return 'normal'
def infer_object(data,path=''): def infer_object(data,path=''):
if isinstance(data,list):
for item in data:
nested_type,nested_id=infer_object(item,path)
if nested_id:return nested_type,nested_id
return ('','')
if not isinstance(data,dict):return ('','') if not isinstance(data,dict):return ('','')
# 中文字段名用于兼容前端表单及嵌套业务对象的常见编码字段。 for key in ('product_code','material_code','supplier_code','record_id','order_id','subject_id','target_id','id','user_id'):
for key in ('product_code','货品编号','产品编码','货品编码','material_code','物料编号','supplier_code','供应商编码',
'order_id','订单编号','record_id','subject_id','target_id'):
value=data.get(key)
if isinstance(value,dict):
nested_type,nested_id=infer_object(value,path)
if nested_id:return nested_type,nested_id
if isinstance(value,list):
for item in value:
nested_type,nested_id=infer_object(item,path)
if nested_id:return nested_type,nested_id
if value not in (None,''):
object_type=key.replace('_code','').replace('_id','')
if key in ('货品编号','产品编码','货品编码'):object_type='product'
elif key=='物料编号':object_type='material'
elif key=='供应商编码':object_type='supplier'
elif key=='订单编号':object_type='order'
return (object_type,str(value))
for value in data.values():
if isinstance(value,(dict,list)):
nested_type,nested_id=infer_object(value,path)
if nested_id:return nested_type,nested_id
for key in ('id','user_id'):
value=data.get(key) value=data.get(key)
if value not in (None,''): if value not in (None,''):
return (key.replace('_id',''),str(value)) return (key.replace('_code','').replace('_id',''),str(value))
return ('','') return ('','')

View File

@ -1,208 +0,0 @@
"""独立的部门归属规则库;不写入出库、人员或组织架构数据。"""
import json
import re
import unicodedata
from services.db import get_db, query
KINDS = ('platform', 'brand', 'nonbrand', 'shop')
FIELDS = {
'platform': ('department', 'shop_keywords', 'source_keywords'),
'brand': ('keyword', 'department', 'mode', 'pdd_department', 'overseas_department', 'notes', 'reference_count'),
'nonbrand': ('name', 'reason', 'guidance'),
'shop': ('shop', 'row_count', 'product_count', 'department', 'layer'),
}
MODES = ('fixed', 'split', 'pdd_only')
def ensure_schema():
query("""CREATE TABLE IF NOT EXISTS department_matching_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
kind VARCHAR(20) NOT NULL,
payload JSON NOT NULL,
priority INT NOT NULL DEFAULT 100,
enabled TINYINT NOT NULL DEFAULT 1,
source_file VARCHAR(255) NOT NULL DEFAULT '',
source_row INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_kind (kind, priority, id)
) CHARACTER SET utf8mb4""")
def records():
rows = query('SELECT * FROM department_matching_records ORDER BY priority,id')
for row in rows:
if isinstance(row['payload'], str):
row['payload'] = json.loads(row['payload'])
return rows
def normalized(text):
return unicodedata.normalize('NFKC', str(text or '')).strip().casefold()
def tokens(text):
return [normalized(v) for v in re.split(r'[,,、\n]+', str(text or '')) if normalized(v)]
def validate_record(data):
kind = data.get('kind')
if kind not in KINDS:
raise ValueError('记录类型不正确')
raw = data.get('payload')
if not isinstance(raw, dict):
raise ValueError('请填写规则内容')
payload = {k: str(raw.get(k, '') if raw.get(k) is not None else '').strip() for k in FIELDS[kind]}
if any(len(v) > 4000 for v in payload.values()):
raise ValueError('单字段内容不能超过4000字')
required = {'platform': ('department',), 'brand': ('keyword', 'mode'),
'nonbrand': ('name',), 'shop': ('shop', 'department')}[kind]
if any(not payload[k] for k in required):
raise ValueError('请填写必填字段')
if kind == 'platform' and not (tokens(payload['shop_keywords']) or tokens(payload['source_keywords'])):
raise ValueError('店铺关键词和来源关键词至少填写一项')
if kind == 'brand':
if payload['mode'] not in MODES:
raise ValueError('品牌匹配方式不正确')
if payload['mode'] in ('fixed', 'split') and not payload['department']:
raise ValueError('请填写默认归属')
if payload['mode'] in ('split', 'pdd_only') and not payload['pdd_department']:
raise ValueError('请填写拼多多归属')
try:
priority = int(data.get('priority', 100))
except (ValueError, TypeError):
raise ValueError('优先级必须为整数')
if not 0 <= priority <= 100000:
raise ValueError('优先级范围为0至100000,数字越小越优先')
enabled = data.get('enabled', True)
if enabled not in (True, False, 0, 1):
raise ValueError('启用状态不正确')
return kind, payload, priority, int(bool(enabled))
def match_department(shop='', source='', rows=None):
"""品牌条件匹配后回落平台层;来源仅在店铺为空时精确匹配。"""
shop, source = normalized(shop), normalized(source)
rows = sorted((r for r in (records() if rows is None else rows) if r['enabled']),
key=lambda r: (r['priority'], r['id']))
def result(row, department, layer, reason):
return {'department': department, 'layer': layer, 'reason': reason, 'rule_id': row['id']}
if shop:
for row in rows:
if row['kind'] != 'brand':
continue
p = row['payload']
keyword = normalized(p['keyword'])
compact = lambda value: re.sub(r'\s+', '', value)
if keyword in ('dr jeveital', 'drjeveital', 'jeveital'):
hit = compact(keyword) in compact(shop)
elif keyword == 'aromat':
hit = bool(re.search(r'aromat(?![a-z])', shop))
else:
hit = bool(keyword and keyword in shop)
if not hit:
continue
is_pdd = '拼多多' in shop
mode = p['mode']
if mode == 'pdd_only' and not is_pdd:
continue
department = p['pdd_department'] if is_pdd and mode != 'fixed' else p['department']
if mode == 'split' and p.get('overseas_department') and any(
word in shop for word in ('京东海外', '京东国际')):
department = p['overseas_department']
return result(row, department, '品牌层', '店铺命中品牌关键词:' + p['keyword'])
candidates = []
for row in rows:
if row['kind'] == 'platform':
for keyword in tokens(row['payload']['shop_keywords']):
if keyword in shop:
candidates.append((-len(keyword), row['priority'], row['id'], keyword, row))
if candidates:
_, _, _, keyword, row = min(candidates, key=lambda item: item[:3])
return result(row, row['payload']['department'], '平台层', '店铺命中平台关键词:' + keyword)
elif source:
for row in rows:
if row['kind'] == 'platform' and source in tokens(row['payload']['source_keywords']):
return result(row, row['payload']['department'], '来源兜底', '店铺为空,数据来源精确命中:' + source)
return {'department': '', 'layer': '未匹配', 'reason': '未命中规则,请维护对应规则', 'rule_id': None}
def check_samples(rows=None):
rows = records() if rows is None else rows
samples = []
for row in rows:
if row['kind'] == 'shop':
p = row['payload']
matched = match_department(p['shop'], rows=rows)
samples.append({'id': row['id'], 'shop': p['shop'], 'expected': p['department'],
'actual': matched['department'], 'passed': matched['department'] == p['department'],
'reason': matched['reason']})
return {'total': len(samples), 'passed': sum(r['passed'] for r in samples),
'mismatches': [r for r in samples if not r['passed']]}
def workbook_records(path):
from openpyxl import load_workbook
workbook = load_workbook(path, data_only=True, read_only=True)
result = []
try:
for sheet, kind in (('平台层规则', 'platform'), ('品牌层规则', 'brand'),
('非品牌清单', 'nonbrand'), ('店铺归属对照表', 'shop')):
for number, values in enumerate(workbook[sheet].iter_rows(min_row=2, values_only=True), 2):
if not any(v is not None for v in values):
continue
v = [str(value).strip() if value is not None else '' for value in values]
if kind == 'platform':
payload = dict(zip(FIELDS[kind], v))
elif kind == 'brand':
keyword, department, match_type, notes, count = v
mode, pdd, overseas = 'fixed', '', ''
if keyword in ('QSIMELR', 'MUTO'):
mode, department, pdd = 'split', '灵动', '雪狼'
overseas = '雪狼' if keyword == 'MUTO' else ''
elif keyword in ('UOUONE', 'WOPUKA', 'FOCOOL', 'AVAA'):
mode, department, pdd = 'pdd_only', '', '春跃'
payload = dict(zip(FIELDS[kind], (keyword, department, mode, pdd, overseas, notes, count)))
payload['notes'] = match_type + ';' + notes
else:
payload = dict(zip(FIELDS[kind], v))
k, payload, priority, enabled = validate_record({'kind': kind, 'payload': payload, 'priority': number * 10})
result.append({'kind': k, 'payload': payload, 'priority': priority, 'enabled': enabled, 'source_row': number})
finally:
workbook.close()
return result
def import_workbook(path):
"""首次导入;已有记录则拒绝覆盖,重复运行安全。"""
from pathlib import Path
rows = workbook_records(path)
ensure_schema()
conn = get_db()
try:
conn.begin()
with conn.cursor() as cur:
cur.execute("SELECT GET_LOCK('department_matching_import', 10) AS acquired")
if not cur.fetchone()['acquired']:
raise ValueError('另一个导入正在进行')
cur.execute('SELECT COUNT(*) AS n FROM department_matching_records')
if cur.fetchone()['n']:
raise ValueError('规则库已有数据,未覆盖;请在页面维护')
cur.executemany('''INSERT INTO department_matching_records
(kind,payload,priority,enabled,source_file,source_row) VALUES (%s,%s,%s,%s,%s,%s)''',
[(r['kind'], json.dumps(r['payload'], ensure_ascii=False), r['priority'], r['enabled'],
Path(path).name, r['source_row']) for r in rows])
conn.commit()
except Exception:
conn.rollback()
raise
finally:
with conn.cursor() as cur:
cur.execute("SELECT RELEASE_LOCK('department_matching_import')")
conn.close()
return {kind: sum(r['kind'] == kind for r in rows) for kind in KINDS}

View File

@ -1,340 +0,0 @@
"""钉钉组织架构只读同步服务。"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from services.db import get_db, query
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SECRET_FILE = PROJECT_ROOT / '.secrets' / 'dingtalk_organization.json'
def load_dingtalk_config():
config = {
'app_key': os.environ.get('DINGTALK_ORG_APP_KEY', '').strip(),
'app_secret': os.environ.get('DINGTALK_ORG_APP_SECRET', '').strip(),
'corp_id': os.environ.get('DINGTALK_ORG_CORP_ID', '').strip(),
}
if SECRET_FILE.exists():
with SECRET_FILE.open(encoding='utf-8') as handle:
saved = json.load(handle)
for key in config:
config[key] = config[key] or str(saved.get(key) or '').strip()
missing = [key for key, value in config.items() if not value]
if missing:
raise RuntimeError('钉钉组织架构配置不完整:' + ', '.join(missing))
return config
def ensure_organization_schema():
statements = [
"""CREATE TABLE IF NOT EXISTS dingtalk_org_departments (
dept_id BIGINT NOT NULL PRIMARY KEY,
parent_id BIGINT NOT NULL DEFAULT 0,
name VARCHAR(200) NOT NULL DEFAULT '',
sort_order BIGINT NOT NULL DEFAULT 0,
active TINYINT NOT NULL DEFAULT 1,
synced_at DATETIME NOT NULL,
KEY idx_dingtalk_dept_parent (parent_id, active, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS dingtalk_org_users (
userid VARCHAR(128) NOT NULL PRIMARY KEY,
unionid VARCHAR(128) NOT NULL DEFAULT '',
name VARCHAR(100) NOT NULL DEFAULT '',
mobile VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(200) NOT NULL DEFAULT '',
job_number VARCHAR(100) NOT NULL DEFAULT '',
avatar VARCHAR(1000) NOT NULL DEFAULT '',
active TINYINT NOT NULL DEFAULT 1,
is_admin TINYINT NOT NULL DEFAULT 0,
is_boss TINYINT NOT NULL DEFAULT 0,
hired_date BIGINT NULL,
matched_dashboard_user_id INT NULL,
synced_at DATETIME NOT NULL,
KEY idx_dingtalk_user_name (name),
KEY idx_dingtalk_user_mobile (mobile),
KEY idx_dingtalk_user_match (matched_dashboard_user_id),
CONSTRAINT fk_dingtalk_user_match FOREIGN KEY (matched_dashboard_user_id)
REFERENCES dashboard_users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS dingtalk_org_memberships (
dept_id BIGINT NOT NULL,
userid VARCHAR(128) NOT NULL,
is_leader TINYINT NOT NULL DEFAULT 0,
dept_order BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (dept_id, userid),
KEY idx_dingtalk_membership_user (userid),
CONSTRAINT fk_dingtalk_membership_dept FOREIGN KEY (dept_id)
REFERENCES dingtalk_org_departments(dept_id) ON DELETE CASCADE,
CONSTRAINT fk_dingtalk_membership_user FOREIGN KEY (userid)
REFERENCES dingtalk_org_users(userid) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS dingtalk_org_sync_runs (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual',
status VARCHAR(20) NOT NULL DEFAULT 'running',
department_count INT NOT NULL DEFAULT 0,
user_count INT NOT NULL DEFAULT 0,
matched_count INT NOT NULL DEFAULT 0,
message VARCHAR(1000) NOT NULL DEFAULT '',
started_at DATETIME NOT NULL,
finished_at DATETIME NULL,
KEY idx_dingtalk_sync_started (started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
for statement in statements:
query(statement)
def _request_json(url, payload=None):
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
request = Request(url, data=body, headers={'Content-Type': 'application/json'})
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode('utf-8'))
if int(result.get('errcode') or 0) != 0:
raise RuntimeError(f"钉钉接口错误 {result.get('errcode')}:{result.get('errmsg') or '未知错误'}")
return result
def _access_token(config):
result = _request_json(
'https://oapi.dingtalk.com/gettoken?' + urlencode({
'appkey': config['app_key'],
'appsecret': config['app_secret'],
})
)
token = str(result.get('access_token') or '')
if not token:
raise RuntimeError('钉钉未返回 access_token')
return token
def _post(token, path, payload):
return _request_json(f'https://oapi.dingtalk.com/{path}?access_token={token}', payload)
def _fetch_departments(token):
departments = {}
root = _post(token, 'topapi/v2/department/get', {'dept_id': 1, 'language': 'zh_CN'}).get('result') or {}
departments[1] = {
'dept_id': 1,
'parent_id': 0,
'name': str(root.get('name') or '企业组织架构'),
'sort_order': int(root.get('order') or 0),
}
stack = [1]
while stack:
parent_id = stack.pop()
result = _post(token, 'topapi/v2/department/listsub', {'dept_id': parent_id, 'language': 'zh_CN'})
for row in result.get('result') or []:
dept_id = int(row.get('dept_id') or 0)
if not dept_id or dept_id in departments:
continue
departments[dept_id] = {
'dept_id': dept_id,
'parent_id': int(row.get('parent_id') or parent_id),
'name': str(row.get('name') or ''),
'sort_order': int(row.get('order') or 0),
}
stack.append(dept_id)
return departments
def _fetch_userids(token, department_ids):
userids = set()
for dept_id in department_ids:
cursor = 0
while True:
result = _post(token, 'topapi/user/listsimple', {
'dept_id': dept_id,
'cursor': cursor,
'size': 100,
'contain_access_limit': False,
'language': 'zh_CN',
}).get('result') or {}
for row in result.get('list') or []:
userid = str(row.get('userid') or '').strip()
if userid:
userids.add(userid)
if not result.get('has_more'):
break
cursor = int(result.get('next_cursor') or 0)
return userids
def _fetch_users(token, userids):
users = {}
for userid in sorted(userids):
detail = _post(token, 'topapi/v2/user/get', {'userid': userid, 'language': 'zh_CN'}).get('result') or {}
detail['userid'] = userid
users[userid] = detail
return users
def _dashboard_user_matches(conn):
with conn.cursor() as cursor:
cursor.execute("SELECT id, real_name, phone FROM dashboard_users")
rows = cursor.fetchall()
by_phone = {}
by_name = {}
for row in rows:
phone = str(row.get('phone') or '').strip()
name = str(row.get('real_name') or '').strip()
if phone:
by_phone.setdefault(phone, []).append(int(row['id']))
if name:
by_name.setdefault(name, []).append(int(row['id']))
return by_phone, by_name
def _match_dashboard_user(detail, by_phone, by_name):
mobile = str(detail.get('mobile') or '').strip()
name = str(detail.get('name') or '').strip()
if mobile and len(by_phone.get(mobile, [])) == 1:
return by_phone[mobile][0]
if name and len(by_name.get(name, [])) == 1:
return by_name[name][0]
return None
def sync_organization(trigger_type='manual'):
"""全量同步钉钉部门和人员;不自动修改 dashboard_users。"""
ensure_organization_schema()
lock_conn = get_db()
run_id = None
try:
with lock_conn.cursor() as cursor:
cursor.execute("SELECT GET_LOCK('dy_dingtalk_org_sync', 0) AS acquired")
if not int((cursor.fetchone() or {}).get('acquired') or 0):
raise RuntimeError('组织架构同步正在执行,请稍后再试')
started_at = datetime.now()
run_id = query(
"INSERT INTO dingtalk_org_sync_runs(trigger_type,status,started_at) VALUES (%s,'running',%s)",
(trigger_type, started_at),
)
config = load_dingtalk_config()
token = _access_token(config)
departments = _fetch_departments(token)
userids = _fetch_userids(token, departments.keys())
users = _fetch_users(token, userids)
synced_at = datetime.now()
conn = get_db()
try:
by_phone, by_name = _dashboard_user_matches(conn)
matched_count = 0
with conn.cursor() as cursor:
cursor.execute("UPDATE dingtalk_org_departments SET active=0")
cursor.execute("UPDATE dingtalk_org_users SET active=0")
for department in departments.values():
cursor.execute(
"""INSERT INTO dingtalk_org_departments
(dept_id,parent_id,name,sort_order,active,synced_at)
VALUES (%s,%s,%s,%s,1,%s)
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id),name=VALUES(name),
sort_order=VALUES(sort_order),active=1,synced_at=VALUES(synced_at)""",
(department['dept_id'], department['parent_id'], department['name'],
department['sort_order'], synced_at),
)
for userid, detail in users.items():
matched_id = _match_dashboard_user(detail, by_phone, by_name)
if matched_id:
matched_count += 1
cursor.execute(
"""INSERT INTO dingtalk_org_users
(userid,unionid,name,mobile,title,job_number,avatar,active,is_admin,is_boss,
hired_date,matched_dashboard_user_id,synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON DUPLICATE KEY UPDATE unionid=VALUES(unionid),name=VALUES(name),
mobile=VALUES(mobile),title=VALUES(title),job_number=VALUES(job_number),
avatar=VALUES(avatar),active=VALUES(active),is_admin=VALUES(is_admin),
is_boss=VALUES(is_boss),hired_date=VALUES(hired_date),
matched_dashboard_user_id=VALUES(matched_dashboard_user_id),synced_at=VALUES(synced_at)""",
(userid, str(detail.get('unionid') or ''), str(detail.get('name') or ''),
str(detail.get('mobile') or ''), str(detail.get('title') or ''),
str(detail.get('job_number') or ''), str(detail.get('avatar') or ''),
1 if detail.get('active', True) else 0, 1 if detail.get('admin') else 0,
1 if detail.get('boss') else 0, detail.get('hired_date'), matched_id, synced_at),
)
cursor.execute("DELETE FROM dingtalk_org_memberships")
for userid, detail in users.items():
leader_map = {int(x.get('dept_id') or 0): bool(x.get('leader'))
for x in detail.get('leader_in_dept') or []}
order_map = {int(x.get('dept_id') or 0): int(x.get('order') or 0)
for x in detail.get('dept_order_list') or []}
for dept_id in detail.get('dept_id_list') or []:
dept_id = int(dept_id or 0)
if dept_id not in departments:
continue
cursor.execute(
"INSERT INTO dingtalk_org_memberships(dept_id,userid,is_leader,dept_order) VALUES (%s,%s,%s,%s)",
(dept_id, userid, 1 if leader_map.get(dept_id) else 0, order_map.get(dept_id, 0)),
)
cursor.execute(
"""UPDATE dingtalk_org_sync_runs SET status='success',department_count=%s,
user_count=%s,matched_count=%s,message=%s,finished_at=%s WHERE id=%s""",
(len(departments), len(users), matched_count, '同步成功', synced_at, run_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {
'run_id': run_id,
'department_count': len(departments),
'user_count': len(users),
'matched_count': matched_count,
'synced_at': synced_at.strftime('%Y-%m-%d %H:%M:%S'),
}
except Exception as exc:
if run_id:
try:
query(
"UPDATE dingtalk_org_sync_runs SET status='failed',message=%s,finished_at=NOW() WHERE id=%s",
(str(exc)[:1000], run_id),
)
except Exception:
pass
raise
finally:
try:
with lock_conn.cursor() as cursor:
cursor.execute("SELECT RELEASE_LOCK('dy_dingtalk_org_sync')")
finally:
lock_conn.close()
def organization_snapshot():
ensure_organization_schema()
departments = query(
"""SELECT dept_id,parent_id,name,sort_order,synced_at
FROM dingtalk_org_departments WHERE active=1 ORDER BY parent_id,sort_order,dept_id"""
)
members = query(
"""SELECT m.dept_id,u.userid,u.name,u.mobile,u.title,u.job_number,u.avatar,
u.active,m.is_leader,m.dept_order,u.matched_dashboard_user_id,
du.username AS matched_username,du.real_name AS matched_real_name
FROM dingtalk_org_memberships m
JOIN dingtalk_org_users u ON u.userid=m.userid AND u.active=1
LEFT JOIN dashboard_users du ON du.id=u.matched_dashboard_user_id
JOIN dingtalk_org_departments d ON d.dept_id=m.dept_id AND d.active=1
ORDER BY m.dept_id,m.is_leader DESC,m.dept_order,u.name"""
)
last_runs = query(
"""SELECT id,trigger_type,status,department_count,user_count,matched_count,message,
started_at,finished_at FROM dingtalk_org_sync_runs ORDER BY id DESC LIMIT 1"""
)
for collection in (departments, members, last_runs):
for row in collection:
for key, value in list(row.items()):
if isinstance(value, datetime):
row[key] = value.strftime('%Y-%m-%d %H:%M:%S')
return {'departments': departments, 'members': members, 'last_sync': last_runs[0] if last_runs else None}

View File

@ -28,7 +28,6 @@ FIELD_REGISTRY = {
{'key': '新旧品', 'sensitive': False}, {'key': '新旧品', 'sensitive': False},
{'key': '下单清单', 'sensitive': False}, {'key': '下单清单', 'sensitive': False},
{'key': '产品分级', 'sensitive': False}, {'key': '产品分级', 'sensitive': False},
{'key': '在途进度', 'sensitive': False},
{'key': '昨日销量', 'sensitive': False}, {'key': '昨日销量', 'sensitive': False},
{'key': '云仓昨日销量', 'sensitive': False}, {'key': '云仓昨日销量', 'sensitive': False},
{'key': '外仓昨日销量', 'sensitive': False}, {'key': '外仓昨日销量', 'sensitive': False},

View File

@ -36,28 +36,6 @@ FORCE_FINISHED_PRODUCTS_SQL = ', '.join(
) )
def active_warehouse_condition(inventory_alias='inventory_detail'):
"""SQL condition that excludes warehouses marked disabled in warehouse management.
Inventory rows whose warehouse is not maintained in ``warehouses`` remain eligible;
only an explicit ``disabled='是'`` removes a warehouse from calculations. Numeric
warehouse codes are compared by value so 29/029 and 35/035 behave consistently.
"""
alias = inventory_alias
return f"""NOT EXISTS (
SELECT 1 FROM warehouses disabled_wh
WHERE COALESCE(disabled_wh.disabled, '否') = '是'
AND (
disabled_wh.warehouse_code = {alias}.warehouse_code
OR (
disabled_wh.warehouse_code REGEXP '^[0-9]+$'
AND {alias}.warehouse_code REGEXP '^[0-9]+$'
AND CAST(disabled_wh.warehouse_code AS UNSIGNED) = CAST({alias}.warehouse_code AS UNSIGNED)
)
)
)"""
def resolve_cost_price(product_code, inventory_price): def resolve_cost_price(product_code, inventory_price):
"""解析产品成本价:命中特殊规则的用单独指定的正确成本价,否则用库存表成本价。""" """解析产品成本价:命中特殊规则的用单独指定的正确成本价,否则用库存表成本价。"""
if product_code in SPECIAL_COST_PRICES: if product_code in SPECIAL_COST_PRICES:
@ -76,11 +54,10 @@ def _get_latest_inv_date():
def _compute_product_inventory(): def _compute_product_inventory():
"""计算每个产品的最新库存(排除15号仓库)""" """计算每个产品的最新库存(排除15号仓库)"""
latest_str = _get_latest_inv_date() latest_str = _get_latest_inv_date()
rows = query(f""" rows = query("""
SELECT product_code, SUM(available_qty) AS total_qty SELECT product_code, SUM(available_qty) AS total_qty
FROM inventory_detail FROM inventory_detail
WHERE data_date = %s AND warehouse_code != '15' WHERE data_date = %s AND warehouse_code != '15'
AND {active_warehouse_condition('inventory_detail')}
AND NOT (product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD') AND NOT (product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')
AND LPAD(warehouse_code, 2, '0') = '20') AND LPAD(warehouse_code, 2, '0') = '20')
GROUP BY product_code GROUP BY product_code
@ -99,7 +76,6 @@ def _compute_raw_inventory_15():
"SELECT product_code, SUM(available_qty) AS total_qty " "SELECT product_code, SUM(available_qty) AS total_qty "
"FROM inventory_detail " "FROM inventory_detail "
"WHERE data_date = %s AND warehouse_code = '15' " "WHERE data_date = %s AND warehouse_code = '15' "
f"AND {active_warehouse_condition('inventory_detail')} "
"GROUP BY product_code", "GROUP BY product_code",
(latest_str,)) (latest_str,))
inv15_map = {} inv15_map = {}
@ -121,8 +97,7 @@ def compute_product_inventory_15():
def compute_product_inventory_by_property(property_keyword): def compute_product_inventory_by_property(property_keyword):
"""根据仓库属性标签计算每个产品的库存(如云仓、外仓)""" """根据仓库属性标签计算每个产品的库存(如云仓、外仓)"""
wh_codes = query( wh_codes = query(
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET(%s, warehouse_property) > 0 " "SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET(%s, warehouse_property) > 0",
"AND COALESCE(disabled, '否') != '是'",
(property_keyword,) (property_keyword,)
) )
# 仓库管理和库存明细的编号格式可能不同(例如管理表为035、明细为35)。 # 仓库管理和库存明细的编号格式可能不同(例如管理表为035、明细为35)。
@ -196,7 +171,7 @@ def compute_product_in_transit():
"SELECT product_code, SUM(production_in_transit) AS prod_in_transit, " "SELECT product_code, SUM(production_in_transit) AS prod_in_transit, "
"SUM(purchase_in_transit) AS purch_in_transit " "SUM(purchase_in_transit) AS purch_in_transit "
"FROM inventory_detail " "FROM inventory_detail "
f"WHERE data_date = %s AND {active_warehouse_condition('inventory_detail')} " "WHERE data_date = %s "
"GROUP BY product_code", "GROUP BY product_code",
(latest_str,)) (latest_str,))
transit_map = {} transit_map = {}
@ -297,7 +272,7 @@ def compute_inventory_cost_summary(latest_date=None):
_cost_map = {r['product_code']: resolve_cost_price(r['product_code'], float(r['pr']) if r['pr'] else 0) for r in _latest_cost} _cost_map = {r['product_code']: resolve_cost_price(r['product_code'], float(r['pr']) if r['pr'] else 0) for r in _latest_cost}
# 成品/包材分类汇总 # 成品/包材分类汇总
_inventory_rows = query(f""" _inventory_rows = query("""
SELECT id.product_code, SELECT id.product_code,
SUM(CASE WHEN SUM(CASE WHEN
(p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL) (p.product_type IN ('成品', '清库存', '现货', '赠品') AND p.deleted_at IS NULL)
@ -327,7 +302,6 @@ def compute_inventory_cost_summary(latest_date=None):
FROM inventory_detail id FROM inventory_detail id
LEFT JOIN products p ON id.product_code = p.product_code LEFT JOIN products p ON id.product_code = p.product_code
WHERE id.data_date = %s WHERE id.data_date = %s
AND {active_warehouse_condition('id')}
AND id.product_code NOT LIKE %s AND id.product_code NOT LIKE %s
AND id.product_name NOT LIKE %s AND id.product_name NOT LIKE %s
AND NOT (id.product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD') AND NOT (id.product_code IN ('POZ-03033', 'POZ-DXPW-1668-PDD', 'AH-YS-0584-TMGJ', 'POZ-JHY-7022', 'SEDATE-YMT-1682-PDD')

View File

@ -1,85 +0,0 @@
"""国内/海外订单统一在途进度口径。"""
from datetime import datetime
from services.db import query
def _order_time_value(value):
"""把历史订单中的 datetime、YYYY-MM-DD 和斜杠日期转成可比较值。"""
if value is None:
return None
if isinstance(value, datetime):
return value
text = str(value).strip()
if not text:
return None
text = text.replace('/', '-').replace('T', ' ')
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d', '%Y-%m-%d %H:%M'):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
return None
def unfinished_order_progress_map(product_codes=None):
"""按货品编号返回国内/海外最早一笔未完结订单的详情进度。
下单时间沿用订单管理口径:订单下达供应商时间优先,其次订单日期,
最后使用计划下单时间;无日期的历史记录排在有日期记录之后。
"""
source_codes = list(product_codes) if product_codes is not None else None
requested = {
str(code or '').strip() for code in (source_codes or []) if str(code or '').strip()
}
if source_codes is not None and not requested:
return {}
rows = []
for source_order, (table, code_column) in enumerate((
('domestic_orders', '货品编号'),
('overseas_orders', '产品编码'),
)):
params = []
code_where = ''
if requested:
code_where = f" AND `{code_column}` IN ({','.join(['%s'] * len(requested))})"
params.extend(sorted(requested))
sql = f"""
SELECT `{code_column}` AS product_code, `进度`,
`订单下达供应商时间` AS supplier_ordered_at,
`日期` AS order_date, `计划下单时间` AS planned_ordered_at, id
FROM {table}
WHERE `状态`='未完结'{code_where}
"""
for row in query(sql, tuple(params)):
row['_source_order'] = source_order
rows.append(row)
def sort_key(row):
ordered_at = (
_order_time_value(row.get('supplier_ordered_at'))
or _order_time_value(row.get('order_date'))
or _order_time_value(row.get('planned_ordered_at'))
)
return (
0 if ordered_at else 1,
ordered_at or datetime.max,
row.get('_source_order', 0),
row.get('id') or 0,
)
rows.sort(key=sort_key)
result = {}
for row in rows:
code = str(row.get('product_code') or '').strip()
if code and code not in result:
result[code] = row.get('进度') or ''
return result
def attach_unfinished_order_progress(rows):
"""为计划预测行批量附加“在途进度”,避免逐 SKU 查询。"""
progress = unfinished_order_progress_map(row.get('product_code') for row in rows)
for row in rows:
row['在途进度'] = progress.get(str(row.get('product_code') or '').strip(), '')
return rows

View File

@ -1,7 +1,7 @@
// 蝶雅采购智能体平台 - 前端逻辑 // 蝶雅采购智能体平台 - 前端逻辑
let charts = {}; let charts = {};
// 页面缓存:切换菜单时不销毁页面状态 // 页面缓存:切换菜单时不销毁页面状态
var CACHE_VERSION = 102; var CACHE_VERSION = 94;
if (!window._pageCache) window._pageCache = {}; if (!window._pageCache) window._pageCache = {};
if (!window._pageDataCache) window._pageDataCache = {}; if (!window._pageDataCache) window._pageDataCache = {};
if (!window._pageReloaders) window._pageReloaders = {}; if (!window._pageReloaders) window._pageReloaders = {};
@ -33,8 +33,6 @@ const PAGE_LOADERS = {
'data_outbound': loadDataPage, 'data_outbound': loadDataPage,
'products': loadProductsPage, 'products': loadProductsPage,
'brand_management': loadBrandManagementPage, 'brand_management': loadBrandManagementPage,
'organization': loadOrganizationPage,
'department_matching': loadDepartmentMatchingPage,
'users': loadUsersPage, 'users': loadUsersPage,
'logs': loadLogsPage, 'logs': loadLogsPage,
'login_records': loadLoginRecordsPage, 'login_records': loadLoginRecordsPage,
@ -2444,30 +2442,19 @@ function aiAppendBubble(role, html) {
function aiRenderTable(columns, rows, sql, totalRows, canExport) { function aiRenderTable(columns, rows, sql, totalRows, canExport) {
if (!rows || !rows.length) return '<div style="color:#999">查询无数据</div>'; if (!rows || !rows.length) return '<div style="color:#999">查询无数据</div>';
if (canExport === undefined || canExport === null) canExport = true; if (canExport === undefined || canExport === null) canExport = true;
var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1); var thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px">' + escHtml(c) + '</th>'; }).join('');
window._aiResults = window._aiResults || {}; var tbody = rows.slice(0, 100).map(function(r) {
window._aiResults[rid] = {columns: columns, rows: rows};
var operationHistoryTable = columns.indexOf('修改内容') >= 0 && columns.indexOf('日志ID') >= 0;
var historyWidths = {'操作时间':150,'操作人':100,'板块':140,'操作':120,'字段':170,'修改内容':420,'匹配方式':130,'结果':80,'请求编号':180,'日志ID':80};
var colgroup = operationHistoryTable ? '<colgroup>' + columns.map(function(c) {
return '<col style="width:' + (historyWidths[c] || 120) + 'px">';
}).join('') + '</colgroup>' : '';
var thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px;overflow:hidden;text-overflow:ellipsis">' + escHtml(c) + '</th>'; }).join('');
var tbody = rows.slice(0, 100).map(function(r, rowIndex) {
return '<tr>' + columns.map(function(c) { return '<tr>' + columns.map(function(c) {
var v = r[c]; var v = r[c];
if (v === null || v === undefined) v = ''; if (v === null || v === undefined) v = '';
var s = String(v); var s = String(v);
if (c === '修改内容') {
var preview = s.length > 72 ? s.slice(0, 72) + '…' : s;
return '<td style="text-align:left;padding:6px 8px;font-size:12px;overflow:hidden" title="' + escHtml(s) + '">' +
'<div style="display:flex;align-items:center;gap:6px;min-width:0;max-width:100%"><span style="display:block;flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(preview) + '</span>' +
(s.length > 72 ? '<button type="button" class="btn btn-sm" style="flex:0 0 auto;padding:0 5px;font-size:11px" onclick="aiShowResultCell(\'' + rid + '\',' + rowIndex + ',\'' + escJs(c) + '\')">查看全部</button>' : '') + '</div></td>';
}
if (s.length > 60) s = s.slice(0, 60) + '…'; if (s.length > 60) s = s.slice(0, 60) + '…';
return '<td style="text-align:center;padding:6px 8px;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(String(v)) + '"><span style="display:block;overflow:hidden;text-overflow:ellipsis">' + escHtml(s) + '</span></td>'; return '<td style="text-align:center;padding:6px 10px;font-size:12px;white-space:nowrap">' + escHtml(s) + '</td>';
}).join('') + '</tr>'; }).join('') + '</tr>';
}).join(''); }).join('');
var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1);
window._aiResults = window._aiResults || {};
window._aiResults[rid] = {columns: columns, rows: rows};
var total = totalRows || rows.length; var total = totalRows || rows.length;
var hint = total > rows.slice(0, 100).length var hint = total > rows.slice(0, 100).length
? '<div style="margin-top:6px;font-size:11px;color:#fa8c16">共 ' + total + ' 行,当前仅展示前 ' + rows.slice(0, 100).length + ' 行,可点「导出CSV」查看全部</div>' ? '<div style="margin-top:6px;font-size:11px;color:#fa8c16">共 ' + total + ' 行,当前仅展示前 ' + rows.slice(0, 100).length + ' 行,可点「导出CSV」查看全部</div>'
@ -2479,22 +2466,11 @@ function aiRenderTable(columns, rows, sql, totalRows, canExport) {
: '<span style="font-size:11px;color:#bbb">(无导出权限)</span>') + : '<span style="font-size:11px;color:#bbb">(无导出权限)</span>') +
'<span style="font-size:11px;color:#bbb">共 ' + total + ' 行</span>' + '<span style="font-size:11px;color:#bbb">共 ' + total + ' 行</span>' +
'</div>'; '</div>';
var tableStyle = operationHistoryTable return toolbar + '<div style="overflow-x:auto;max-height:320px;overflow-y:auto"><table class="data-table" style="font-size:12px;min-width:100%">' +
? 'font-size:12px;table-layout:fixed;width:1570px;min-width:1570px;border-collapse:collapse' '<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint +
: 'font-size:12px;min-width:100%';
return toolbar + '<div style="width:100%;overflow:auto;max-height:320px"><table class="data-table" style="' + tableStyle + '">' +
colgroup + '<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint +
(sql ? '<div style="margin-top:6px;font-size:11px;color:#bbb;word-break:break-all">SQL:' + escHtml(sql) + '</div>' : ''); (sql ? '<div style="margin-top:6px;font-size:11px;color:#bbb;word-break:break-all">SQL:' + escHtml(sql) + '</div>' : '');
} }
window.aiShowResultCell = function(rid, rowIndex, column) {
var result = (window._aiResults || {})[rid];
var row = result && result.rows && result.rows[rowIndex];
if (!row) return;
var id = 'aiResultCell-' + Date.now();
document.body.insertAdjacentHTML('beforeend', '<div class="modal-overlay" id="' + id + '" style="display:flex;z-index:100100"><div class="modal" style="width:min(900px,92vw);max-height:86vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + escHtml(column || '完整内容') + '</h3><button class="modal-close" onclick="document.getElementById(\'' + id + '\').remove()">×</button></div><div class="modal-body" style="overflow:auto"><div style="font-size:12px;color:#777;margin-bottom:8px">' + escHtml(row['操作时间'] || '') + ' · ' + escHtml(row['操作人'] || '') + ' · ' + escHtml(row['板块'] || '') + '</div><pre style="margin:0;padding:14px;background:#f7f8fa;border:1px solid #e5e7eb;border-radius:4px;white-space:pre-wrap;word-break:break-word;font:12px/1.7 inherit">' + escHtml(row[column] || '') + '</pre></div><div class="modal-footer"><button class="btn btn-primary" onclick="document.getElementById(\'' + id + '\').remove()">关闭</button></div></div></div>');
};
// 导出 CSV(带 BOM,Excel 直接打开不乱码) // 导出 CSV(带 BOM,Excel 直接打开不乱码)
window.aiExportCsv = function(rid) { window.aiExportCsv = function(rid) {
var res = (window._aiResults || {})[rid]; var res = (window._aiResults || {})[rid];
@ -6060,8 +6036,6 @@ var PAGE_MAP = {
'/orders/overseas': 'orders_overseas', '/orders/overseas': 'orders_overseas',
'/orders/packaging': 'orders_packaging', '/orders/packaging': 'orders_packaging',
'/orders/finished': 'orders_finished', '/orders/finished': 'orders_finished',
'/organization': 'organization',
'/department-matching': 'department_matching',
'/users': 'users', '/users': 'users',
'/login-records': 'login_records', '/login-records': 'login_records',
'/logs': 'logs', '/logs': 'logs',
@ -6201,8 +6175,6 @@ window._hideNoPermNavItems = function() {
{page:'personnel', sel:'a.nav-sub-item[href="/personnel"]', sub:true}, {page:'personnel', sel:'a.nav-sub-item[href="/personnel"]', sub:true},
{page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false}, {page:'knowledge', sel:'a.nav-item[href="/knowledge"]', sub:false},
{page:'contract_templates', sel:'a.nav-sub-item[href="/contract-templates"]', sub:true}, {page:'contract_templates', sel:'a.nav-sub-item[href="/contract-templates"]', sub:true},
{page:'organization', sel:'a.nav-sub-item[href="/organization"]', sub:true},
{page:'department_matching', sel:'a.nav-sub-item[href="/department-matching"]', sub:true},
{page:'users', sel:'a.nav-sub-item[href="/users"]', sub:true}, {page:'users', sel:'a.nav-sub-item[href="/users"]', sub:true},
{page:'permissions', sel:'a.nav-sub-item[href="/permissions"]', sub:true}, {page:'permissions', sel:'a.nav-sub-item[href="/permissions"]', sub:true},
{page:'login_records', sel:'a.nav-sub-item[href="/login-records"]', sub:true}, {page:'login_records', sel:'a.nav-sub-item[href="/login-records"]', sub:true},
@ -7655,7 +7627,12 @@ function loadInventoryBoardPage(container) {
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<h1 style="margin:0">库存数据看板</h1> <h1 style="margin:0">库存数据看板</h1>
<label style="font-size:13px;color:#666;margin-left:auto">月份 <label style="font-size:13px;color:#666;margin-left:auto">月份
<select id="invBoardMonth" autocomplete="off" aria-label="选择月份" style="width:150px;height:36px;box-sizing:border-box;padding:6px 10px;border:1px solid var(--border,#d9d9d9);border-radius:6px;font-size:15px;font-weight:600;color:#333;background:#fff"></select> <select id="invBoardMonth" autocomplete="off" aria-label="选择月份" style="width:150px;height:36px;box-sizing:border-box;padding:6px 10px;border:1px solid var(--border,#d9d9d9);border-radius:6px;font-size:15px;font-weight:600;color:#333;background:#fff">
<option value="2026-08">2026-08</option>
<option value="2026-07">2026-07</option>
<option value="2026-06">2026-06</option>
<option value="2026-05">2026-05</option>
</select>
</label> </label>
<button class="btn btn-sm" onclick="reloadInventoryBoard(true)">重新计算</button> <button class="btn btn-sm" onclick="reloadInventoryBoard(true)">重新计算</button>
<button class="btn btn-sm btn-primary" onclick="openInventoryManualModal()">人工新建</button> <button class="btn btn-sm btn-primary" onclick="openInventoryManualModal()">人工新建</button>
@ -7670,13 +7647,8 @@ function loadInventoryBoardPage(container) {
var monthInput = document.getElementById('invBoardMonth'); var monthInput = document.getElementById('invBoardMonth');
if (monthInput) { if (monthInput) {
var initialMonth = window._inventoryBoardSelectedMonth || inventoryBoardCurrentMonth(); monthInput.value = '2026-08';
monthInput.innerHTML = inventoryBoardMonthOptions(initialMonth, false); monthInput.onchange = function() { reloadInventoryBoard(false); };
monthInput.value = initialMonth;
monthInput.onchange = function() {
window._inventoryBoardSelectedMonth = this.value;
reloadInventoryBoard(false);
};
} }
fetch('/api/dashboard/inventory-board?month=' + encodeURIComponent(monthInput ? monthInput.value : ''), { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) fetch('/api/dashboard/inventory-board?month=' + encodeURIComponent(monthInput ? monthInput.value : ''), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(r => r.json()) .then(r => r.json())
@ -7692,36 +7664,9 @@ function loadInventoryBoardPage(container) {
}); });
} }
function inventoryBoardCurrentMonth() {
var parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit'
}).formatToParts(new Date());
var year = '', month = '';
parts.forEach(function(part) {
if (part.type === 'year') year = part.value;
if (part.type === 'month') month = part.value;
});
return year + '-' + month;
}
function inventoryBoardMonthOptions(selected, includePlaceholder) {
var current = inventoryBoardCurrentMonth();
var cursor = new Date(Number(current.slice(0, 4)), Number(current.slice(5, 7)) - 1, 1);
var earliest = new Date(2025, 0, 1);
var options = includePlaceholder ? '<option value="">请选择月份</option>' : '';
var values = [];
while (cursor >= earliest) {
values.push(cursor.getFullYear() + '-' + String(cursor.getMonth() + 1).padStart(2, '0'));
cursor.setMonth(cursor.getMonth() - 1);
}
if (selected && values.indexOf(selected) < 0) values.unshift(selected);
values.forEach(function(value) {
options += '<option value="' + value + '"' + (value === selected ? ' selected' : '') + '>' + value + '</option>';
});
return options;
}
function inventoryBoardMonth() { function inventoryBoardMonth() {
var el = document.getElementById('invBoardMonth'); var el = document.getElementById('invBoardMonth');
return (el && el.value) || inventoryBoardCurrentMonth(); return (el && el.value) || new Date().toISOString().slice(0, 7);
} }
function reloadInventoryBoard(force) { function reloadInventoryBoard(force) {
var month = inventoryBoardMonth(); var month = inventoryBoardMonth();
@ -7762,12 +7707,21 @@ function scheduleInventoryBoardFreshPoll(d) {
}, 1500); }, 1500);
} }
function inventoryManualMonthOptions() { function inventoryManualMonthOptions() {
return inventoryBoardMonthOptions(inventoryBoardMonth(), true); var options = '<option value="">请选择月份</option>';
for (var year = 2025; year <= 2026; year++) {
var startMonth = year === 2025 ? 1 : 1;
var endMonth = year === 2026 ? 5 : 12;
for (var month = startMonth; month <= endMonth; month++) {
var value = year + '-' + String(month).padStart(2, '0');
options += '<option value="' + value + '">' + value + '</option>';
}
}
return options;
} }
function openInventoryManualModal() { function openInventoryManualModal() {
var groups = [ var groups = [
['库存金额',[['all_amount','全部仓库成品'],['wh01_amount','蝶雅仓成品'],['wh15_amount','加工厂仓成品'],['cloud_amount','云仓成品'],['overseas_amount','海外仓成品'],['domestic_self_amount','国内平台自营仓成品']]], ['库存金额',[['all_amount','全部仓库成品'],['wh01_amount','蝶雅仓成品'],['wh15_amount','加工厂仓成品'],['cloud_amount','云仓成品'],['overseas_amount','海外仓成品'],['domestic_self_amount','国内平台自营仓成品']]],
['经营效率',[['inventory_total_amount','库存总金额'],['monthly_ship_cost','月销货成本'],['monthly_return_cost','月退货成本'],['monthly_refund_amount','月退款金额'],['return_rate','退货率'],['monthly_purchase_inbound','月采购入库'],['turnover_rate','月库存周转率'],['turnover_days','库存周转天数'],['active_sku','动销SKU数量(≥30件)'],['active_sku_100','动销SKU数量(≥100件)'],['total_sku','总SKU数量(库存≥1)'],['total_sku_200','总SKU数量(库存≥200)'],['active_rate','动销率(≥30件)'],['active_rate_100','动销率(≥100件)']]], ['经营效率',[['inventory_total_amount','库存总金额'],['monthly_ship_cost','月销货成本'],['monthly_return_cost','月退货成本'],['monthly_refund_amount','月退款金额'],['return_rate','退货率'],['monthly_purchase_inbound','月采购入库'],['turnover_rate','月库存周转率'],['turnover_days','库存周转天数'],['active_sku','动销SKU数量(大于30)'],['active_sku_100','动销SKU数量(>100天)'],['total_sku','总SKU数量(库存>=1)'],['total_sku_200','总SKU数量(库存>=200)'],['active_rate','动销率(>30)'],['active_rate_100','动销率(>100)']]],
['呆滞治理',[['slow_amount','呆滞库存金额'],['slow_ratio','呆滞库存比例']]], ['呆滞治理',[['slow_amount','呆滞库存金额'],['slow_ratio','呆滞库存比例']]],
['缺货监控',[['stockout_sku_domestic','缺货SKU次数(国内)'],['stockout_sku_snowwolf','缺货SKU次数(国际-雪狼)'],['stockout_sku_lingdong','缺货SKU次数(国际-灵动)'],['stockout_sku_chunyue','缺货SKU次数(国际-春跃)'],['stockout_rate','缺货率'],['top100_stockout_rate','销量TOP100缺货率'],['new_stockout_rate','新品缺货率'],['old_stockout_rate','老品缺货率']]] ['缺货监控',[['stockout_sku_domestic','缺货SKU次数(国内)'],['stockout_sku_snowwolf','缺货SKU次数(国际-雪狼)'],['stockout_sku_lingdong','缺货SKU次数(国际-灵动)'],['stockout_sku_chunyue','缺货SKU次数(国际-春跃)'],['stockout_rate','缺货率'],['top100_stockout_rate','销量TOP100缺货率'],['new_stockout_rate','新品缺货率'],['old_stockout_rate','老品缺货率']]]
]; ];
@ -7846,10 +7800,10 @@ function renderInventoryBoard(d) {
'monthly_return_cost': {label:'月退货成本', unit:'万元', icon:'↩️', color:'#fa8c16'}, 'monthly_return_cost': {label:'月退货成本', unit:'万元', icon:'↩️', color:'#fa8c16'},
'monthly_refund_amount': {label:'月退款金额', unit:'万元', icon:'💸', color:'#eb2f96'}, 'monthly_refund_amount': {label:'月退款金额', unit:'万元', icon:'💸', color:'#eb2f96'},
'monthly_purchase_inbound': {label:'月采购入库', unit:'件', icon:'🚚', color:'#13c2c2'}, 'monthly_purchase_inbound': {label:'月采购入库', unit:'件', icon:'🚚', color:'#13c2c2'},
'active_sku': {label:'动销SKU数量(≥30件)', unit:'个', icon:'📊', color:'#52c41a'}, 'active_sku': {label:'动销SKU数量(大于30)', unit:'个', icon:'📊', color:'#52c41a'},
'active_sku_100': {label:'动销SKU数量(≥100件)', unit:'个', icon:'📈', color:'#a0d911'}, 'active_sku_100': {label:'动销SKU数量(>100天)', unit:'个', icon:'📈', color:'#a0d911'},
'total_sku': {label:'总SKU数量(库存≥1)', unit:'个', icon:'🗃️', color:'#1890ff'}, 'total_sku': {label:'总SKU数量(库存>=1)', unit:'个', icon:'🗃️', color:'#1890ff'},
'total_sku_200': {label:'总SKU数量(库存≥200)', unit:'个', icon:'📚', color:'#2f54eb'}, 'total_sku_200': {label:'总SKU数量(库存>=200)', unit:'个', icon:'📚', color:'#2f54eb'},
'slow_amount': {label:'呆滞库存金额', unit:'万元', icon:'🕸️', color:'#faad14'}, 'slow_amount': {label:'呆滞库存金额', unit:'万元', icon:'🕸️', color:'#faad14'},
'stockout_sku_domestic': {label:'缺货SKU次数(国内)', unit:'次', icon:'🇨🇳', color:'#f5222d'}, 'stockout_sku_domestic': {label:'缺货SKU次数(国内)', unit:'次', icon:'🇨🇳', color:'#f5222d'},
'stockout_sku_snowwolf': {label:'缺货SKU次数(国际-雪狼)', unit:'次', icon:'🐺', color:'#722ed1'}, 'stockout_sku_snowwolf': {label:'缺货SKU次数(国际-雪狼)', unit:'次', icon:'🐺', color:'#722ed1'},
@ -7957,12 +7911,12 @@ function renderInventoryBoard(d) {
{key:'monthly_purchase_inbound', label:'月采购入库', value:num(d.monthly_purchase_inbound), unit:'件', icon:'🚚', color:'#13c2c2'}, {key:'monthly_purchase_inbound', label:'月采购入库', value:num(d.monthly_purchase_inbound), unit:'件', icon:'🚚', color:'#13c2c2'},
{key:'turnover_rate', label:'月库存周转率', value:has(d.turnover_rate) ? pct(d.turnover_rate * 100) : '—', unit:'', icon:'🔄', color:'#722ed1'}, {key:'turnover_rate', label:'月库存周转率', value:has(d.turnover_rate) ? pct(d.turnover_rate * 100) : '—', unit:'', icon:'🔄', color:'#722ed1'},
{key:'turnover_days', label:'库存周转天数', value:has(d.turnover_days) ? d.turnover_days.toFixed(1) : '—', unit:'天', icon:'⏱️', color:'#2f54eb'}, {key:'turnover_days', label:'库存周转天数', value:has(d.turnover_days) ? d.turnover_days.toFixed(1) : '—', unit:'天', icon:'⏱️', color:'#2f54eb'},
{key:'active_sku', label:'动销SKU数量(≥30件)', value:num(d.active_sku_30), unit:'个', icon:'📊', color:'#52c41a'}, {key:'active_sku', label:'动销SKU数量(大于30)', value:num(d.active_sku_30), unit:'个', icon:'📊', color:'#52c41a'},
{key:'active_sku_100', label:'动销SKU数量(≥100件)', value:num(d.active_sku_100), unit:'个', icon:'📈', color:'#a0d911'}, {key:'active_sku_100', label:'动销SKU数量(>100天)', value:num(d.active_sku_100), unit:'个', icon:'📈', color:'#a0d911'},
{key:'total_sku', label:'总SKU数量(库存≥1)', value:num(d.total_sku), unit:'个', icon:'🗃️', color:'#1890ff'}, {key:'total_sku', label:'总SKU数量(库存>=1)', value:num(d.total_sku), unit:'个', icon:'🗃️', color:'#1890ff'},
{key:'total_sku_200', label:'总SKU数量(库存≥200)', value:num(d.total_sku_200), unit:'个', icon:'📚', color:'#2f54eb'}, {key:'total_sku_200', label:'总SKU数量(库存>=200)', value:num(d.total_sku_200), unit:'个', icon:'📚', color:'#2f54eb'},
{key:'active_rate', label:'动销率(≥30件)', value:pct(d.active_rate), unit:'', icon:'📈', color:'#a0d911'}, {key:'active_rate', label:'动销率(>30)', value:pct(d.active_rate), unit:'', icon:'📈', color:'#a0d911'},
{key:'active_rate_100', label:'动销率(≥100件)', value:pct(d.active_rate_100), unit:'', icon:'📊', color:'#13c2c2'} {key:'active_rate_100', label:'动销率(>100)', value:pct(d.active_rate_100), unit:'', icon:'📊', color:'#13c2c2'}
]; ];
var sec2Html = ''; var sec2Html = '';
cards2.forEach(function(s) { cards2.forEach(function(s) {
@ -8020,8 +7974,8 @@ function saveInventoryExternal(key) {
var val = (input.value || '').trim(); var val = (input.value || '').trim();
var label = {'monthly_ship_cost':'月销货成本','monthly_return_cost':'月退货成本', var label = {'monthly_ship_cost':'月销货成本','monthly_return_cost':'月退货成本',
'monthly_refund_amount':'月退款金额','monthly_purchase_inbound':'月采购入库', 'monthly_refund_amount':'月退款金额','monthly_purchase_inbound':'月采购入库',
'active_sku':'动销SKU数量(≥30件)','active_sku_100':'动销SKU数量(≥100件)', 'active_sku':'动销SKU数量(大于30)','active_sku_100':'动销SKU数量(>100天)',
'total_sku':'总SKU数量(库存≥1)','total_sku_200':'总SKU数量(库存≥200)', 'total_sku':'总SKU数量(库存>=1)','total_sku_200':'总SKU数量(库存>=200)',
'slow_amount':'呆滞库存金额'}[key] || key; 'slow_amount':'呆滞库存金额'}[key] || key;
// 空值 = 清空外部值回退内部计算 // 空值 = 清空外部值回退内部计算
if (val !== '' && isNaN(parseFloat(val))) { if (val !== '' && isNaN(parseFloat(val))) {
@ -8062,13 +8016,14 @@ function saveInventoryExternal(key) {
var WH_INFO = { var WH_INFO = {
'all': { 'all': {
title: '全部仓库成品', title: '全部仓库成品',
desc: '仓库信息管理中全部启用仓库的成品库存之和(排除20号原材料仓)' desc: '代表全部序号仓库数据之和',
whs: '1、2、3、4、5、6、7、8、9、10、11、12、13、14、15、16、20、24、25、26、27、29号仓'
}, },
'wh01': {title: '蝶雅仓成品', desc: '代表 1 号仓数据', whs: '1号仓(蝶雅广州新仓)'}, 'wh01': {title: '蝶雅仓成品', desc: '代表 1 号仓数据', whs: '1号仓(蝶雅广州新仓)'},
'wh15': {title: '加工厂仓成品', desc: '代表 15 号仓数据', whs: '15号仓(加工厂储存仓)'}, 'wh15': {title: '加工厂仓成品', desc: '代表 15 号仓数据', whs: '15号仓(加工厂储存仓)'},
'cloud': {title: '云仓成品', desc: '仓库信息管理中已启用且勾选“云仓”属性的仓库之和(排除1号仓)'}, 'cloud': {title: '云仓成品', desc: '代表以下仓库数据之和', whs: '1、2、3、4、5、6、7、8、16、24、27、29号仓'},
'overseas': {title: '海外仓成品', desc: '仓库信息管理中已启用且勾选“海外仓”属性的仓库之和'}, 'overseas': {title: '海外仓成品', desc: '代表以下仓库数据之和', whs: '9、10、11、12、25、26号仓'},
'domestic_self': {title: '国内平台自营仓成品', desc: '已启用的国内平台自营仓之和(13、14号仓及名称含“自营”的非海外仓,排除1号仓)'} 'domestic_self': {title: '国内平台自营仓成品', desc: '代表以下仓库数据之和', whs: '13、14号仓'}
}; };
// 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示) // 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示)
@ -8091,12 +8046,12 @@ var CALC_INFO = {
'monthly_purchase_inbound': {title: '月采购入库', desc: '取自财务数据报表'}, 'monthly_purchase_inbound': {title: '月采购入库', desc: '取自财务数据报表'},
'turnover_rate': {title: '月库存周转率', desc: '月销货成本 ÷ 库存总金额'}, 'turnover_rate': {title: '月库存周转率', desc: '月销货成本 ÷ 库存总金额'},
'turnover_days': {title: '库存周转天数', desc: '当月天数 ÷ 月库存周转率'}, 'turnover_days': {title: '库存周转天数', desc: '当月天数 ÷ 月库存周转率'},
'active_sku': {title: '动销SKU数量(≥30件)', desc: '所选自然月累计销售发货达到 30 件的 SKU 数量'}, 'active_sku': {title: '动销SKU数量(大于30)', desc: '当月至少销售发货 30 件的 SKU 数量'},
'active_sku_100': {title: '动销SKU数量(≥100件)', desc: '所选自然月累计销售发货达到 100 件的 SKU 数量'}, 'active_sku_100': {title: '动销SKU数量(>100天)', desc: '当月至少销售发货 100 件的 SKU 数量'},
'total_sku': {title: '总SKU数量(库存≥1)', desc: '月末启用仓库中成品库存合计达到 1 件的去重 SKU 数'}, 'total_sku': {title: '总SKU数量(库存>=1)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 1)'},
'total_sku_200': {title: '总SKU数量(库存≥200)', desc: '月末启用仓库中成品库存合计达到 200 件的去重 SKU 数'}, 'total_sku_200': {title: '总SKU数量(库存>=200)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 200)'},
'active_rate': {title: '动销率(≥30件)', desc: '动销SKU数量(≥30件)÷ 总SKU数量(库存≥1)× 100%'}, 'active_rate': {title: '动销率(>30)', desc: '动销SKU数量(>30)÷ 总SKU数量(库存>=1)× 100%'},
'active_rate_100': {title: '动销率(≥100件)', desc: '动销SKU数量(≥100件)÷ 总SKU数量(库存≥1)× 100%'} 'active_rate_100': {title: '动销率(>100)', desc: '动销SKU数量(>100)÷ 总SKU数量(库存>=1)× 100%'}
}; };
function showWhTip(key, el) { function showWhTip(key, el) {
@ -12055,258 +12010,6 @@ function savePersonnelGroup(payload) {
}).catch(function(e) { alert('保存失败:' + e.message); }); }).catch(function(e) { alert('保存失败:' + e.message); });
} }
// ─── 组织架构 ───
function loadOrganizationPage(container) {
container.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;overflow:auto';
container.innerHTML = `
<div class="page-header" style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
<div><h1>组织架构</h1><p class="page-desc">同步并查看钉钉部门与人员信息</p></div>
<button class="btn btn-primary" id="organizationSyncBtn" onclick="syncDingtalkOrganization()" style="display:none">同步钉钉组织架构</button>
</div>
<div id="organizationSummary" style="display:grid;grid-template-columns:repeat(4,minmax(150px,1fr));gap:12px;margin-bottom:14px"></div>
<div class="card" style="flex:none;min-height:0;overflow:hidden">
<div class="card-body" style="height:auto;min-height:0;padding:0;display:grid;grid-template-columns:300px minmax(0,1fr);align-items:start">
<aside style="border-right:1px solid var(--border);height:calc(100vh - 245px);min-height:520px;max-height:760px;overflow:auto;padding:14px 12px;box-sizing:border-box">
<div style="display:flex;align-items:center;gap:6px;margin:0 6px 10px">
<div style="font-size:14px;font-weight:600">部门架构</div>
<button class="btn btn-sm" style="margin-left:auto;padding:2px 7px;font-size:11px" onclick="expandAllOrganizationDepartments()">全部展开</button>
<button class="btn btn-sm" style="padding:2px 7px;font-size:11px" onclick="collapseAllOrganizationDepartments()">全部收起</button>
</div>
<div id="organizationDeptTree"><div class="page-loading"><div class="loading-spinner"></div></div></div>
</aside>
<section style="min-width:0;height:clamp(620px,calc(100vh - 225px),820px);display:flex;flex-direction:column;align-self:start">
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong id="organizationMemberTitle">全部人员</strong>
<input id="organizationSearch" class="form-input" style="width:240px;margin-left:auto" placeholder="姓名/手机号/职位/工号" autocomplete="off" onkeydown="if(event.key==='Enter')searchOrganizationMembers()">
<button class="btn btn-primary" onclick="searchOrganizationMembers()">搜索</button>
<button class="btn" onclick="resetOrganizationSearch()">重置</button>
</div>
<div style="flex:1;min-height:0;overflow:auto">
<table class="data-table" style="width:100%;min-width:820px">
<thead style="position:sticky;top:0;z-index:2"><tr><th>姓名</th><th>部门</th><th>职位</th><th>手机号</th><th>工号</th><th>系统账号关联</th><th>状态</th></tr></thead>
<tbody id="organizationMemberBody"><tr><td colspan="7" style="text-align:center;padding:50px;color:var(--text-light)">正在加载...</td></tr></tbody>
</table>
</div>
<div id="organizationPagination" style="padding:10px 16px;border-top:1px solid var(--border);text-align:center"></div>
</section>
</div>
</div>`;
var syncBtn = document.getElementById('organizationSyncBtn');
if (syncBtn && window._hasPagePerm('organization', 'sync')) syncBtn.style.display = '';
fetchOrganizationData();
}
window._organizationSelectedDept = 0;
window._organizationData = null;
window._organizationExpandedDepts = {'1': true};
window._organizationMemberPage = 1;
window._organizationMemberPageSize = 20;
function fetchOrganizationData() {
fetch('/api/organization/tree', {credentials:'same-origin'})
.then(function(response) { return response.json(); })
.then(function(res) {
if (res.code !== 0) throw new Error(res.msg || '组织架构加载失败');
window._organizationData = res.data || {departments:[], members:[], last_sync:null};
renderOrganizationSummary();
renderOrganizationDepartments();
renderOrganizationMembers();
})
.catch(function(error) {
var tree = document.getElementById('organizationDeptTree');
var body = document.getElementById('organizationMemberBody');
if (tree) tree.innerHTML = '<div style="padding:25px 8px;text-align:center;color:#f5222d">' + escHtml(error.message) + '</div>';
if (body) body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:50px;color:#f5222d">' + escHtml(error.message) + '</td></tr>';
});
}
function renderOrganizationSummary() {
var data = window._organizationData || {};
var departments = data.departments || [], members = data.members || [], last = data.last_sync || {};
var uniqueUsers = {};
members.forEach(function(member) { uniqueUsers[member.userid] = member; });
var matched = Object.values(uniqueUsers).filter(function(member) { return !!member.matched_dashboard_user_id; }).length;
var cards = [
['部门数量', Math.max(0, departments.length - 1), '#1677ff'],
['人员数量', Object.keys(uniqueUsers).length, '#52c41a'],
['已关联系统账号', matched, '#722ed1'],
['最近同步', last.finished_at || '尚未同步', last.status === 'failed' ? '#f5222d' : '#fa8c16']
];
var summary = document.getElementById('organizationSummary');
if (!summary) return;
summary.innerHTML = cards.map(function(card) {
return '<div class="card" style="margin:0;border-top:3px solid ' + card[2] + '"><div class="card-body" style="padding:14px 16px"><div style="font-size:12px;color:var(--text-light);margin-bottom:7px">' + card[0] + '</div><div style="font-size:' + (card[0] === '最近同步' ? '15px' : '24px') + ';font-weight:700;color:#333">' + escHtml(String(card[1])) + '</div></div></div>';
}).join('');
}
function organizationDepartmentMap() {
var map = {};
((window._organizationData || {}).departments || []).forEach(function(dept) { map[String(dept.dept_id)] = dept; });
return map;
}
function renderOrganizationDepartments() {
var data = window._organizationData || {}, departments = data.departments || [], members = data.members || [];
var tree = document.getElementById('organizationDeptTree');
if (!tree) return;
if (!departments.length) {
tree.innerHTML = '<div style="padding:35px 8px;text-align:center;color:var(--text-light)">暂无组织架构数据<br><span style="font-size:12px">请点击右上角同步按钮</span></div>';
return;
}
var children = {}, directCount = {};
departments.forEach(function(dept) { (children[String(dept.parent_id)] = children[String(dept.parent_id)] || []).push(dept); });
members.forEach(function(member) { directCount[String(member.dept_id)] = (directCount[String(member.dept_id)] || 0) + 1; });
Object.keys(children).forEach(function(key) { children[key].sort(function(a,b) { return (a.sort_order || 0) - (b.sort_order || 0) || Number(a.dept_id) - Number(b.dept_id); }); });
function branch(parentId, depth) {
return (children[String(parentId)] || []).map(function(dept) {
var selected = Number(window._organizationSelectedDept) === Number(dept.dept_id);
var deptKey = String(dept.dept_id), hasChildren = !!(children[deptKey] && children[deptKey].length);
var expanded = !!window._organizationExpandedDepts[deptKey];
var toggle = hasChildren
? '<span onclick="event.stopPropagation();toggleOrganizationDepartment(' + Number(dept.dept_id) + ')" title="' + (expanded ? '收起' : '展开') + '" style="width:18px;text-align:center;cursor:pointer;font-size:14px;user-select:none">' + (expanded ? '▾' : '▸') + '</span>'
: '<span style="width:18px;text-align:center;color:#bbb;font-size:10px">●</span>';
var childHtml = hasChildren && expanded ? branch(dept.dept_id, depth + 1) : '';
return '<div class="organization-dept-node' + (selected ? ' selected' : '') + '" onclick="selectOrganizationDepartment(' + Number(dept.dept_id) + ')" style="padding:8px 8px 8px ' + (8 + depth * 18) + 'px;border-radius:6px;cursor:pointer;display:flex;align-items:center;gap:4px;margin:2px 0;background:' + (selected ? '#e6f4ff' : 'transparent') + ';color:' + (selected ? '#1677ff' : '#333') + '">' + toggle + '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(dept.name) + '</span><span style="font-size:11px;color:#999">' + (directCount[deptKey] || 0) + '</span></div>' + childHtml;
}).join('');
}
var root = departments.find(function(dept) { return Number(dept.dept_id) === 1; });
var rootExpanded = window._organizationExpandedDepts['1'] !== false;
tree.innerHTML = '<div class="organization-dept-node" onclick="selectOrganizationDepartment(0)" style="padding:8px;border-radius:6px;cursor:pointer;display:flex;gap:4px;background:' + (Number(window._organizationSelectedDept) === 0 ? '#e6f4ff' : 'transparent') + ';color:' + (Number(window._organizationSelectedDept) === 0 ? '#1677ff' : '#333') + '"><span onclick="event.stopPropagation();toggleOrganizationDepartment(1)" title="' + (rootExpanded ? '收起' : '展开') + '" style="width:18px;text-align:center;cursor:pointer;user-select:none">' + (rootExpanded ? '▾' : '▸') + '</span><strong style="flex:1">' + escHtml(root ? root.name : '全部部门') + '</strong></div>' + (rootExpanded ? branch(1, 1) : '');
}
function toggleOrganizationDepartment(deptId) {
var key = String(Number(deptId));
window._organizationExpandedDepts[key] = !window._organizationExpandedDepts[key];
renderOrganizationDepartments();
}
function expandAllOrganizationDepartments() {
(((window._organizationData || {}).departments) || []).forEach(function(dept) {
window._organizationExpandedDepts[String(dept.dept_id)] = true;
});
renderOrganizationDepartments();
}
function collapseAllOrganizationDepartments() {
window._organizationExpandedDepts = {'1': false};
renderOrganizationDepartments();
}
function organizationDescendantIds(deptId) {
var departments = ((window._organizationData || {}).departments || []), result = {}, queue = [Number(deptId)];
while (queue.length) {
var current = queue.shift(); result[current] = true;
departments.forEach(function(dept) { if (Number(dept.parent_id) === current && !result[Number(dept.dept_id)]) queue.push(Number(dept.dept_id)); });
}
return result;
}
function selectOrganizationDepartment(deptId) {
window._organizationSelectedDept = Number(deptId) || 0;
window._organizationMemberPage = 1;
renderOrganizationDepartments();
renderOrganizationMembers();
}
function renderOrganizationMembers() {
var data = window._organizationData || {}, members = data.members || [], deptMap = organizationDepartmentMap();
var selected = Number(window._organizationSelectedDept) || 0;
var allowed = selected ? organizationDescendantIds(selected) : null;
var keyword = (document.getElementById('organizationSearch')?.value || '').trim().toLowerCase();
var users = {};
members.forEach(function(member) {
if (allowed && !allowed[Number(member.dept_id)]) return;
var item = users[member.userid] || Object.assign({}, member, {department_names:[]});
var dept = deptMap[String(member.dept_id)];
if (dept && item.department_names.indexOf(dept.name) < 0) item.department_names.push(dept.name);
users[member.userid] = item;
});
var rows = Object.values(users).filter(function(member) {
if (!keyword) return true;
return [member.name, member.mobile, member.title, member.job_number, (member.department_names || []).join(' '), member.matched_real_name, member.matched_username].join(' ').toLowerCase().indexOf(keyword) >= 0;
}).sort(function(a,b) { return String(a.name || '').localeCompare(String(b.name || ''), 'zh-CN'); });
var pageSize = window._organizationMemberPageSize || 20;
var totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
var currentPage = Math.min(Math.max(1, Number(window._organizationMemberPage) || 1), totalPages);
window._organizationMemberPage = currentPage;
var pageRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
var title = document.getElementById('organizationMemberTitle');
if (title) title.textContent = (selected && deptMap[String(selected)] ? deptMap[String(selected)].name : '全部人员') + '(' + rows.length + '人)';
var body = document.getElementById('organizationMemberBody');
if (!body) return;
if (!rows.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:50px;color:var(--text-light)">暂无匹配人员</td></tr>';
renderOrganizationPagination(0, 1, 1);
return;
}
body.innerHTML = pageRows.map(function(member) {
var matched = member.matched_dashboard_user_id ? escHtml((member.matched_real_name || member.matched_username || '') + (member.matched_username ? '(' + member.matched_username + ')' : '')) : '<span style="color:#fa8c16">未关联</span>';
return '<tr><td style="font-weight:600">' + escHtml(member.name || '-') + '</td><td>' + escHtml((member.department_names || []).join(' / ') || '-') + '</td><td>' + escHtml(member.title || '-') + '</td><td>' + escHtml(member.mobile || '-') + '</td><td>' + escHtml(member.job_number || '-') + '</td><td>' + matched + '</td><td><span class="tag tag-green">在职</span></td></tr>';
}).join('');
renderOrganizationPagination(rows.length, currentPage, totalPages);
}
function renderOrganizationPagination(total, currentPage, totalPages) {
var container = document.getElementById('organizationPagination');
if (!container) return;
if (!total) {
container.innerHTML = '';
return;
}
var start = (currentPage - 1) * 20 + 1;
var end = Math.min(total, currentPage * 20);
var buttons = '';
var first = Math.max(1, currentPage - 2), last = Math.min(totalPages, first + 4);
first = Math.max(1, last - 4);
for (var page = first; page <= last; page++) {
buttons += '<button class="btn btn-sm' + (page === currentPage ? ' btn-primary' : '') + '" style="min-width:32px" onclick="goOrganizationMemberPage(' + page + ')">' + page + '</button>';
}
container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap">' +
'<span style="font-size:12px;color:var(--text-light);margin-right:8px">共 ' + total + ' 人,当前 ' + start + '-' + end + '</span>' +
'<button class="btn btn-sm" onclick="goOrganizationMemberPage(' + (currentPage - 1) + ')"' + (currentPage <= 1 ? ' disabled' : '') + '>上一页</button>' +
buttons +
'<button class="btn btn-sm" onclick="goOrganizationMemberPage(' + (currentPage + 1) + ')"' + (currentPage >= totalPages ? ' disabled' : '') + '>下一页</button>' +
'<span style="font-size:12px;color:var(--text-light);margin-left:8px">第 ' + currentPage + '/' + totalPages + ' 页</span>' +
'<input id="organizationPageJump" type="number" min="1" max="' + totalPages + '" class="form-input" style="width:64px;height:30px" placeholder="页码" onkeydown="if(event.key===\'Enter\')jumpOrganizationMemberPage()">' +
'<button class="btn btn-sm" onclick="jumpOrganizationMemberPage()">跳转</button></div>';
}
function goOrganizationMemberPage(page) {
window._organizationMemberPage = Math.max(1, Number(page) || 1);
renderOrganizationMembers();
}
function jumpOrganizationMemberPage() {
var input = document.getElementById('organizationPageJump');
goOrganizationMemberPage(input ? input.value : 1);
}
function searchOrganizationMembers() {
window._organizationMemberPage = 1;
renderOrganizationMembers();
}
function resetOrganizationSearch() {
var input = document.getElementById('organizationSearch');
if (input) input.value = '';
window._organizationMemberPage = 1;
renderOrganizationMembers();
}
function syncDingtalkOrganization() {
var button = document.getElementById('organizationSyncBtn');
if (button) { button.disabled = true; button.textContent = '同步中...'; }
fetch('/api/organization/sync', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:'{}'})
.then(function(response) { return response.json(); })
.then(function(res) {
if (res.code !== 0) throw new Error(res.msg || '同步失败');
alert('同步成功:部门 ' + res.data.department_count + ' 个,人员 ' + res.data.user_count + ' 人,已关联系统账号 ' + res.data.matched_count + ' 人');
fetchOrganizationData();
})
.catch(function(error) { alert('同步失败:' + error.message); })
.finally(function() { if (button) { button.disabled = false; button.textContent = '同步钉钉组织架构'; } });
}
// ─── 用户管理 ─── // ─── 用户管理 ───
function downloadUserTemplate() { function downloadUserTemplate() {
fetch('/api/users/template', {credentials: 'same-origin'}) fetch('/api/users/template', {credentials: 'same-origin'})
@ -14398,7 +14101,6 @@ window._permPageFields = {
// 知识库 // 知识库
'knowledge': ['标题','主题分类','创建人','创建时间','更新时间','状态'], 'knowledge': ['标题','主题分类','创建人','创建时间','更新时间','状态'],
// 系统设置 // 系统设置
'organization': [],
'users': ['用户名','姓名','角色','岗位','用户级别','手机号','邮箱','状态','最后登录'], 'users': ['用户名','姓名','角色','岗位','用户级别','手机号','邮箱','状态','最后登录'],
'permissions': ['用户名','姓名','角色','岗位'], 'permissions': ['用户名','姓名','角色','岗位'],
'login_records': ['用户名','登录时间','IP地址'], 'login_records': ['用户名','登录时间','IP地址'],
@ -14627,7 +14329,6 @@ const PLAN_COLUMNS = [
{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}, {field: '昨日销量', label: '昨日销量', defaultShow: true},
{field: '云仓昨日销量', label: '云仓昨日销量', defaultShow: false}, {field: '云仓昨日销量', label: '云仓昨日销量', defaultShow: false},
{field: '外仓昨日销量', label: '外仓昨日销量', defaultShow: false}, {field: '外仓昨日销量', label: '外仓昨日销量', defaultShow: false},
@ -14724,7 +14425,7 @@ const PLAN_FILTER_OPTIONS = {
// 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询) // 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询)
const PLAN_FILTER_EXCLUDE_FIELDS = { const PLAN_FILTER_EXCLUDE_FIELDS = {
'产品分级':1, '在途进度':1, '昨日销量':1, '云仓昨日销量':1, '外仓昨日销量':1, '销售趋势':1, '库存':1, '云仓库存':1, '外仓库存':1, '加工厂库存':1, '产品分级':1, '昨日销量':1, '云仓昨日销量':1, '外仓昨日销量':1, '销售趋势':1, '库存':1, '云仓库存':1, '外仓库存':1, '加工厂库存':1,
'云仓可分配库存':1, '外仓可分配库存':1, '是否调拨':1, '云仓可分配库存':1, '外仓可分配库存':1, '是否调拨':1,
'生产在途':1, '采购在途':1, '日销量预测':1, '云仓日销量预测':1, '外仓日销量预测':1, '库存健康指数':1, '风险把控':1, '生产在途':1, '采购在途':1, '日销量预测':1, '云仓日销量预测':1, '外仓日销量预测':1, '库存健康指数':1, '风险把控':1,
'库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1, '库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1,
@ -15484,7 +15185,7 @@ function renderPlanTable(rows) {
var pcode = (r.product_code || ''); var pcode = (r.product_code || '');
var pname = r.product_name || ''; var pname = r.product_name || '';
var suggQty = r['建议下单数量'] || 0; var suggQty = r['建议下单数量'] || 0;
var actQty = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : (r['实际下单数量'] || 0); var actQty = r['实际下单数量'] || 0;
var remark = r['下单备注'] || ''; var remark = r['下单备注'] || '';
return '<button class="btn btn-sm btn-outline" onclick="planToggleBom(' + inlineJsString(pcode) + ',' + inlineJsString(pname) + ',' + suggQty + ',' + actQty + ',' + inlineJsString(remark) + ')" title="查看下单信息" style="font-size:12px;padding:2px 10px">📋 下单信息</button>'; return '<button class="btn btn-sm btn-outline" onclick="planToggleBom(' + inlineJsString(pcode) + ',' + inlineJsString(pname) + ',' + suggQty + ',' + actQty + ',' + inlineJsString(remark) + ')" title="查看下单信息" style="font-size:12px;padding:2px 10px">📋 下单信息</button>';
} }
@ -15613,8 +15314,7 @@ function renderPlanTable(rows) {
return '<span style="font-weight:600;color:#d4380d">' + val + '</span>'; return '<span style="font-weight:600;color:#d4380d">' + val + '</span>';
} }
if (c.field === '正式下单数量') { if (c.field === '正式下单数量') {
var savedFormalQty = r['正式下单数量']; var currentVal = parseInt(r['实际下单数量']) || 0;
var currentVal = parseInt(savedFormalQty !== undefined && savedFormalQty !== null ? savedFormalQty : r['实际下单数量']) || 0;
var code = r.product_code || ''; var code = r.product_code || '';
var codeHtml = escHtml(code); var codeHtml = escHtml(code);
var canEditFormal = window._canEditField && window._canEditField('planning', '正式下单数量'); var canEditFormal = window._canEditField && window._canEditField('planning', '正式下单数量');
@ -15661,8 +15361,7 @@ function renderPlanTable(rows) {
}).join(''); }).join('');
var code = r.product_code || ''; var code = r.product_code || '';
// 序列化下单所需数据 // 序列化下单所需数据
var formalQtyValue = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : r['实际下单数量']; var formalQty = parseInt(r['实际下单数量']) || 0;
var formalQty = parseInt(formalQtyValue) || 0;
var suggestedQty = parseInt(r['建议下单数量']) || 0; var suggestedQty = parseInt(r['建议下单数量']) || 0;
var orderData = JSON.stringify({ var orderData = JSON.stringify({
product_code: r.product_code || '', product_code: r.product_code || '',
@ -19254,7 +18953,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
if (m.material_code === '成品_' + productCode) { if (m.material_code === '成品_' + productCode) {
// 修复:允许恢复为0(用户手动设0表示不下单) // 修复:允许恢复为0(用户手动设0表示不下单)
if (m.actual_quantity >= 0) actualQty = m.actual_quantity; if (m.actual_quantity >= 0) actualQty = m.actual_quantity;
if (m.remark !== undefined && m.remark !== null) orderRemark = m.remark; if (m.remark) orderRemark = m.remark;
} }
}); });
} }
@ -19266,23 +18965,6 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
alert('❌ 获取物料清单失败'); alert('❌ 获取物料清单失败');
return; return;
} }
var savedOrder = res.data.pending_order;
if (savedOrder && (!window._pendingBomData || !window._pendingBomData[productCode])) {
var savedItems = savedOrder.bom_items;
if (typeof savedItems === 'string') {
try { savedItems = JSON.parse(savedItems); } catch (e) { savedItems = []; }
}
if (Array.isArray(savedItems)) {
if (!window._pendingBomData) window._pendingBomData = {};
window._pendingBomData[productCode] = savedItems;
}
if (savedOrder.formal_order_qty !== undefined && savedOrder.formal_order_qty !== null) {
actualQty = parseInt(savedOrder.formal_order_qty, 10) || 0;
}
if (savedOrder.remark !== undefined && savedOrder.remark !== null) {
orderRemark = savedOrder.remark;
}
}
// 没有绑定包材时仍显示成品下单信息,包材区域由下方统一展示暂无数据。 // 没有绑定包材时仍显示成品下单信息,包材区域由下方统一展示暂无数据。
var bom = res.data.bom_materials || []; var bom = res.data.bom_materials || [];
// 获取正式下单数量(从计划预测页面元素获取) // 获取正式下单数量(从计划预测页面元素获取)
@ -21104,10 +20786,7 @@ window.viewBackupDetail = function(bid, detailItem) {
}); });
var html = '<div style="padding:24px 24px 8px">'; var html = '<div style="padding:24px 24px 8px">';
html += '<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px">'; html += '<h3 style="margin-bottom:16px;font-size:17px">📋 下单详情 - ' + escHtml(code) + '</h3>';
html += '<h3 style="margin:0;font-size:17px">📋 下单详情 - ' + escHtml(code) + '</h3>';
html += '<button type="button" aria-label="关闭下单详情" title="关闭" onclick="this.closest(\'.modal-overlay\').remove()" style="width:32px;height:32px;flex:0 0 32px;display:flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:transparent;color:#8c8c8c;font-size:24px;line-height:1;cursor:pointer" onmouseenter="this.style.background=\'#f5f5f5\';this.style.color=\'#262626\'" onmouseleave="this.style.background=\'transparent\';this.style.color=\'#8c8c8c\'">×</button>';
html += '</div>';
// ─── 板块1:成品信息 ─── // ─── 板块1:成品信息 ───
html += '<h4 style="font-size:14px;color:#333;margin:0 0 10px 0;padding:8px 12px;background:#e6f7ff;border-radius:6px">📦 成品信息</h4>'; html += '<h4 style="font-size:14px;color:#333;margin:0 0 10px 0;padding:8px 12px;background:#e6f7ff;border-radius:6px">📦 成品信息</h4>';
@ -21117,7 +20796,6 @@ window.viewBackupDetail = function(bid, detailItem) {
['货品编号', item.product_code], ['货品编号', item.product_code],
['货品名称', item.product_name], ['货品名称', item.product_name],
['供应商名称', item.supplier_name], ['供应商名称', item.supplier_name],
['货期', (item.lead_time === null || item.lead_time === undefined || item.lead_time === '') ? '-' : item.lead_time + '天'],
['采购计划', item.procurement_planner], ['采购计划', item.procurement_planner],
['采购跟单', item.procurement_follower], ['采购跟单', item.procurement_follower],
['成品下单人', item.product_orderer] ['成品下单人', item.product_orderer]
@ -21154,17 +20832,15 @@ window.viewBackupDetail = function(bid, detailItem) {
} }
html += '</table></div>'; html += '</table></div>';
// ─── 板块2:销量信息(新订单与计划预测详情云仓数据完全同源)─── // ─── 板块2:销量信息(与计划预测详情默认数据完全同源)───
var salesInfo = item.sales_info || {}; var salesInfo = item.sales_info || {};
var salesSnapshotScope = item.sales_snapshot_scope || 'default';
var salesLabelPrefix = salesSnapshotScope === 'cloud' ? '云仓' : '';
var salesInfoFields = [ var salesInfoFields = [
['产品分级', salesLabelPrefix + '产品分级'], ['昨日销量', salesLabelPrefix + '昨日销量'], ['销售趋势', salesLabelPrefix + '销售趋势'], ['产品分级', '产品分级'], ['昨日销量', '昨日销量'], ['销售趋势', '销售趋势'],
['库存', salesLabelPrefix + '库存'], ['加工厂库存', salesLabelPrefix + '加工厂库存'], ['生产在途', salesLabelPrefix + '生产在途'], ['采购在途', salesLabelPrefix + '采购在途'], ['库存', '库存'], ['加工厂库存', '加工厂库存'], ['生产在途', '生产在途'], ['采购在途', '采购在途'],
['日销量预测', salesLabelPrefix + '日销量预测'], ['3天平均销量', salesLabelPrefix + '3天平均销量'], ['7天平均销量', salesLabelPrefix + '7天平均销量'], ['日销量预测', '日销量预测'], ['3天平均销量', '3天平均销量'], ['7天平均销量', '7天平均销量'],
['14天平均销量', salesLabelPrefix + '14天平均销量'], ['库存可支撑天数', salesLabelPrefix + '库存可支撑天数'], ['14天平均销量', '14天平均销量'], ['库存可支撑天数', '库存可支撑天数'],
['加工厂库存支撑天数', salesLabelPrefix + '加工厂库存支撑天数'], ['总支撑天数', salesLabelPrefix + '总支撑天数'], ['加工厂库存支撑天数', '加工厂库存支撑天数'], ['总支撑天数', '总支撑天数'],
['30天平均销量', salesLabelPrefix + '30天平均销量'], ['极值判断', salesLabelPrefix + '极值判断'] ['30天平均销量', '30天平均销量'], ['极值判断', '极值判断']
]; ];
html += '<div class="pd-card" style="margin-bottom:16px">'; html += '<div class="pd-card" style="margin-bottom:16px">';
html += '<div class="pd-card-header">' + html += '<div class="pd-card-header">' +
@ -21173,7 +20849,7 @@ window.viewBackupDetail = function(bid, detailItem) {
'</svg><span>销量信息</span></div>'; '</svg><span>销量信息</span></div>';
if (Object.keys(salesInfo).length) { if (Object.keys(salesInfo).length) {
html += '<div class="pd-card-body">'; html += '<div class="pd-card-body">';
html += '<div style="font-size:12px;color:#8c8c8c;background:#fafafa;border:1px solid #f0f0f0;border-radius:6px;padding:7px 10px;margin-bottom:10px">计划预测点击下单时的' + (salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)') + '快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。</div>'; html += '<div style="font-size:12px;color:#8c8c8c;background:#fafafa;border:1px solid #f0f0f0;border-radius:6px;padding:7px 10px;margin-bottom:10px">计划预测点击下单时的默认销量数据快照' + (item.sales_snapshot_at ? ',快照时间:' + escHtml(String(item.sales_snapshot_at).slice(0, 19)) : '') + '。</div>';
html += '<div class="pd-fields pd-fields-4col">'; html += '<div class="pd-fields pd-fields-4col">';
salesInfoFields.forEach(function(field) { salesInfoFields.forEach(function(field) {
var value = salesInfo[field[0]]; var value = salesInfo[field[0]];
@ -21193,13 +20869,12 @@ window.viewBackupDetail = function(bid, detailItem) {
html += '<h4 style="font-size:14px;color:#333;margin:12px 0 10px 0;padding:8px 12px;background:#fff7e6;border-radius:6px">📦 包材信息</h4>'; html += '<h4 style="font-size:14px;color:#333;margin:12px 0 10px 0;padding:8px 12px;background:#fff7e6;border-radius:6px">📦 包材信息</h4>';
if (bomList.length > 0) { if (bomList.length > 0) {
html += '<div style="border:1px solid var(--border);border-radius:8px;overflow-x:auto">'; html += '<div style="border:1px solid var(--border);border-radius:8px;overflow-x:auto">';
html += '<table style="width:100%;min-width:1740px;border-collapse:collapse;font-size:13px;white-space:nowrap">'; html += '<table style="width:100%;min-width:1600px;border-collapse:collapse;font-size:13px;white-space:nowrap">';
html += '<thead><tr style="background:#555;color:#fff">'; html += '<thead><tr style="background:#555;color:#fff">';
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料编号</th>'; html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料编号</th>';
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料名称</th>'; html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">物料名称</th>';
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:76px;min-width:76px;white-space:nowrap">下单数量</th>'; html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:76px;min-width:76px;white-space:nowrap">下单数量</th>';
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:88px;min-width:88px;white-space:nowrap">剩余包材</th>'; html += '<th style="padding:8px 6px;border:1px solid #666;text-align:center;width:88px;min-width:88px;white-space:nowrap">剩余包材</th>';
html += '<th style="padding:8px 8px;border:1px solid #666;text-align:left;width:140px;min-width:140px;white-space:nowrap">下单进度</th>';
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">包材下单人</th>'; html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">包材下单人</th>';
html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">供应商</th>'; html += '<th style="padding:8px 6px;border:1px solid #666;text-align:left;width:110px;min-width:110px;white-space:nowrap">供应商</th>';
html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">单价</th>'; html += '<th style="padding:8px 12px;border:1px solid #666;text-align:left">单价</th>';
@ -21222,7 +20897,6 @@ window.viewBackupDetail = function(bid, detailItem) {
html += '<td style="padding:8px 12px;border:1px solid #eee">' + escHtml(b.material_name || '-') + '</td>'; html += '<td style="padding:8px 12px;border:1px solid #eee">' + escHtml(b.material_name || '-') + '</td>';
html += '<td style="padding:8px 6px;border:1px solid #eee;width:76px;min-width:76px;text-align:center;white-space:nowrap">' + qty + '</td>'; html += '<td style="padding:8px 6px;border:1px solid #eee;width:76px;min-width:76px;text-align:center;white-space:nowrap">' + qty + '</td>';
html += '<td style="padding:8px 6px;border:1px solid #eee;width:88px;min-width:88px;text-align:center;white-space:nowrap;font-weight:600;color:' + (remainingPack < 0 ? '#cf1322' : '#333') + '">' + remainingPack.toLocaleString('zh-CN', {maximumFractionDigits: 2}) + '</td>'; html += '<td style="padding:8px 6px;border:1px solid #eee;width:88px;min-width:88px;text-align:center;white-space:nowrap;font-weight:600;color:' + (remainingPack < 0 ? '#cf1322' : '#333') + '">' + remainingPack.toLocaleString('zh-CN', {maximumFractionDigits: 2}) + '</td>';
html += '<td style="padding:8px 8px;border:1px solid #eee;width:140px;min-width:140px;text-align:left;white-space:pre-wrap;word-break:break-word">' + escHtml(b.order_progress || '-') + '</td>';
html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(b.packaging_orderer || '-') + '</td>'; html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(b.packaging_orderer || '-') + '</td>';
html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(supplierShortName) + '</td>'; html += '<td style="padding:8px 6px;border:1px solid #eee;width:110px;min-width:110px;white-space:nowrap">' + escHtml(supplierShortName) + '</td>';
html += '<td style="padding:8px 12px;border:1px solid #eee">' + unitPrice.toFixed(4) + '</td>'; html += '<td style="padding:8px 12px;border:1px solid #eee">' + unitPrice.toFixed(4) + '</td>';
@ -21825,6 +21499,7 @@ function showOrderDetail(encodedStr, title) {
{label:'货品名称', field:'名称', editable:false}, {label:'货品名称', field:'名称', editable:false},
{label:'到货仓库', field:'仓库到货', editable:true, type:'supplier'}, {label:'到货仓库', field:'仓库到货', editable:true, type:'supplier'},
{label:'实际下单数量', field:'数量', editable:true}, {label:'实际下单数量', field:'数量', editable:true},
{label:'下单备注', field:'下单备注', editable:true},
{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, type:'select', options:['是','否']}, {label:'是否更换稿件', field:'有无新稿件', editable:true, type:'select', options:['是','否']},
@ -21983,15 +21658,6 @@ function showOrderDetail(encodedStr, title) {
html += '</div>'; html += '</div>';
html += '</div></div>'; html += '</div></div>';
// 三类订单的下单备注取自外层列表同一行,整行展示并放在进度上方。
// 使用与进度相同的文本域样式,保存时仍走各订单现有更新接口。
if (!isFinishedDetail) {
var orderRemarkVal = row['下单备注'];
if (orderRemarkVal === null || orderRemarkVal === undefined) orderRemarkVal = '';
var remarkEditable = window._hasPagePerm(detailPermissionPage, 'edit');
html += '<div style="margin-top:6px"><div class="detail-pair" style="min-height:70px"><span class="detail-lbl" style="min-height:68px">下单备注</span><div class="detail-val-wrap" style="padding:4px 12px"><textarea class="detail-textarea" data-orig="' + escHtml(String(orderRemarkVal)) + '" data-id="' + row.id + '" data-field="下单备注"' + (remarkEditable ? '' : ' readonly') + ' style="width:100%;height:60px;box-sizing:border-box;font-size:13px;border:1px solid #d9d9d9;border-radius:4px;padding:6px 8px;color:#333;background:' + (remarkEditable ? '#fff' : '#f5f5f5') + ';resize:vertical;font-family:inherit">' + escHtml(String(orderRemarkVal)) + '</textarea></div></div></div>';
}
// 成品提货的进度字段整行显示,数据来自国内订单中最早的未完结订单,只读。 // 成品提货的进度字段整行显示,数据来自国内订单中最早的未完结订单,只读。
if (isFinishedDetail) { if (isFinishedDetail) {
var finishedProgress = row['产品进度'] || row['进度'] || ''; var finishedProgress = row['产品进度'] || row['进度'] || '';
@ -22019,7 +21685,7 @@ function showOrderDetail(encodedStr, title) {
html += '<div style="display:flex;align-items:center;margin:0 0 8px 0">'; html += '<div style="display:flex;align-items:center;margin:0 0 8px 0">';
html += '<div style="margin-left:auto;display:flex;align-items:center;gap:6px"><button id="addMultiBatchBtn" class="btn btn-sm btn-outline" onclick="promptAddArrivalBatchRows(this)" data-order-id="' + row.id + '" data-order-type="' + orderType + '" style="font-size:12px">➕ 添加多行</button><button id="addBatchBtn" class="btn btn-sm btn-outline" onclick="addArrivalBatchRow(this)" data-order-id="' + row.id + '" data-order-type="' + orderType + '" style="font-size:12px">➕ 添加一行</button></div>'; html += '<div style="margin-left:auto;display:flex;align-items:center;gap:6px"><button id="addMultiBatchBtn" class="btn btn-sm btn-outline" onclick="promptAddArrivalBatchRows(this)" data-order-id="' + row.id + '" data-order-type="' + orderType + '" style="font-size:12px">➕ 添加多行</button><button id="addBatchBtn" class="btn btn-sm btn-outline" onclick="addArrivalBatchRow(this)" data-order-id="' + row.id + '" data-order-type="' + orderType + '" style="font-size:12px">➕ 添加一行</button></div>';
html += '</div>'; html += '</div>';
var showDefectiveQty = true; var showDefectiveQty = orderType === 'packaging';
html += '<table id="arrivalBatchTable" style="width:100%;border-collapse:collapse;font-size:13px;table-layout:auto" data-order-qty="' + (parseInt(row['数量']) || 0) + '"><thead><tr>'; html += '<table id="arrivalBatchTable" style="width:100%;border-collapse:collapse;font-size:13px;table-layout:auto" data-order-qty="' + (parseInt(row['数量']) || 0) + '"><thead><tr>';
html += '<th style="border:1.5px solid #b0b0b0;padding:6px 8px;background:#f5f5f5;color:#555;font-weight:600;text-align:center;white-space:nowrap;width:1%">批次编号</th>'; html += '<th style="border:1.5px solid #b0b0b0;padding:6px 8px;background:#f5f5f5;color:#555;font-weight:600;text-align:center;white-space:nowrap;width:1%">批次编号</th>';
html += '<th style="border:1.5px solid #b0b0b0;padding:6px 8px;background:#f5f5f5;color:#555;font-weight:600;text-align:center;white-space:nowrap;width:1%">操作人</th>'; html += '<th style="border:1.5px solid #b0b0b0;padding:6px 8px;background:#f5f5f5;color:#555;font-weight:600;text-align:center;white-space:nowrap;width:1%">操作人</th>';
@ -22123,7 +21789,7 @@ function showOrderDetail(encodedStr, title) {
.then(function(res) { .then(function(res) {
if (res.code !== 0) { if (res.code !== 0) {
var abBody = overlay.querySelector('#arrivalBatchBody'); var abBody = overlay.querySelector('#arrivalBatchBody');
if (abBody) abBody.innerHTML = '<tr><td colspan="8" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>'; if (abBody) abBody.innerHTML = '<tr><td colspan="' + (orderType === 'packaging' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>';
var pmBody = overlay.querySelector('#packagingMaterialBody'); var pmBody = overlay.querySelector('#packagingMaterialBody');
if (pmBody) pmBody.innerHTML = '<tr><td colspan="7" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>'; if (pmBody) pmBody.innerHTML = '<tr><td colspan="7" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">暂无数据</td></tr>';
return; return;
@ -22136,16 +21802,12 @@ function showOrderDetail(encodedStr, title) {
var abHtml = ''; var abHtml = '';
var totalQty = 0; var totalQty = 0;
var totalDefectiveQty = 0; var totalDefectiveQty = 0;
var showDefectiveQty = true; var showDefectiveQty = orderType === 'packaging';
var batchCount = data.arrival_batches ? data.arrival_batches.length : 0; var batchCount = data.arrival_batches ? data.arrival_batches.length : 0;
// 数据行 // 数据行
data.arrival_batches.forEach(function(b, idx) { data.arrival_batches.forEach(function(b, idx) {
var qty = parseInt(b.arrival_qty) || 0; var qty = parseInt(b.arrival_qty) || 0;
var defectiveQty = parseInt(b.defective_qty) || 0; var defectiveQty = parseInt(b.defective_qty) || 0;
var dateFilled = Number(b.arrival_date_filled) === 1;
var qtyFilled = Number(b.arrival_qty_filled) === 1;
var defectiveFilled = Number(b.defective_qty_filled) === 1;
var allowBlankFill = orderType === 'packaging';
totalQty += qty - defectiveQty; totalQty += qty - defectiveQty;
totalDefectiveQty += defectiveQty; totalDefectiveQty += defectiveQty;
var dateVal = (b.arrival_date || '').substring(0, 10); var dateVal = (b.arrival_date || '').substring(0, 10);
@ -22154,9 +21816,9 @@ function showOrderDetail(encodedStr, title) {
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + (idx + 1) + '</td>'; abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + (idx + 1) + '</td>';
abHtml += '<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operator_name || '-') + '</td>'; abHtml += '<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operator_name || '-') + '</td>';
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operated_at || '-') + '</td>'; abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operated_at || '-') + '</td>';
abHtml += renderArrivalBatchStoredCell('date', 'batch-date', dateVal, dateFilled, allowBlankFill, '到货日期') + abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-date" value="' + dateVal + '" data-orig="' + dateVal + '"><span>' + escHtml(dateVal || '-') + '</span></td>' +
renderArrivalBatchStoredCell('number', 'batch-qty', qtyFilled ? qty : '', qtyFilled, allowBlankFill, '到货数量', true) + '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' +
renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveFilled ? defectiveQty : '', defectiveFilled, true, '不良品数量', false) + (showDefectiveQty ? '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-defective-qty" value="' + defectiveQty + '" data-orig="' + defectiveQty + '"><span>' + defectiveQty + '</span></td>' : '') +
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' + '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' + '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(b.remark || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(b.remark || '') + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' + '<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(b.remark || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(b.remark || '') + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
@ -22173,7 +21835,7 @@ function showOrderDetail(encodedStr, title) {
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>'; abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>';
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;color:#999;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>'; abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;color:#999;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" placeholder="数量" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>'; abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" placeholder="数量" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" class="batch-defective-qty" placeholder="不良品" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>'; if (showDefectiveQty) abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-defective-qty" placeholder="不良品" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
abHtml += '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;color:#ccc">-</td>'; abHtml += '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;color:#ccc">-</td>';
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" placeholder="备注" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveNewArrivalBatch(this,' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button></div></td>'; abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" placeholder="备注" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveNewArrivalBatch(this,' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button></div></td>';
abHtml += '</tr>'; abHtml += '</tr>';
@ -22210,23 +21872,13 @@ function showOrderDetail(encodedStr, title) {
.catch(function(err) { .catch(function(err) {
console.error('加载详情额外数据失败:', err); console.error('加载详情额外数据失败:', err);
var abBody = overlay.querySelector('#arrivalBatchBody'); var abBody = overlay.querySelector('#arrivalBatchBody');
if (abBody) abBody.innerHTML = '<tr><td colspan="8" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>'; if (abBody) abBody.innerHTML = '<tr><td colspan="' + (orderType === 'packaging' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>';
var pmBody = overlay.querySelector('#packagingMaterialBody'); var pmBody = overlay.querySelector('#packagingMaterialBody');
if (pmBody) pmBody.innerHTML = '<tr><td colspan="' + (orderType === 'domestic' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>'; if (pmBody) pmBody.innerHTML = '<tr><td colspan="' + (orderType === 'domestic' ? 8 : 7) + '" style="border:1.5px solid #b0b0b0;padding:20px;text-align:center;color:#bbb">加载失败</td></tr>';
}); });
})(); })();
} }
function renderArrivalBatchStoredCell(type, className, value, filled, allowBlankFill, placeholder, nonNegative) {
var style = 'border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap';
var safeValue = value === null || value === undefined ? '' : String(value);
if (allowBlankFill && !filled) {
return '<td style="' + style + '"><input type="' + type + '" class="' + className + '" value="" data-orig="" placeholder="' + placeholder + '"' +
(nonNegative ? ' min="0"' : '') + ' oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:80%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #1890ff;border-radius:3px;padding:4px 6px;background:#fffbe6"></td>';
}
return '<td style="' + style + '"><input type="hidden" class="' + className + '" value="' + escHtml(safeValue) + '" data-orig="' + escHtml(safeValue) + '"><span>' + escHtml(safeValue || '-') + '</span></td>';
}
function appendArrivalBatchEditingRow(btn) { function appendArrivalBatchEditingRow(btn) {
var overlay = btn.closest('.modal-overlay'); var overlay = btn.closest('.modal-overlay');
if (!overlay) { if (!overlay) {
@ -22260,7 +21912,7 @@ function appendArrivalBatchEditingRow(btn) {
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>' + '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>' +
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>' + '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>' +
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="数量"></td>' + '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="数量"></td>' +
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" class="batch-defective-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="不良品"></td>' + (orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-defective-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="不良品"></td>' : '') +
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:4px 6px"></td>' + '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:4px 6px"></td>' +
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' + '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical" placeholder="备注"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' + '<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical" placeholder="备注"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
@ -22358,14 +22010,11 @@ function saveNewArrivalBatch(btn, orderId, orderType) {
var tr = btn.closest('tr'); var tr = btn.closest('tr');
if (!tr) return; if (!tr) return;
var date = tr.querySelector('.batch-date')?.value || ''; var date = tr.querySelector('.batch-date')?.value || '';
var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim(); var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim(); var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
var qty = qtyRaw === '' ? '' : parseInt(qtyRaw, 10);
var defectiveQty = defectiveRaw === '' ? '' : parseInt(defectiveRaw, 10);
var remark = tr.querySelector('.batch-remark')?.value || ''; var remark = tr.querySelector('.batch-remark')?.value || '';
if (!date && qtyRaw === '') { alert('请输入到货日期或数量'); return; } if (!date && !qty) { alert('请输入到货日期或数量'); return; }
if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; } if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; }
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
btn.disabled = true; btn.disabled = true;
btn.textContent = '⋯'; btn.textContent = '⋯';
fetch('/api/domestic/orders/add_batch', { fetch('/api/domestic/orders/add_batch', {
@ -22384,9 +22033,9 @@ function saveNewArrivalBatch(btn, orderId, orderType) {
'<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + batchNum + '</td>' + '<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + batchNum + '</td>' +
'<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operator_name || '-') + '</td>' + '<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operator_name || '-') + '</td>' +
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operated_at || '-') + '</td>' + '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operated_at || '-') + '</td>' +
renderArrivalBatchStoredCell('date', 'batch-date', date, !!(res.data && res.data.arrival_date_filled), orderType === 'packaging', '到货日期') + '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-date" value="' + date + '" data-orig="' + date + '"><span>' + escHtml(date || '-') + '</span></td>' +
renderArrivalBatchStoredCell('number', 'batch-qty', qty, !!(res.data && res.data.arrival_qty_filled), orderType === 'packaging', '到货数量', true) + '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' +
renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, !!(res.data && res.data.defective_qty_filled), true, '不良品数量', false) + (orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-defective-qty" value="' + defectiveQty + '" data-orig="' + defectiveQty + '"><span>' + defectiveQty + '</span></td>' : '') +
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' + '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(remark) + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(remark) + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveArrivalBatch(this,' + batchId + ',' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button><button onclick="deleteArrivalBatch(this,' + batchId + ',\'' + orderType + '\')" title="删除" style="padding:2px 6px;font-size:11px;border:1px solid #ff4d4f;background:#fff1f0;color:#ff4d4f;border-radius:3px;cursor:pointer;margin-left:2px;white-space:nowrap">✕</button></div></td>'; '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(remark) + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(remark) + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveArrivalBatch(this,' + batchId + ',' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button><button onclick="deleteArrivalBatch(this,' + batchId + ',\'' + orderType + '\')" title="删除" style="padding:2px 6px;font-size:11px;border:1px solid #ff4d4f;background:#fff1f0;color:#ff4d4f;border-radius:3px;cursor:pointer;margin-left:2px;white-space:nowrap">✕</button></div></td>';
recalcArrivalUnfinished(); recalcArrivalUnfinished();
@ -22409,13 +22058,10 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) {
var tr = btn.closest('tr'); var tr = btn.closest('tr');
if (!tr) return; if (!tr) return;
var date = tr.querySelector('.batch-date')?.value || ''; var date = tr.querySelector('.batch-date')?.value || '';
var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim(); var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim(); var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
var qty = qtyRaw === '' ? '' : parseInt(qtyRaw, 10);
var defectiveQty = defectiveRaw === '' ? '' : parseInt(defectiveRaw, 10);
var remark = tr.querySelector('.batch-remark')?.value || ''; var remark = tr.querySelector('.batch-remark')?.value || '';
if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; } if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; }
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
btn.disabled = true; btn.disabled = true;
btn.textContent = '⋯'; btn.textContent = '⋯';
fetch('/api/domestic/orders/update_batch', { fetch('/api/domestic/orders/update_batch', {
@ -22441,14 +22087,6 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) {
var opt = tr.querySelector('.batch-operated-at'); var opt = tr.querySelector('.batch-operated-at');
if (op) op.textContent = (res.data && res.data.operator_name) || '-'; if (op) op.textContent = (res.data && res.data.operator_name) || '-';
if (opt) opt.textContent = (res.data && res.data.operated_at) || '-'; if (opt) opt.textContent = (res.data && res.data.operated_at) || '-';
if (orderType === 'packaging') {
var dateCell = dd && dd.closest('td');
var qtyCell = dq && dq.closest('td');
if (dateCell && res.data && res.data.arrival_date_filled) dateCell.outerHTML = renderArrivalBatchStoredCell('date', 'batch-date', date, true, false, '到货日期');
if (qtyCell && res.data && res.data.arrival_qty_filled) qtyCell.outerHTML = renderArrivalBatchStoredCell('number', 'batch-qty', qty, true, false, '到货数量', true);
}
var defectiveCell = df && df.closest('td');
if (defectiveCell && res.data && res.data.defective_qty_filled) defectiveCell.outerHTML = renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, true, false, '不良品数量', false);
recalcArrivalUnfinished(); recalcArrivalUnfinished();
refreshOrderListFromArrivalModal(btn); refreshOrderListFromArrivalModal(btn);
setTimeout(function() { btn.textContent = '✅'; }, 500); setTimeout(function() { btn.textContent = '✅'; }, 500);
@ -22604,12 +22242,10 @@ function saveDetailChanges(btn) {
if (tr.classList.contains('batch-placeholder') || tr.classList.contains('batch-editing')) { if (tr.classList.contains('batch-placeholder') || tr.classList.contains('batch-editing')) {
// 新增行:有日期或数量才保存 // 新增行:有日期或数量才保存
var d = tr.querySelector('.batch-date')?.value || ''; var d = tr.querySelector('.batch-date')?.value || '';
var qRaw = (tr.querySelector('.batch-qty')?.value || '').trim(); var q = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
var fRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim(); var f = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
var q = qRaw === '' ? '' : parseInt(qRaw, 10);
var f = fRaw === '' ? '' : parseInt(fRaw, 10);
var r = tr.querySelector('.batch-remark')?.value || ''; var r = tr.querySelector('.batch-remark')?.value || '';
if (d || qRaw !== '' || fRaw !== '') { if (d || q || f) {
batchAdds.push({arrival_date: d, arrival_qty: q, defective_qty: f, remark: r}); batchAdds.push({arrival_date: d, arrival_qty: q, defective_qty: f, remark: r});
} }
} else { } else {
@ -22624,18 +22260,16 @@ function saveDetailChanges(btn) {
var of = tr.querySelector('.batch-defective-qty')?.getAttribute('data-orig') || ''; var of = tr.querySelector('.batch-defective-qty')?.getAttribute('data-orig') || '';
var or_ = tr.querySelector('.batch-remark')?.getAttribute('data-orig') || ''; var or_ = tr.querySelector('.batch-remark')?.getAttribute('data-orig') || '';
if (String(d) !== String(od) || String(q) !== String(oq) || String(f) !== String(of) || String(r) !== String(or_)) { if (String(d) !== String(od) || String(q) !== String(oq) || String(f) !== String(of) || String(r) !== String(or_)) {
batchUpdates.push({id: bid, arrival_date: d, arrival_qty: q === '' ? '' : parseInt(q, 10), defective_qty: f === '' ? '' : parseInt(f, 10), remark: r}); batchUpdates.push({id: bid, arrival_date: d, arrival_qty: parseInt(q) || 0, defective_qty: parseInt(f) || 0, remark: r});
} }
} }
}); });
var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) { var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) {
if (b.arrival_qty !== '' && (!Number.isInteger(b.arrival_qty) || b.arrival_qty < 0)) return true; return b.defective_qty < 0 || b.defective_qty > b.arrival_qty;
if (b.defective_qty !== '' && !Number.isInteger(b.defective_qty)) return true;
return false;
}); });
if (invalidBatch) { if (invalidBatch) {
showCenteredAlert('提示', '到货数量必须为非负整数,不良品数量必须为整数'); showCenteredAlert('提示', '不良品数量不能为负数或大于到货数量');
return; return;
} }
@ -29067,7 +28701,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', '新旧品', '产品分级',
'昨日销量', '销售趋势', '昨日销量', '销售趋势',
'库存', '加工厂库存', '生产在途', '采购在途', '库存', '加工厂库存', '生产在途', '采购在途',
'日销量预测', '日销量预测',
@ -29088,7 +28722,6 @@ var POPUP_FIELD_LABELS = {
'product_code': '货品编号', 'product_code': '货品编号',
'product_name': '货品名称', 'product_name': '货品名称',
'产品分级': '产品分级', '产品分级': '产品分级',
'在途进度': '在途进度',
'brand': '品牌', 'brand': '品牌',
'specification': '规格', 'specification': '规格',
'cost_price': '成本价', 'cost_price': '成本价',
@ -29563,7 +29196,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) {
window._pendingBomData[data.product_code].forEach(function(m) { window._pendingBomData[data.product_code].forEach(function(m) {
if (m.material_code === '成品_' + data.product_code) { if (m.material_code === '成品_' + data.product_code) {
if (m.actual_quantity >= 0) savedProductQty = m.actual_quantity; if (m.actual_quantity >= 0) savedProductQty = m.actual_quantity;
if (m.remark !== undefined && m.remark !== null) savedRemark = m.remark; if (m.remark) savedRemark = m.remark;
} }
}); });
} }
@ -29764,11 +29397,10 @@ window.showProductDetail = function(productCode) {
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',
'\u5e93\u5b58\u53ef\u652f\u6491\u5929\u6570', '\u52a0\u5de5\u5382\u5e93\u5b58\u652f\u6491\u5929\u6570', '\u603b\u652f\u6491\u5929\u6570', '\u5e93\u5b58\u53ef\u652f\u6491\u5929\u6570', '\u52a0\u5de5\u5382\u5e93\u5b58\u652f\u6491\u5929\u6570', '\u603b\u652f\u6491\u5929\u6570',
'30\u5929\u5e73\u5747\u9500\u91cf', '\u6781\u503c\u5224\u65ad']; '30\u5929\u5e73\u5747\u9500\u91cf', '\u6781\u503c\u5224\u65ad'];
var scopedSalesFields = salesFields.filter(function(field) { return field !== '在途进度'; });
// \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09 // \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09
var basicHtml = ''; var basicHtml = '';
@ -29817,8 +29449,8 @@ window.showProductDetail = function(productCode) {
}; };
return labels[field] || POPUP_FIELD_LABELS[field] || field; return labels[field] || POPUP_FIELD_LABELS[field] || field;
}; };
for (var i = 0; i < scopedSalesFields.length; i++) { for (var i = 0; i < salesFields.length; i++) {
var cloudField = scopedSalesFields[i]; var cloudField = salesFields[i];
var cloudLabel = scopedFieldLabel(cloudField, '云仓'); var cloudLabel = scopedFieldLabel(cloudField, '云仓');
var cloudVal = cloudSales[cloudField] !== undefined ? cloudSales[cloudField] : '-'; var cloudVal = cloudSales[cloudField] !== undefined ? cloudSales[cloudField] : '-';
var cloudValHtml = formatPopupFieldValue(cloudField, cloudVal, cloudSales); var cloudValHtml = formatPopupFieldValue(cloudField, cloudVal, cloudSales);
@ -29831,8 +29463,8 @@ window.showProductDetail = function(productCode) {
// 外仓数据与默认数据使用相同字段顺序和四列布局。 // 外仓数据与默认数据使用相同字段顺序和四列布局。
var externalSalesHtml = ''; var externalSalesHtml = '';
var externalSales = p['外仓数据'] || {}; var externalSales = p['外仓数据'] || {};
for (var i = 0; i < scopedSalesFields.length; i++) { for (var i = 0; i < salesFields.length; i++) {
var externalField = scopedSalesFields[i]; var externalField = salesFields[i];
var externalLabel = scopedFieldLabel(externalField, '外仓'); var externalLabel = scopedFieldLabel(externalField, '外仓');
var externalVal = externalSales[externalField] !== undefined ? externalSales[externalField] : '-'; var externalVal = externalSales[externalField] !== undefined ? externalSales[externalField] : '-';
var externalValHtml = formatPopupFieldValue(externalField, externalVal, externalSales); var externalValHtml = formatPopupFieldValue(externalField, externalVal, externalSales);

View File

@ -1,139 +0,0 @@
// 部门匹配独立规则库,不对业务数据执行回写。
var departmentMatchingState = {rows: [], kind: 'platform', page: 1, search: ''};
var departmentMatchingKinds = {platform: '平台层规则', brand: '品牌层规则', nonbrand: '非品牌清单', shop: '店铺归属对照表'};
var departmentMatchingFields = {
platform: [['department', '归属部门(必填)'], ['shop_keywords', '店铺关键词(逗号分隔)'], ['source_keywords', '空店铺来源(逗号分隔,精确匹配)']],
brand: [['keyword', '品牌关键词(必填)'], ['department', '默认归属'], ['mode', '匹配方式(必填)'], ['pdd_department', '拼多多归属'], ['overseas_department', '京东海外/国际归属'], ['notes', '判定说明']],
nonbrand: [['name', '名称(必填)'], ['reason', '为什么不是品牌'], ['guidance', '判定说明']],
shop: [['shop', '店铺(必填)'], ['department', '预期归属(必填)'], ['layer', '参考命中层级']]
};
var departmentMatchingModes = {fixed: '固定归属(全平台)', split: '按平台分配归属', pdd_only: '拼多多专属,其他回落平台层'};
async function departmentMatchingRequest(path, data) {
var response = await fetch('/api/department-matching/' + path, data === undefined ? {} : {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
});
var result = await response.json();
if (!response.ok || result.code !== 0) throw new Error(result.msg || '请求失败');
return result;
}
async function loadDepartmentMatchingPage(container) {
container.innerHTML = `<style>
.dm-table{border-collapse:collapse;width:100%;font-size:13px}
.dm-table th,.dm-table td{border:1px solid var(--border,#e5e7eb);padding:10px 12px;text-align:left;vertical-align:top;overflow-wrap:anywhere;max-width:360px}
.dm-table th{background:var(--bg-secondary,#f5f6f8);position:sticky;top:0;z-index:1;white-space:nowrap}
.dm-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}
.dm-panel{padding:18px;margin-bottom:16px}.dm-help{color:var(--text-light,#6b7280);font-size:12px;line-height:1.8}
.dm-scroll{overflow:auto;max-height:calc(100vh - 470px);min-height:200px}
.dm-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
.dm-fields label{display:block;font-size:13px;margin-bottom:6px}.dm-fields .form-input{width:100%;box-sizing:border-box}
@media(max-width:700px){.dm-fields{grid-template-columns:1fr}}
</style><div class="page-header"><h1>部门匹配管理</h1></div>
<div class="card dm-panel"><strong>匹配测试</strong><div class="dm-help">品牌层优先 → 平台关键词按具体程度匹配 → 店铺为空时按来源精确兜底。仅试算归属,不修改业务数据。</div>
<div class="dm-toolbar"><input id="dmShop" class="form-input" style="width:360px;max-width:100%" placeholder="店铺名称" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
<input id="dmSource" class="form-input" style="width:230px;max-width:100%" placeholder="数据来源(店铺为空时使用)" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
<button class="btn btn-primary" onclick="testDepartmentMatching()">测试匹配</button><button class="btn" onclick="checkDepartmentMatching()">核验店铺对照表</button></div>
<div id="dmResult" role="status" class="dm-help">请输入店铺或数据来源进行测试。</div></div>
<div class="card dm-panel"><div id="dmTabs" class="dm-toolbar"></div>
<div class="dm-toolbar"><input id="dmSearch" class="form-input" style="width:300px" placeholder="搜索关键词、归属、店铺或说明" onkeydown="if(event.key==='Enter')searchDepartmentMatching(false)">
<button class="btn btn-primary" onclick="searchDepartmentMatching(false)">搜索</button><button class="btn" onclick="searchDepartmentMatching(true)">重置</button>
<button id="dmCreate" class="btn btn-primary" style="margin-left:auto;display:none" onclick="editDepartmentMatching(0)">新增</button></div>
<div class="dm-help" style="margin-bottom:10px">非品牌清单用于解释排除项;归属通过平台规则判定。平台层先比较关键词长度,长度相同时优先级数字越小越优先。</div>
<div id="dmTable" class="dm-scroll">正在加载…</div><div id="dmPager" class="dm-toolbar" style="justify-content:center"></div></div>`;
try {
var result = await departmentMatchingRequest('records');
departmentMatchingState.rows = result.data;
renderDepartmentMatching();
} catch (err) { document.getElementById('dmTable').textContent = err.message; }
}
function renderDepartmentMatching() {
var state = departmentMatchingState;
var tabs = document.getElementById('dmTabs');
if (!tabs) return;
tabs.innerHTML = Object.keys(departmentMatchingKinds).map(function(kind) {
return '<button class="btn ' + (state.kind === kind ? 'btn-primary' : '') + '" onclick="selectDepartmentMatchingTab(\'' + kind + '\')">' + departmentMatchingKinds[kind] + '(' + state.rows.filter(function(r){return r.kind === kind;}).length + ')</button>';
}).join('');
document.getElementById('dmCreate').style.display = window._hasPagePerm('department_matching', 'create') ? '' : 'none';
document.getElementById('dmSearch').value = state.search;
var rows = state.rows.filter(function(row) {
return row.kind === state.kind && (!state.search || departmentMatchingFields[row.kind].map(function(f){return row.payload[f[0]] || '';}).join(' ').toLowerCase().includes(state.search.toLowerCase()));
});
var pages = Math.max(1, Math.ceil(rows.length / 20));
state.page = Math.min(Math.max(state.page, 1), pages);
var fields = departmentMatchingFields[state.kind];
var canEdit = window._hasPagePerm('department_matching', 'edit');
var canDelete = window._hasPagePerm('department_matching', 'delete');
var rule = state.kind === 'platform' || state.kind === 'brand';
var showPriority = state.kind === 'platform';
var heads = '<th>序号</th>' + fields.map(function(f){return '<th>' + escHtml(f[1].replace('(必填)','')) + '</th>';}).join('') + (showPriority ? '<th>优先级</th>' : '') + (rule ? '<th>状态</th>' : '') + (canEdit || canDelete ? '<th>操作</th>' : '');
var body = rows.slice((state.page - 1) * 20, state.page * 20).map(function(row, i) {
return '<tr><td>' + ((state.page - 1) * 20 + i + 1) + '</td>' + fields.map(function(f) {
var value = row.payload[f[0]] || '';
if (f[0] === 'mode') value = departmentMatchingModes[value] || value;
return '<td>' + escHtml(String(value)) + '</td>';
}).join('') + (showPriority ? '<td>' + row.priority + '</td>' : '') + (rule ? '<td>' + (row.enabled ? '启用' : '停用') + '</td>' : '') + (canEdit || canDelete ? '<td style="white-space:nowrap">' +
(canEdit ? '<button class="btn btn-sm" onclick="editDepartmentMatching(' + row.id + ')">编辑</button> ' : '') +
(canDelete ? '<button class="btn btn-sm" style="color:#dc2626" onclick="deleteDepartmentMatching(' + row.id + ')">删除</button>' : '') + '</td>' : '') + '</tr>';
}).join('');
document.getElementById('dmTable').innerHTML = '<table class="dm-table"><thead><tr>' + heads + '</tr></thead><tbody>' + (body || '<tr><td colspan="15">暂无记录</td></tr>') + '</tbody></table>';
document.getElementById('dmPager').innerHTML = '<span>共 ' + rows.length + ' 条,每页20条</span><button class="btn" onclick="pageDepartmentMatching(-1)" ' + (state.page === 1 ? 'disabled' : '') + '>上一页</button><span>' + state.page + ' / ' + pages + '</span><button class="btn" onclick="pageDepartmentMatching(1)" ' + (state.page === pages ? 'disabled' : '') + '>下一页</button><input id="dmJump" class="form-input" type="number" min="1" max="' + pages + '" style="width:70px" value="' + state.page + '" onkeydown="if(event.key===\'Enter\')pageDepartmentMatching(0)"><button class="btn" onclick="pageDepartmentMatching(0)">跳转</button>';
}
function selectDepartmentMatchingTab(kind) { departmentMatchingState.kind=kind; departmentMatchingState.page=1; renderDepartmentMatching(); }
function searchDepartmentMatching(reset) { departmentMatchingState.search=reset?'':document.getElementById('dmSearch').value.trim(); departmentMatchingState.page=1; renderDepartmentMatching(); }
function pageDepartmentMatching(delta) {
departmentMatchingState.page = delta ? departmentMatchingState.page + delta : (parseInt(document.getElementById('dmJump').value,10) || 1);
renderDepartmentMatching();
}
function editDepartmentMatching(id) {
var row = departmentMatchingState.rows.find(function(r){return r.id === id;}) || {id:0, kind:departmentMatchingState.kind, payload:{}, priority:100, enabled:1};
document.getElementById('dmEditModal')?.remove();
var modal = document.createElement('div'); modal.id='dmEditModal'; modal.className='modal-overlay'; modal.style.cssText='display:flex;z-index:15000';
var fields = departmentMatchingFields[row.kind].map(function(f) {
var value = row.payload[f[0]] || '';
var control;
if (f[0] === 'mode') control = '<select class="form-input" data-dm-field="mode">' + Object.keys(departmentMatchingModes).map(function(mode) {return '<option value="' + mode + '" ' + (value === mode ? 'selected' : '') + '>' + departmentMatchingModes[mode] + '</option>';}).join('') + '</select>';
else control = '<textarea class="form-input" rows="' + (['notes','guidance','reason'].includes(f[0]) ? 3 : 2) + '" maxlength="4000" data-dm-field="' + f[0] + '">' + escHtml(String(value)) + '</textarea>';
return '<div><label>' + escHtml(f[1]) + '</label>' + control + '</div>';
}).join('');
var rule = row.kind === 'brand' || row.kind === 'platform';
modal.innerHTML = '<div class="modal" style="width:min(760px,94vw);max-height:90vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + (id ? '编辑' : '新增') + departmentMatchingKinds[row.kind] + '</h3><button class="modal-close" onclick="document.getElementById(\'dmEditModal\').remove()">×</button></div><form id="dmEditForm" style="display:flex;flex-direction:column;min-height:0"><div class="modal-body" style="overflow:auto"><div class="dm-fields">' + fields + (rule ? '<div><label>优先级(0–100000,数字越小越优先)</label><input id="dmPriority" type="number" min="0" max="100000" required class="form-input" value="' + row.priority + '"></div><div><label>状态</label><select id="dmEnabled" class="form-input"><option value="1">启用</option><option value="0" ' + (!row.enabled ? 'selected' : '') + '>停用</option></select></div>' : '') + '</div><div class="dm-help" style="margin-top:12px">品牌关键词取自店铺名称。按平台分配时,拼多多优先使用拼多多归属,京东海外/国际其次,其他使用默认归属。非品牌条目用于说明,不独立覆盖归属。</div><div id="dmEditError" style="color:#dc2626;margin-top:8px" role="alert"></div></div><div class="modal-footer"><button type="button" class="btn" onclick="document.getElementById(\'dmEditModal\').remove()">取消</button><button id="dmSave" type="submit" class="btn btn-primary">保存</button></div></form></div>';
if (row.kind === 'brand') modal.querySelector('#dmPriority').parentElement.remove();
document.body.appendChild(modal);
document.getElementById('dmEditForm').onsubmit = async function(event) {
event.preventDefault();
var payload=Object.assign({},row.payload); modal.querySelectorAll('[data-dm-field]').forEach(function(el){payload[el.dataset.dmField]=el.value;});
var button=document.getElementById('dmSave'); button.disabled=true;
try {
await departmentMatchingRequest('save', {id:row.id, kind:row.kind, payload:payload, priority:row.kind==='platform'?Number(document.getElementById('dmPriority').value):row.priority, enabled:rule?Number(document.getElementById('dmEnabled').value):1});
departmentMatchingState.rows=(await departmentMatchingRequest('records')).data;
modal.remove(); renderDepartmentMatching();
} catch(err) { document.getElementById('dmEditError').textContent=err.message; button.disabled=false; }
};
}
async function deleteDepartmentMatching(id) {
if (!confirm('确认删除此记录?删除规则会影响后续匹配测试结果。')) return;
try { await departmentMatchingRequest('delete',{id:id}); departmentMatchingState.rows=(await departmentMatchingRequest('records')).data; renderDepartmentMatching(); }
catch(err) { alert(err.message); }
}
async function testDepartmentMatching() {
var el=document.getElementById('dmResult');
try {
var data=(await departmentMatchingRequest('test',{shop:document.getElementById('dmShop').value, source:document.getElementById('dmSource').value})).data;
el.textContent='归属:' + (data.department || '未匹配') + ' | ' + data.layer + ' | ' + data.reason;
} catch(err) { el.textContent=err.message; }
}
async function checkDepartmentMatching() {
var el=document.getElementById('dmResult'); el.textContent='正在核验…';
try {
var data=(await departmentMatchingRequest('check',{})).data;
el.textContent='核验完成:' + data.passed + ' / ' + data.total + ' 条归属一致。';
if (data.mismatches.length) el.innerHTML += '<div style="overflow:auto;max-height:240px"><table class="dm-table"><thead><tr><th>店铺</th><th>预期归属</th><th>实际归属</th><th>原因</th></tr></thead><tbody>' + data.mismatches.map(function(r){return '<tr><td>' + escHtml(r.shop) + '</td><td>' + escHtml(r.expected) + '</td><td>' + escHtml(r.actual || '未匹配') + '</td><td>' + escHtml(r.reason) + '</td></tr>';}).join('') + '</tbody></table></div>';
} catch(err) { el.textContent=err.message; }
}

View File

@ -200,10 +200,6 @@
<span class="nav-sub-icon">&#9679;</span> <span class="nav-sub-icon">&#9679;</span>
<span>人员信息管理</span> <span>人员信息管理</span>
</a> </a>
<a href="/department-matching" class="nav-sub-item {% if page == 'department_matching' %}active{% endif %}" data-page="department_matching">
<span class="nav-sub-icon">&#9679;</span>
<span>部门匹配管理</span>
</a>
</div> </div>
</div> </div>
{# 库存与出库(含子菜单): 所有人 #} {# 库存与出库(含子菜单): 所有人 #}
@ -397,10 +393,6 @@
<span class="nav-arrow">&#9662;</span> <span class="nav-arrow">&#9662;</span>
</a> </a>
<div class="nav-submenu" id="submenu-sysconfig"> <div class="nav-submenu" id="submenu-sysconfig">
<a href="/organization" class="nav-sub-item {% if page == 'organization' %}active{% endif %}" data-page="organization">
<span class="nav-sub-icon">&#9679;</span>
<span>组织架构</span>
</a>
<a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users"> <a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users">
<span class="nav-sub-icon">&#9679;</span> <span class="nav-sub-icon">&#9679;</span>
<span>用户管理</span> <span>用户管理</span>
@ -471,7 +463,6 @@
</script> </script>
<script src="/static/js/marked.min.js"></script> <script src="/static/js/marked.min.js"></script>
<script src="/static/js/dompurify.min.js"></script> <script src="/static/js/dompurify.min.js"></script>
<script src="/static/js/department_matching.js?v={{ app_js_version }}"></script>
<script src="/static/js/app.js?v={{ app_js_version }}"></script> <script src="/static/js/app.js?v={{ app_js_version }}"></script>
<script src="/static/js/knowledge-actions.js?v=1"></script> <script src="/static/js/knowledge-actions.js?v=1"></script>
<script> <script>
@ -659,7 +650,7 @@
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {}); var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) { if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) {
// 找第一个有权限的页面 // 找第一个有权限的页面
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','organization','department_matching','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']; var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','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++) { for (var pi = 0; pi < accessiblePages.length; pi++) {
var ap = accessiblePages[pi]; var ap = accessiblePages[pi];
var apPerms = perms[ap] || {}; var apPerms = perms[ap] || {};

View File

@ -1,120 +0,0 @@
# -*- coding: utf-8 -*-
import unittest
from unittest import mock
from routes import ai
from services import ai_datasources, audit_schema
class AiOperationHistoryTest(unittest.TestCase):
def test_history_query_returns_exact_and_legacy_matches_distinctly(self):
fake_logs = [
{'id': 2, 'created_at': '2026-10-28 10:00:00', 'user_id': 3, 'page': '计划预测',
'action': '数据变更', 'field': None, 'object_type': 'product', 'object_id': 'SKU-1',
'old_values': '{"采购计划":"甲"}', 'new_values': '{"采购计划":"乙"}',
'detail': '更新计划预测', 'result_status': 'success', 'request_id': 'r2',
'real_name': '张三', 'username': 'zhang'},
{'id': 1, 'created_at': '2026-10-27 10:00:00', 'user_id': 4, 'page': '计划预测',
'action': '数据变更', 'field': None, 'object_type': None, 'object_id': None,
'old_values': None, 'new_values': None, 'detail': '货品编号:SKU-1,采购计划调整',
'result_status': 'success', 'request_id': 'r1', 'real_name': '李四', 'username': 'li'},
]
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 2}], fake_logs]) as query:
result = ai_datasources._h_operation_history({'product_code': 'SKU-1', 'module': '计划预测'})
self.assertEqual(2, result['summary']['row_count'])
self.assertEqual('精确对象匹配', result['rows'][0]['匹配方式'])
self.assertIn('采购计划:甲 → 乙', result['rows'][0]['修改内容'])
self.assertEqual('历史文本匹配', result['rows'][1]['匹配方式'])
self.assertIn('object_id=%s', query.call_args_list[0].args[0])
self.assertIn('%计划预测%', query.call_args_list[0].args[1])
def test_chinese_and_nested_product_identifiers_are_inferred(self):
self.assertEqual(('product', 'SKU-2'), audit_schema.infer_object({'货品编号': 'SKU-2'}))
self.assertEqual(('product', 'SKU-3'), audit_schema.infer_object({'items': [{'产品编码': 'SKU-3'}]}))
self.assertEqual(('product', 'SKU-4'), audit_schema.infer_object({'id': 22, 'product': {'product_code': 'SKU-4'}}))
def test_history_requests_are_isolated_from_other_ai_sources(self):
tasks = ai._normalize_tasks([
{'datasource': 'product_profile', 'params': {'product_code': 'SKU-1'}},
{'datasource': 'operation_history', 'params': {'product_code': 'SKU-1'}},
{'datasource': 'planning_metrics', 'params': {'product_code': 'SKU-1'}},
])
self.assertEqual(['operation_history'], [task['datasource'] for task in tasks])
def test_history_can_search_legacy_text_by_product_name(self):
fake_logs = [{'id': 3, 'created_at': None, 'user_id': 1, 'page': '计划预测',
'action': '编辑', 'object_type': None, 'object_id': None,
'old_values': None, 'new_values': None, 'detail': '修改 Aromat 精华液',
'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]) as query:
result = ai_datasources._h_operation_history({'product_name': 'Aromat 精华液'})
self.assertEqual('历史文本匹配', result['rows'][0]['匹配方式'])
self.assertIn('%Aromat 精华液%', query.call_args_list[0].args[1])
def test_history_query_is_scoped_to_permitted_pages(self):
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 0}], []]) as query:
ai_datasources._h_operation_history({'_allowed_pages': ['planning', '计划预测']})
self.assertIn('COALESCE(NULLIF(l.page', query.call_args_list[0].args[0])
self.assertIn('planning', query.call_args_list[0].args[1])
def test_history_field_values_are_hidden_without_field_permission(self):
rows = [{'板块': '计划预测', '修改内容': '采购计划: (未记录) → 王某', '字段': '请求字段(旧日志未结构化)'}]
with mock.patch('routes.auth._has_effective_page_permission', return_value=True), \
mock.patch('services.field_perms.field_perms_for_page', return_value={'采购计划': {'view': False}}):
result = ai._filter_operation_history_permissions(1, '采购跟单', rows)
self.assertEqual('修改字段内容因字段权限不可见', result[0]['修改内容'])
def test_history_content_localizes_common_fields_and_keeps_full_values(self):
fake_logs = [{'id': 4, 'created_at': None, 'user_id': 1, 'page': '产品信息管理',
'action': '编辑产品', 'object_type': 'product', 'object_id': 'SKU-5',
'old_values': '{"purchase_plan":"甲"}',
'new_values': '{"purchase_plan":"乙","product_name":"Aromat 精华液"}',
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]):
result = ai_datasources._h_operation_history({'product_code': 'SKU-5'})
row = result['rows'][0]
self.assertEqual('采购计划', row['字段'])
self.assertIn('采购计划:甲 → 乙', row['修改内容'])
self.assertNotIn('货品名称', row['修改内容'])
def test_nested_json_field_keys_are_localized(self):
fake_logs = [{'id': 5, 'created_at': None, 'user_id': 1, 'page': 'planning',
'action': '数据变更', 'object_type': None, 'object_id': None,
'old_values': '{"columns":[{"field":"product_code","label":"货品编号"}],"sort_by":"product_name"}',
'new_values': '{"columns":[{"field":"product_code","label":"货品编号"}],"sort_by":"product_code"}',
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]):
row = ai_datasources._h_operation_history({'module': '计划预测'})['rows'][0]
self.assertEqual('排序字段', row['字段'])
self.assertIn('排序字段:货品名称 → 货品编号', row['修改内容'])
self.assertNotIn('字段列', row['修改内容'])
self.assertEqual('计划预测', row['板块'])
def test_history_omits_unverifiable_bulk_payload_and_shows_only_nested_changes(self):
fake_logs = [
{'id': 6, 'created_at': None, 'user_id': 1, 'page': '物料清单管理',
'action': '数据变更', 'object_type': 'product', 'object_id': 'SKU-6',
'old_values': None,
'new_values': '{"product_codes":["SKU-6"],"bom_data_map":{"SKU-6":[{"material_code":"MAT-1","quantity":2}]}}',
'detail': 'POST /api/bom/save;提交内容', 'result_status': 'success',
'real_name': '用户', 'username': 'u'},
{'id': 7, 'created_at': None, 'user_id': 1, 'page': '物料清单管理',
'action': '数据变更', 'object_type': 'product', 'object_id': 'SKU-7',
'old_values': '{"bom_data_map":{"SKU-7":[{"material_code":"MAT-1","quantity":2,"remark":"保留"}]}}',
'new_values': '{"bom_data_map":{"SKU-7":[{"material_code":"MAT-1","quantity":3,"remark":"保留"}]}}',
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'},
]
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 2}], fake_logs]):
rows = ai_datasources._h_operation_history({'module': '物料清单管理'})['rows']
self.assertEqual('该记录未保存可比较的字段前后值', rows[0]['修改内容'])
self.assertNotIn('SKU-6', rows[0]['修改内容'])
self.assertIn('数量:2 → 3', rows[1]['修改内容'])
self.assertNotIn('保留', rows[1]['修改内容'])
def test_history_source_requires_operation_log_page_permission(self):
with mock.patch('routes.auth._has_effective_page_permission', return_value=False):
self.assertFalse(ai._can_view_datasource(8, '采购跟单', 'operation_history'))
if __name__ == '__main__':
unittest.main()

View File

@ -22,26 +22,6 @@ class BackupRemainingPackagingTest(unittest.TestCase):
purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8') purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8')
self.assertIn("bm['post_pack_qty'] = post_pack_qty", purchase_source) self.assertIn("bm['post_pack_qty'] = post_pack_qty", purchase_source)
def test_backup_detail_includes_lead_time_and_packaging_progress(self):
root = Path(__file__).resolve().parents[1]
frontend = (root / 'static/js/app.js').read_text(encoding='utf-8')
backend = (root / 'routes/purchase.py').read_text(encoding='utf-8')
self.assertIn("['货期', (item.lead_time", frontend)
self.assertIn("item.lead_time + '天'", frontend)
self.assertIn('>下单进度</th>', frontend)
self.assertIn("b.order_progress || '-'", frontend)
normalize_source = backend[
backend.index('def _normalize_backup_bom('):
backend.index("# ─── API: 采购订单 ───")
]
self.assertIn("AS order_progress", normalize_source)
self.assertIn("BINARY `编码`=BINARY %s", normalize_source)
self.assertIn("BINARY `来源产品编码`=BINARY %s", normalize_source)
self.assertIn("`下单时间`=%s", normalize_source)
self.assertIn("current['order_progress']", normalize_source)
def test_backup_detail_reads_saved_planning_sales_snapshot(self): def test_backup_detail_reads_saved_planning_sales_snapshot(self):
source = purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8') source = purchase_source = (Path(__file__).resolve().parents[1] / 'routes/purchase.py').read_text(encoding='utf-8')
frontend = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8') frontend = (Path(__file__).resolve().parents[1] / 'static/js/app.js').read_text(encoding='utf-8')
@ -50,14 +30,11 @@ class BackupRemainingPackagingTest(unittest.TestCase):
self.assertIn("raw_snapshot = row.pop('sales_snapshot', None)", detail_source) self.assertIn("raw_snapshot = row.pop('sales_snapshot', None)", detail_source)
self.assertNotIn('_build_planning_sales_snapshot(', detail_source) self.assertNotIn('_build_planning_sales_snapshot(', detail_source)
self.assertIn('sales_snapshot, sales_snapshot_at', source) self.assertIn('sales_snapshot, sales_snapshot_at', source)
self.assertIn('与计划预测详情云仓数据完全同源', frontend) self.assertIn('与计划预测详情默认数据完全同源', frontend)
self.assertIn("return {'__scope': 'cloud'", source)
self.assertIn("row['sales_snapshot_scope']", source)
self.assertIn("salesSnapshotScope === 'cloud' ? '云仓数据' : '默认数据(历史)'", frontend)
self.assertIn('<span>销量信息</span></div>', frontend) self.assertIn('<span>销量信息</span></div>', frontend)
self.assertIn('class="pd-fields pd-fields-4col"', frontend) self.assertIn('class="pd-fields pd-fields-4col"', frontend)
self.assertIn("formatPopupFieldValue(field[0], value, salesInfo)", frontend) self.assertIn("formatPopupFieldValue(field[0], value, salesInfo)", frontend)
self.assertIn("['库存可支撑天数', salesLabelPrefix + '库存可支撑天数']", frontend) self.assertIn("['库存可支撑天数', '库存可支撑天数']", frontend)
self.assertIn('历史订单未保存销量快照', frontend) self.assertIn('历史订单未保存销量快照', frontend)

View File

@ -1,107 +0,0 @@
import unittest
from unittest.mock import patch
from flask import Flask
from services.department_matching import match_department, validate_record, check_samples
from routes.department_matching import department_matching_bp
def rule(kind, payload, row_id=1, priority=100, enabled=1):
return dict(kind=kind, payload=payload, id=row_id, priority=priority, enabled=enabled)
class MatchingTest(unittest.TestCase):
def test_specific_platform_precedes_general_keyword(self):
rows = [rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords=''), 1, 10),
rule('platform', dict(department='雪狼', shop_keywords='天猫国际', source_keywords=''), 2, 20)]
self.assertEqual(match_department('天猫国际', rows=rows)['department'], '雪狼')
def test_brand_overrides_platform_case_and_spaces(self):
rows = [rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords='')),
rule('brand', dict(keyword='DR JEVEITAL', mode='fixed', department='洁维塔'), 2)]
self.assertEqual(match_department('天猫-drjeveital旗舰店', rows=rows)['department'], '洁维塔')
def test_aromat_boundary(self):
rows = [rule('brand', dict(keyword='AROMAT', mode='fixed', department='雪狼'))]
self.assertEqual(match_department('AROMAT HERBS店', rows=rows)['department'], '雪狼')
self.assertEqual(match_department('AROMATHERBS店', rows=rows)['department'], '')
def test_conditional_brand_and_platform_fallback(self):
rows = [rule('brand', dict(keyword='MUTO', mode='split', department='灵动',
pdd_department='雪狼', overseas_department='雪狼')),
rule('brand', dict(keyword='UOUONE', mode='pdd_only', department='', pdd_department='春跃'), 2),
rule('platform', dict(department='战狼', shop_keywords='天猫', source_keywords=''), 3)]
for shop, expected in [('拼多多-MUTO', '雪狼'), ('京东海外-MUTO', '雪狼'),
('京东国际-MUTO', '雪狼'), ('抖音-MUTO', '灵动'),
('天猫-UOUONE', '战狼'), ('拼多多-UOUONE', '春跃')]:
with self.subTest(shop=shop):
self.assertEqual(match_department(shop, rows=rows)['department'], expected)
def test_source_only_for_empty_shop_and_exact(self):
rows = [rule('platform', dict(department='洁维塔', shop_keywords='阿里健康', source_keywords='jdmrt,京东洁维塔'))]
self.assertEqual(match_department(' ', 'JDMRT', rows)['department'], '洁维塔')
self.assertEqual(match_department('未知店铺', 'jdmrt', rows)['department'], '')
self.assertEqual(match_department('', 'jdmrt-extra', rows)['department'], '')
def test_disabled_rules_and_deterministic_priority(self):
rows = [rule('platform', dict(department='停用', shop_keywords='天猫国际', source_keywords=''), enabled=0),
rule('platform', dict(department='B', shop_keywords='天猫', source_keywords=''), 2, 20),
rule('platform', dict(department='A', shop_keywords='天猫', source_keywords=''), 3, 10)]
self.assertEqual(match_department('天猫国际', rows=rows)['department'], 'A')
def test_samples_report_mismatches_without_becoming_rules(self):
rows = [rule('shop', dict(shop='未知店铺', department='A'))]
self.assertEqual(match_department('未知店铺', rows=rows)['department'], '')
report = check_samples(rows)
self.assertEqual((report['total'], report['passed']), (1, 0))
self.assertEqual(report['mismatches'][0]['expected'], 'A')
def test_reject_invalid_conditions_and_priority(self):
for data in [dict(kind='unknown', payload={}),
dict(kind='platform', payload=dict(department='A')),
dict(kind='brand', payload=dict(keyword='A', mode='split', department='B')),
dict(kind='platform', payload=dict(department='A', shop_keywords='x'), priority=-1)]:
with self.subTest(data=data), self.assertRaises(ValueError):
validate_record(data)
class MatchingApiTest(unittest.TestCase):
def setUp(self):
app = Flask(__name__)
app.secret_key = 'testing'
app.register_blueprint(department_matching_bp)
self.client = app.test_client()
patcher = patch('routes.auth.query', return_value=[{'status': 1}])
patcher.start()
self.addCleanup(patcher.stop)
def login(self):
with self.client.session_transaction() as session:
session['user_id'] = 1
session['role'] = 'viewer'
def test_view_and_write_permissions_are_enforced(self):
self.login()
with patch('routes.department_matching.role_required', return_value=False), \
patch('routes.department_matching.operation_required', return_value=False), \
patch('routes.department_matching.query') as db:
for path in ('records', 'save', 'delete', 'test', 'check'):
response = self.client.get('/api/department-matching/records') if path == 'records' else self.client.post('/api/department-matching/' + path, json={})
self.assertEqual(response.status_code, 403)
db.assert_not_called()
def test_read_only_test_has_no_writes(self):
self.login()
with patch('routes.department_matching.role_required', return_value=True), \
patch('routes.department_matching.match_department', return_value={'department': '雪狼'}) as match, \
patch('routes.department_matching.query') as db, \
patch('routes.department_matching.add_operation_log') as log:
response = self.client.post('/api/department-matching/test', json={'shop': '天猫国际'})
self.assertEqual(response.get_json()['data']['department'], '雪狼')
match.assert_called_once_with('天猫国际', '')
db.assert_not_called()
log.assert_not_called()
if __name__ == '__main__':
unittest.main()

View File

@ -1,43 +0,0 @@
"""库存数据看板月份与统计口径回归测试。"""
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class InventoryBoardLogicTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.frontend = ROOT.joinpath('static', 'js', 'app.js').read_text(encoding='utf-8')
cls.backend = ROOT.joinpath('routes', 'dashboard.py').read_text(encoding='utf-8')
def test_month_is_dynamic_and_uses_shanghai_timezone(self):
self.assertIn("timeZone: 'Asia/Shanghai'", self.frontend)
self.assertIn('inventoryBoardCurrentMonth()', self.frontend)
self.assertNotIn("monthInput.value = '2026-08'", self.frontend)
def test_snapshot_is_limited_to_selected_month(self):
self.assertIn(
'WHERE data_date >= %s AND data_date < %s',
self.backend,
)
def test_disabled_warehouses_and_all_warehouse_stockout_are_enforced(self):
self.assertIn("active_warehouse_condition('id')", self.backend)
self.assertIn('HAVING SUM(id.available_qty) <= 0', self.backend)
self.assertIn('_stockout_codes.intersection_update(_sales_30.keys())', self.backend)
self.assertNotIn('AND id.available_qty = 0', self.backend)
def test_external_value_is_optional_override(self):
self.assertIn('if key not in external_map', self.backend)
self.assertNotIn("if value in ('', None):\n result[key] = 0", self.backend)
def test_sku_thresholds_follow_visible_labels(self):
self.assertIn("float(row['qty'] or 0) >= 30", self.backend)
self.assertIn("float(row['qty'] or 0) >= 100", self.backend)
self.assertIn("float(row['qty'] or 0) >= 200", self.backend)
if __name__ == '__main__':
unittest.main()

View File

@ -22,8 +22,6 @@ class InventoryBoardStaleCacheTest(unittest.TestCase):
normalized = ' '.join(str(sql).split()).lower() normalized = ' '.join(str(sql).split()).lower()
if normalized.startswith('select count(*) as row_count'): if normalized.startswith('select count(*) as row_count'):
return [{'row_count': 10, 'qty_sum': 20, 'updated_signature': 'sig'}] return [{'row_count': 10, 'qty_sum': 20, 'updated_signature': 'sig'}]
if normalized.startswith('select warehouse_code, warehouse_property, disabled from warehouses'):
return [{'warehouse_code': '01', 'warehouse_property': '云仓', 'disabled': '否'}]
if normalized.startswith('create table if not exists inventory_board_system_cache'): if normalized.startswith('create table if not exists inventory_board_system_cache'):
return 0 return 0
if normalized.startswith('select payload, updated_at from inventory_board_system_cache'): if normalized.startswith('select payload, updated_at from inventory_board_system_cache'):

View File

@ -69,12 +69,6 @@ class NavigationOrderTest(unittest.TestCase):
self.assertIn('.nav-order-11 { order: 11; }', html) self.assertIn('.nav-order-11 { order: 11; }', html)
self.assertNotIn('测试问题', html) self.assertNotIn('测试问题', html)
def test_system_settings_contains_organization_page(self):
html = BASE_TEMPLATE.read_text(encoding='utf-8')
self.assertIn('href="/organization"', html)
self.assertIn('data-page="organization"', html)
self.assertIn('<span>组织架构</span>', html)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -1,43 +0,0 @@
"""包材订单到货批次不良品补填规则回归测试。"""
import unittest
from pathlib import Path
class PackagingArrivalDefectiveTest(unittest.TestCase):
def test_packaging_defective_allows_negative_and_over_arrival(self):
from routes.purchase import _arrival_batch_quantities
self.assertEqual((10, -3, True, True), _arrival_batch_quantities(
{'arrival_qty': '10', 'defective_qty': '-3'}, 'packaging'))
self.assertEqual((10, 30, True, True), _arrival_batch_quantities(
{'arrival_qty': '10', 'defective_qty': '30'}, 'packaging'))
def test_blank_defective_is_preserved_as_unfilled(self):
from routes.purchase import _arrival_batch_quantities
self.assertEqual((10, 0, True, False), _arrival_batch_quantities(
{'arrival_qty': '10', 'defective_qty': ''}, 'packaging'))
def test_domestic_and_overseas_use_same_defective_rules(self):
from routes.purchase import _arrival_batch_quantities
self.assertEqual((10, -1, True, True), _arrival_batch_quantities(
{'arrival_qty': '10', 'defective_qty': '-1'}, 'domestic'))
self.assertEqual((10, 11, True, True), _arrival_batch_quantities(
{'arrival_qty': '10', 'defective_qty': '11'}, 'overseas'))
def test_frontend_keeps_blank_and_only_validates_non_packaging_range(self):
source = Path(__file__).resolve().parents[1].joinpath('static/js/app.js').read_text(encoding='utf-8')
self.assertIn("defectiveRaw === '' ? ''", source)
self.assertNotIn("orderType !== 'packaging' && defectiveRaw !== ''", source)
self.assertIn("renderArrivalBatchStoredCell('number', 'batch-defective-qty'", source)
self.assertNotIn('min="0" class="batch-defective-qty"', source)
def test_defective_column_is_visible_for_all_three_order_types(self):
source = Path(__file__).resolve().parents[1].joinpath('static/js/app.js').read_text(encoding='utf-8')
self.assertIn('var showDefectiveQty = true;', source)
self.assertIn("renderArrivalBatchStoredCell('number', 'batch-defective-qty'", source)
if __name__ == '__main__':
unittest.main()

View File

@ -49,7 +49,7 @@ class PlanningNewOldTest(unittest.TestCase):
for existing, captured in [(None, '新品'), ({'id': 5, 'bom_items': None, 'new_old_product': '旧品'}, '旧品')]: for existing, captured in [(None, '新品'), ({'id': 5, 'bom_items': None, 'new_old_product': '旧品'}, '旧品')]:
writes = [] writes = []
def sql(query, params=None): def sql(query, params=None):
if 'SELECT id, bom_items, new_old_product' in query: if query.lstrip().startswith('SELECT id, bom_items'):
return [existing] if existing else [] return [existing] if existing else []
if query.lstrip().startswith('SELECT'): if query.lstrip().startswith('SELECT'):
return [] return []
@ -67,38 +67,6 @@ class PlanningNewOldTest(unittest.TestCase):
params = writes[0][1] params = writes[0][1]
self.assertEqual(params[-4] if existing else params[-3], captured) self.assertEqual(params[-4] if existing else params[-3], captured)
def test_save_order_modal_creates_hidden_draft_without_pending_order(self):
writes = []
def sql(query, params=None):
if 'SELECT id, remark, bom_items, formal_order_qty, status FROM pending_orders' in query:
return []
if 'SELECT product_name, product_type FROM products' in query:
return [{'product_name': '测试成品', 'product_type': '成品'}]
writes.append((query, params))
return 42
bom_items = [
{'material_code': '成品_P1', 'actual_quantity': 12, 'remark': '成品备注'},
{'material_code': 'MAT-1', 'material_name': '测试包材', 'quantity': 1,
'actual_quantity': 8, 'remark': '包材备注', 'pre_pack_qty': 5,
'pkg_remark': '余额备注', 'change_manuscript': '是'},
]
with self.app.test_request_context(json={
'product_code': 'P1', 'remark': '成品备注', 'bom_items': bom_items
}), mock.patch.object(purchase, 'operation_required', side_effect=lambda page, op: page == 'planning'), \
mock.patch.object(purchase, 'query', side_effect=sql), \
mock.patch.object(purchase, '_sync_pending_pre_packaging') as sync_pack, \
mock.patch.object(purchase, 'add_operation_log'):
response = purchase.purchase_update_pending.__wrapped__()
self.assertEqual(response.get_json()['code'], 0)
insert = next((q, p) for q, p in writes if q.lstrip().startswith('INSERT INTO pending_orders'))
self.assertIn("'draft'", insert[0])
self.assertEqual(insert[1][0:3], ('P1', '测试成品', '成品'))
self.assertEqual(insert[1][3], 12)
self.assertTrue(any('UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s' in q for q, _ in writes))
sync_pack.assert_called_once()
def test_batch_add_captures_each_product_choice(self): def test_batch_add_captures_each_product_choice(self):
writes = [] writes = []
def sql(query, params=None): def sql(query, params=None):
@ -119,31 +87,6 @@ class PlanningNewOldTest(unittest.TestCase):
self.assertEqual(response.get_json()['data']['added'], 2) self.assertEqual(response.get_json()['data']['added'], 2)
self.assertEqual([params[-3] for params in writes], ['新品', '旧品']) self.assertEqual([params[-3] for params in writes], ['新品', '旧品'])
def test_batch_add_promotes_existing_order_draft(self):
writes = []
def sql(query, params=None):
if "status='pending'" in query and query.lstrip().startswith('SELECT id'):
return []
if "status='draft'" in query and query.lstrip().startswith('SELECT id'):
return [{'id': 9, 'new_old_product': '新品', 'sales_snapshot': '{"昨日销量":1}',
'sales_snapshot_at': '2026-10-28 09:00:00'}]
if 'SELECT p.product_code' in query:
return [{'product_code': 'P1', 'product_name': '商品', 'product_type': '成品',
'planning_new_old': '新品'}]
if query.lstrip().startswith('SELECT'):
return []
writes.append((query, params))
return 1
with 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, '_build_planning_sales_snapshot') as snapshot, \
mock.patch.object(purchase, 'add_operation_log'):
response = purchase.purchase_batch_add_pending.__wrapped__()
self.assertEqual(response.get_json()['data'], {'added': 1, 'skipped': 0})
self.assertTrue(any("status='pending'" in q and 'WHERE id=%s' in q for q, _ in writes))
snapshot.assert_not_called()
def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self): def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self):
for value in ('新品', '旧品'): for value in ('新品', '旧品'):
writes = [] writes = []

View File

@ -19,4 +19,3 @@
{"checked_at": "2026-10-26T09:15:23.729554+08:00", "version": "c3d7f4b283212db955f8d15b681c6cf3fa80e3b2874936002bfc90d06d840f68", "target_inventory": "2026-10-26", "target_outbound": "2026-10-25", "missing": ["outbound:2026-10-25:dymuto", "outbound:2026-10-25:dyqsimelr", "outbound:2026-10-25:jdbz", "outbound:2026-10-25:jdgj", "outbound:2026-10-25:jdjwt", "outbound:2026-10-25:jdmrt", "outbound:2026-10-25:jdsmj", "outbound:2026-10-25:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-26", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-26", "key": "02", "rows": 1760}, {"kind": "inventory", "date": "2026-10-26", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-26", "key": "05", "rows": 1455}, {"kind": "inventory", "date": "2026-10-26", "key": "06", "rows": 909}, {"kind": "inventory", "date": "2026-10-26", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-26", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-26", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-26", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-26", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-26", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-26", "key": "14", "rows": 377}, {"kind": "inventory", "date": "2026-10-26", "key": "15", "rows": 1572}, {"kind": "inventory", "date": "2026-10-26", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-26", "key": "20", "rows": 9389}, {"kind": "inventory", "date": "2026-10-26", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-26", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-26", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-26", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-26", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-26", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-25", "key": "wdt", "rows": 2195}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"} {"checked_at": "2026-10-26T09:15:23.729554+08:00", "version": "c3d7f4b283212db955f8d15b681c6cf3fa80e3b2874936002bfc90d06d840f68", "target_inventory": "2026-10-26", "target_outbound": "2026-10-25", "missing": ["outbound:2026-10-25:dymuto", "outbound:2026-10-25:dyqsimelr", "outbound:2026-10-25:jdbz", "outbound:2026-10-25:jdgj", "outbound:2026-10-25:jdjwt", "outbound:2026-10-25:jdmrt", "outbound:2026-10-25:jdsmj", "outbound:2026-10-25:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-26", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-26", "key": "02", "rows": 1760}, {"kind": "inventory", "date": "2026-10-26", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-26", "key": "05", "rows": 1455}, {"kind": "inventory", "date": "2026-10-26", "key": "06", "rows": 909}, {"kind": "inventory", "date": "2026-10-26", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-26", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-26", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-26", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-26", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-26", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-26", "key": "14", "rows": 377}, {"kind": "inventory", "date": "2026-10-26", "key": "15", "rows": 1572}, {"kind": "inventory", "date": "2026-10-26", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-26", "key": "20", "rows": 9389}, {"kind": "inventory", "date": "2026-10-26", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-26", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-26", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-26", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-26", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-26", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-25", "key": "wdt", "rows": 2195}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
{"checked_at": "2026-10-27T09:15:28.445085+08:00", "version": "5c93a6bf338b2c936ee3f4f14fd0a47c985a093c9ea4887eaeb2331d1e0aaba4", "target_inventory": "2026-10-27", "target_outbound": "2026-10-26", "missing": ["outbound:2026-10-26:dymuto", "outbound:2026-10-26:dyqsimelr", "outbound:2026-10-26:jdbz", "outbound:2026-10-26:jdgj", "outbound:2026-10-26:jdjwt", "outbound:2026-10-26:jdmrt", "outbound:2026-10-26:jdsmj", "outbound:2026-10-26:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-27", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-27", "key": "02", "rows": 1762}, {"kind": "inventory", "date": "2026-10-27", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-27", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-27", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-27", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-27", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-27", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-27", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-27", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-27", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-27", "key": "14", "rows": 379}, {"kind": "inventory", "date": "2026-10-27", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-27", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-27", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-27", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-27", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-27", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-27", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-27", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-27", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-26", "key": "wdt", "rows": 2203}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"} {"checked_at": "2026-10-27T09:15:28.445085+08:00", "version": "5c93a6bf338b2c936ee3f4f14fd0a47c985a093c9ea4887eaeb2331d1e0aaba4", "target_inventory": "2026-10-27", "target_outbound": "2026-10-26", "missing": ["outbound:2026-10-26:dymuto", "outbound:2026-10-26:dyqsimelr", "outbound:2026-10-26:jdbz", "outbound:2026-10-26:jdgj", "outbound:2026-10-26:jdjwt", "outbound:2026-10-26:jdmrt", "outbound:2026-10-26:jdsmj", "outbound:2026-10-26:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-27", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-27", "key": "02", "rows": 1762}, {"kind": "inventory", "date": "2026-10-27", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-27", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-27", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-27", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-27", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-27", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-27", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-27", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-27", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-27", "key": "14", "rows": 379}, {"kind": "inventory", "date": "2026-10-27", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-27", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-27", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-27", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-27", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-27", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-27", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-27", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-27", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-26", "key": "wdt", "rows": 2203}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
{"checked_at": "2026-10-28T09:15:22.895734+08:00", "version": "1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83", "target_inventory": "2026-10-28", "target_outbound": "2026-10-27", "missing": ["outbound:2026-10-27:dymuto", "outbound:2026-10-27:dyqsimelr", "outbound:2026-10-27:jdbz", "outbound:2026-10-27:jdgj", "outbound:2026-10-27:jdjwt", "outbound:2026-10-27:jdmrt", "outbound:2026-10-27:jdsmj", "outbound:2026-10-27:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-28", "key": "01", "rows": 2381}, {"kind": "inventory", "date": "2026-10-28", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-28", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-28", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-28", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-28", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-28", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-28", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-28", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-28", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-28", "key": "13", "rows": 744}, {"kind": "inventory", "date": "2026-10-28", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-28", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-28", "key": "16", "rows": 276}, {"kind": "inventory", "date": "2026-10-28", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-28", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-28", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-28", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-28", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-28", "key": "29", "rows": 700}, {"kind": "inventory", "date": "2026-10-28", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-27", "key": "wdt", "rows": 2186}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"} {"checked_at": "2026-10-28T09:15:22.895734+08:00", "version": "1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83", "target_inventory": "2026-10-28", "target_outbound": "2026-10-27", "missing": ["outbound:2026-10-27:dymuto", "outbound:2026-10-27:dyqsimelr", "outbound:2026-10-27:jdbz", "outbound:2026-10-27:jdgj", "outbound:2026-10-27:jdjwt", "outbound:2026-10-27:jdmrt", "outbound:2026-10-27:jdsmj", "outbound:2026-10-27:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-28", "key": "01", "rows": 2381}, {"kind": "inventory", "date": "2026-10-28", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-28", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-28", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-28", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-28", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-28", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-28", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-28", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-28", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-28", "key": "13", "rows": 744}, {"kind": "inventory", "date": "2026-10-28", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-28", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-28", "key": "16", "rows": 276}, {"kind": "inventory", "date": "2026-10-28", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-28", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-28", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-28", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-28", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-28", "key": "29", "rows": 700}, {"kind": "inventory", "date": "2026-10-28", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-27", "key": "wdt", "rows": 2186}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
{"checked_at": "2026-10-29T09:15:23.959650+08:00", "version": "b0cac8db89c53cafba0449fb85717cd53a9a70f0ac356bfcb1bb12582edf217d", "target_inventory": "2026-10-29", "target_outbound": "2026-10-28", "missing": ["outbound:2026-10-28:dymuto", "outbound:2026-10-28:dyqsimelr", "outbound:2026-10-28:jdbz", "outbound:2026-10-28:jdgj", "outbound:2026-10-28:jdjwt", "outbound:2026-10-28:jdmrt", "outbound:2026-10-28:jdsmj", "outbound:2026-10-28:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-29", "key": "01", "rows": 2384}, {"kind": "inventory", "date": "2026-10-29", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-29", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-29", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-29", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-29", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-29", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-29", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-29", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-29", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-29", "key": "13", "rows": 745}, {"kind": "inventory", "date": "2026-10-29", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-29", "key": "15", "rows": 1577}, {"kind": "inventory", "date": "2026-10-29", "key": "16", "rows": 277}, {"kind": "inventory", "date": "2026-10-29", "key": "20", "rows": 9417}, {"kind": "inventory", "date": "2026-10-29", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-29", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-29", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-29", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-29", "key": "29", "rows": 702}, {"kind": "inventory", "date": "2026-10-29", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-28", "key": "wdt", "rows": 2151}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}

View File

@ -1 +1 @@
{"inventory:2026-10-29:01":"a0756f317b28e6f648e431cd44a894a92ba90dfce9f58508a1c774f9ad6ef0d0","inventory:2026-10-29:02":"ac4b8c3f4bc44f4d13030f253227268f43c7b94821878d1a90c7cecfe1b23cda","inventory:2026-10-29:04":"c899c38489e0f0e6cb35c6d208160bab84bb8653c0ca36a9a0e91ad70eb488f8","inventory:2026-10-29:05":"b6b6a51d9f9806012dae7cfa7701784ee739bc4476d8dde1906b8407a9c9b591","inventory:2026-10-29:06":"ed6fa1662393aa01a9666d5818deeb5cf6fe1b404f569bca83f0a6da50ca0504","inventory:2026-10-29:07":"bd0c62dc8b80a20b0b293e1b9944d45f3f30979750fa4770d3f77f2b2c0e2ace","inventory:2026-10-29:09":"f87c59ef82cc52eba6fba5dfeb519bb75b6f2e4dfaba08a2fa2e4a27a1d6a150","inventory:2026-10-29:10":"bcf8ff6f78ffc223ee6125df2d2b489008424daa1ce2a7f56314620fa4db8cbb","inventory:2026-10-29:11":"dd236bf42adf9b5ec05d5b0109ebef92d8a173fa2285551df38102c2bd5f6818","inventory:2026-10-29:12":"85b18fd3486a8bf6a10de9fd3dfbfc23ea3d3a28371442fd8c058cd954837f36","inventory:2026-10-29:13":"cda0408e658ee9b21e1b67ce189f03ea30ad8be4f9b091a91ccc69992df1cfb3","inventory:2026-10-29:14":"e8bbf760e54c1865b6c02c24259e095d07cb13e7107bb45cd0739ef606c958f7","inventory:2026-10-29:15":"a479de395b7d343952059c58d035bf9245760cf9781460ffb3c2f4212a72fe0b","inventory:2026-10-29:16":"7e2ec0676667fae020ebd26517e404c26b0ceb91f1ab56a460ea7d96809bd449","inventory:2026-10-29:20":"cb5ad1bed854158f723ade267cadcdd44d633015308b13deb97836b2252beb9d","inventory:2026-10-29:24":"7e2a4ee729327513326f2b20f7995a90a5582cf886c921b1d618b5f95fcf60ef","inventory:2026-10-29:25":"7d71ff3dca7adf3069feca3229eac7c59efc5990790219d89ec980d9eb8b4150","inventory:2026-10-29:26":"c297c48aa84a8424a3f7665e2b7eb070df3cce57462900dd797975715f09034f","inventory:2026-10-29:27":"0583cebd82db2a92fa88c3cf989460e3cff37e370ed9eef27e4d373d72325da6","inventory:2026-10-29:29":"14e00982e3e067f8b12a955a1ef8d018aa0dd2b3f56e064eb6b1af3e5b758c6f","inventory:2026-10-29:35":"3390cf1fe2b74c34f6e1f1a421c6e7af3727e0e09e17e046bbc75eab4af4b7e7","outbound:2026-10-28:wdt":"3daad2fd47d7d4c503134f42033027aa4cb6b765078fef214c0cab1bdda78b71"} {"inventory:2026-10-28:01":"ee9ffde70d78afd9d4d27d6f1b22e9e0022fdd9c86e5c2c67d7d83f007503993","inventory:2026-10-28:02":"b6f32ab450d9b2f724a2b9ff161fbc1531efd9b279e6d09781d4b3bcb1f1d88f","inventory:2026-10-28:04":"d6ed21cd34cd5c2233762997ab25a36749c50d3e7e21f23451a6ec4f007c8f0e","inventory:2026-10-28:05":"46fe52b48ab93cab631ab1953ddb5c8726ffba25ac041b70bae4c3c6cd0b3e47","inventory:2026-10-28:06":"f0c8c9ead0853fc85c9cf26fe5a507b7aec0d3f2239a104b9b27333d84437198","inventory:2026-10-28:07":"8ac84fc6bedf774a7508ccbeeceb1390157876e326a0a6a2c03ad06e5d050ac1","inventory:2026-10-28:09":"2f0b19ffda192f152a43518da4e45e181a39fb93c790c1d7beaf15dfae57d25f","inventory:2026-10-28:10":"1dce710dbce65d875380ff6ebbc71eaa9b08334be2a86d49bab0b0c8699089ef","inventory:2026-10-28:11":"6b4271d5400dff848b73e41ae9cae8b3e9c4ae5dadf827e55a7ffb8359646078","inventory:2026-10-28:12":"36eccf226239325947a7e174a122d0915e7539e8219b5fb6c7377a31977b8ef6","inventory:2026-10-28:13":"f81ca4914ef3d5b3dcc1a5a89db8e3dbfafec2a06ceea6e165f7b3af44faef96","inventory:2026-10-28:14":"042427a0adf4670d4081d1ca290f85c85540a2b5cdc89ec258ef37aba5616e6e","inventory:2026-10-28:15":"62bc97bbb873b36e2ddddbf28910a4dc4bc8ba4d03bbf499d8ac5ca94adb6ea1","inventory:2026-10-28:16":"921f538bcf32911b784574dae5a1ddad961896fb3f85c5bf6a60adb56b08695e","inventory:2026-10-28:20":"646999613cdddee716b26e44c8df672172b0127b221e1e7730ff6e2867a4c662","inventory:2026-10-28:24":"9447b4d1792fb51f33a1dd197e449a79681403635f1c4aceaec063318c38f1e6","inventory:2026-10-28:25":"db3a781aee1ef0b9b645fcee4841d8e1a4eec508239765175566bb3a86cd2d02","inventory:2026-10-28:26":"36a8c7b6bb21d6a7bc94c593eb51cd0a6d352bea77d770582ef6abfea62be05d","inventory:2026-10-28:27":"5e06568d122df8724d8f4494a12930dedd74a4d29a417046111e74f65848b4f0","inventory:2026-10-28:29":"205a39cf7ec53a49e69e7c62844b0843dd38d6ad743787af510821cc815edfd0","inventory:2026-10-28:35":"e31341fd65088cf85008539e52923049acdd03d00aee97ea785751f2055c95f6","outbound:2026-10-27:wdt":"45dbce5b64f9f35e48475ec85ed3e91fe764c4472450384a59358699aa316883"}

View File

@ -1 +1 @@
{"cache_refreshed":true,"changed":[{"date":"2026-10-29","key":"01","kind":"inventory","rows":2384},{"date":"2026-10-29","key":"02","kind":"inventory","rows":1764},{"date":"2026-10-29","key":"04","kind":"inventory","rows":617},{"date":"2026-10-29","key":"05","kind":"inventory","rows":1458},{"date":"2026-10-29","key":"06","kind":"inventory","rows":911},{"date":"2026-10-29","key":"07","kind":"inventory","rows":348},{"date":"2026-10-29","key":"09","kind":"inventory","rows":473},{"date":"2026-10-29","key":"10","kind":"inventory","rows":220},{"date":"2026-10-29","key":"11","kind":"inventory","rows":242},{"date":"2026-10-29","key":"12","kind":"inventory","rows":314},{"date":"2026-10-29","key":"13","kind":"inventory","rows":745},{"date":"2026-10-29","key":"14","kind":"inventory","rows":382},{"date":"2026-10-29","key":"15","kind":"inventory","rows":1577},{"date":"2026-10-29","key":"16","kind":"inventory","rows":277},{"date":"2026-10-29","key":"20","kind":"inventory","rows":9417},{"date":"2026-10-29","key":"24","kind":"inventory","rows":182},{"date":"2026-10-29","key":"25","kind":"inventory","rows":88},{"date":"2026-10-29","key":"26","kind":"inventory","rows":78},{"date":"2026-10-29","key":"27","kind":"inventory","rows":59},{"date":"2026-10-29","key":"29","kind":"inventory","rows":702},{"date":"2026-10-29","key":"35","kind":"inventory","rows":19},{"date":"2026-10-28","key":"wdt","kind":"outbound","rows":2151}],"checked_at":"2026-10-29T09:15:23.959650+08:00","coverage":{"inventory":21,"outbound":1},"desktop_notification":"delivered","files":22,"missing":["outbound:2026-10-28:dymuto","outbound:2026-10-28:dyqsimelr","outbound:2026-10-28:jdbz","outbound:2026-10-28:jdgj","outbound:2026-10-28:jdjwt","outbound:2026-10-28:jdmrt","outbound:2026-10-28:jdsmj","outbound:2026-10-28:tmgj"],"missing_policy":"skip","state":"success","target_inventory":"2026-10-29","target_outbound":"2026-10-28","version":"b0cac8db89c53cafba0449fb85717cd53a9a70f0ac356bfcb1bb12582edf217d"} {"cache_refreshed":true,"changed":[{"date":"2026-10-28","key":"01","kind":"inventory","rows":2381},{"date":"2026-10-28","key":"02","kind":"inventory","rows":1764},{"date":"2026-10-28","key":"04","kind":"inventory","rows":617},{"date":"2026-10-28","key":"05","kind":"inventory","rows":1458},{"date":"2026-10-28","key":"06","kind":"inventory","rows":911},{"date":"2026-10-28","key":"07","kind":"inventory","rows":348},{"date":"2026-10-28","key":"09","kind":"inventory","rows":473},{"date":"2026-10-28","key":"10","kind":"inventory","rows":220},{"date":"2026-10-28","key":"11","kind":"inventory","rows":242},{"date":"2026-10-28","key":"12","kind":"inventory","rows":314},{"date":"2026-10-28","key":"13","kind":"inventory","rows":744},{"date":"2026-10-28","key":"14","kind":"inventory","rows":382},{"date":"2026-10-28","key":"15","kind":"inventory","rows":1575},{"date":"2026-10-28","key":"16","kind":"inventory","rows":276},{"date":"2026-10-28","key":"20","kind":"inventory","rows":9393},{"date":"2026-10-28","key":"24","kind":"inventory","rows":182},{"date":"2026-10-28","key":"25","kind":"inventory","rows":88},{"date":"2026-10-28","key":"26","kind":"inventory","rows":78},{"date":"2026-10-28","key":"27","kind":"inventory","rows":59},{"date":"2026-10-28","key":"29","kind":"inventory","rows":700},{"date":"2026-10-28","key":"35","kind":"inventory","rows":19},{"date":"2026-10-27","key":"wdt","kind":"outbound","rows":2186}],"checked_at":"2026-10-28T09:15:22.895734+08:00","coverage":{"inventory":21,"outbound":1},"desktop_notification":"delivered","files":22,"missing":["outbound:2026-10-27:dymuto","outbound:2026-10-27:dyqsimelr","outbound:2026-10-27:jdbz","outbound:2026-10-27:jdgj","outbound:2026-10-27:jdjwt","outbound:2026-10-27:jdmrt","outbound:2026-10-27:jdsmj","outbound:2026-10-27:tmgj"],"missing_policy":"skip","state":"success","target_inventory":"2026-10-28","target_outbound":"2026-10-27","version":"1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83"}