全部优化

This commit is contained in:
Codex 2026-10-29 10:36:31 +08:00
parent 862f868979
commit c088fdb823
19 changed files with 1264 additions and 243 deletions

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: 按产品编码/名称筛选(模糊匹配) - product_code: 按产品编码/名称筛选(模糊匹配);operation_history 也支持 product_name 按产品名称检索旧日志文本
- 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,6 +48,8 @@ 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 或明确的板块/人员/日期条件;不做无条件全库扫描。
""" """
@ -86,6 +88,51 @@ 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):
@ -435,7 +482,28 @@ def ai_ask():
denied.append(ds.get('name') or key) denied.append(ds.get('name') or key)
continue continue
try: try:
result = ds['handler'](task.get('params') or {}) or {} params = dict(task.get('params') 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,
@ -468,7 +536,34 @@ 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:
@ -555,7 +650,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'} 'material_code', 'module', 'field', 'start_date', 'end_date', 'product_name'}
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))
@ -566,6 +661,10 @@ 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
@ -639,6 +738,9 @@ 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

@ -6,7 +6,11 @@ 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 resolve_cost_price, compute_inventory_cost_summary from services.inventory_calc import (
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
@ -668,12 +672,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 近 30 天 SUM(货品成本) - 月销货成本:daily_outbound 所选自然月 SUM(货品成本)
- 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位 - 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位
- 月采购入库:arrival_batches 近 30 天 SUM(arrival_qty) - 月采购入库:arrival_batches 所选自然月 SUM(arrival_qty)
- 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率 - 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率
- 动销SKU(>30/>100):近 30/100 天有出库记录的成品口径 SKU(排除内料) - 动销SKU(≥30/≥100):所选月累计出库达到 30/100 件的成品口径 SKU(排除内料)
- 总SKU:products 成品/清库存/现货/赠品计数;动销率 = 动销SKU(30天)/总SKU - 总SKU:月末启用仓库库存分别达到 1/200 件的成品口径去重 SKU
板块3 呆滞治理: 板块3 呆滞治理:
- 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天 - 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天
首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量 首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量
@ -692,8 +696,11 @@ 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("SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date < %s", (next_month,))[0]['d'] latest_date = query(
"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,
@ -756,19 +763,22 @@ 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:
value = external_map.get(key) if key not in external_map or external_map.get(key) in ('', None):
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):
result[key] = 0 continue
# 根据当月手工值实时计算派生指标。 # 根据当月手工值实时计算派生指标。
result['active_sku_30'] = result['active_sku'] result['active_sku_30'] = result['active_sku']
result['return_rate'] = round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2) if result['monthly_ship_cost'] else 0 result['return_rate'] = (
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
@ -833,10 +843,20 @@ 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 "
"FROM inventory_detail WHERE data_date=%s", (latest_date,))[0] f"FROM inventory_detail WHERE data_date=%s AND {active_warehouse_condition('inventory_detail')}",
_cache_key = 'inventory_board_system:%s:%s:%s:%s:%s' % ( (latest_date,),
month, latest_date, _inventory_signature['row_count'], )[0]
_inventory_signature['qty_sum'], _inventory_signature['updated_signature'] or '') # 仓库启停/属性变化也必须使缓存失效;使用短摘要避免 cache_key 超出表字段长度。
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:
@ -923,42 +943,65 @@ 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
LEFT JOIN products p ON id.product_code = p.product_code WHERE id.data_date = %s
WHERE id.data_date = %s AND id.warehouse_code != '20' AND {wh_cond} AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL) AND {active_warehouse_condition('id')} AND {wh_cond}
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("id.warehouse_code = %s", ('01',)), # 蝶雅仓成品 'wh01_amount': _wh_finished_amount(
'wh15_amount': _wh_finished_amount("id.warehouse_code = %s", ('15',)), # 加工厂仓成品 "id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (1,)),
'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 warehouse_code != '01'")] "AND COALESCE(disabled, '否') != '是' "
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)")]
if _cloud_codes: if _cloud_codes:
_ph = ','.join(['%s'] * len(_cloud_codes)) _cond, _params = _warehouse_match(_cloud_codes)
amounts['cloud_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _cloud_codes) amounts['cloud_amount'] = _wh_finished_amount(_cond, _params)
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:
_ph = ','.join(['%s'] * len(_overseas_codes)) _cond, _params = _warehouse_match(_overseas_codes)
amounts['overseas_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _overseas_codes) amounts['overseas_amount'] = _wh_finished_amount(_cond, _params)
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 warehouse_code != '01'", "AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND COALESCE(disabled, '否') != '是' "
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)",
('%自营%',))] ('%自营%',))]
if _self_codes: if _self_codes:
_ph = ','.join(['%s'] * len(_self_codes)) _cond, _params = _warehouse_match(_self_codes)
amounts['domestic_self_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _self_codes) amounts['domestic_self_amount'] = _wh_finished_amount(_cond, _params)
else: else:
amounts['domestic_self_amount'] = 0.0 amounts['domestic_self_amount'] = 0.0
return amounts return amounts
@ -987,25 +1030,38 @@ 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天 两个口径(近 N 天有出库、成品口径、排除内料) # 9. 动销SKU数量:所选自然月累计出库分别达到 30 / 100 件的 SKU 数。
def _active_sku(days): _monthly_sales = query("""
return int(query(""" SELECT d.货品编号 AS code, SUM(d.货品数量) AS qty
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
""", (max(month_start, latest_date - timedelta(days=days - 1)), next_month, '%内料%', '%-NL'))[0]['v']) AND (
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)
active_sku_30 = _active_sku(30) # 动销SKU数量(>30天) # 10. 总SKU数量:月末启用仓库的成品库存分别达到 1 / 200 件的去重 SKU 数。
active_sku_100 = _active_sku(100) # 动销SKU数量(>100天) _month_end_sku_rows = query(f"""
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
# 10. 总SKU数量 FROM inventory_detail id
total_sku = int(query( WHERE id.data_date = %s AND {active_warehouse_condition('id')}
"SELECT COUNT(*) AS v FROM products p " AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
"WHERE p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL" AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
)[0]['v']) 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)
)
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
@ -1015,6 +1071,7 @@ 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天销量(日均销量分母)
@ -1025,12 +1082,15 @@ 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(""" _inv_by_code = 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
LEFT JOIN products p ON id.product_code = p.product_code WHERE id.data_date = %s AND {active_warehouse_condition('id')}
WHERE id.data_date = %s 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 GROUP BY id.product_code
""", (latest_date, '%-NL', '%内料%')) """, (latest_date, '%-NL', '%内料%'))
@ -1060,15 +1120,20 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
# 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。 # 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。
# 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。 # 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。
_stock_rows = query(""" _stock_rows = query(f"""
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 id.available_qty = 0 AND {active_warehouse_condition('id')}
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, id.data_date ORDER BY id.product_code, id.data_date GROUP 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 = {}
@ -1088,6 +1153,8 @@ 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
@ -1146,7 +1213,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': 0, 'total_sku_200': total_sku_200,
'active_rate': active_rate, 'active_rate': active_rate,
'active_rate_100': 0, 'active_rate_100': 0,
# 板块3 呆滞治理 # 板块3 呆滞治理

View File

@ -19,6 +19,7 @@ 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__)
@ -336,11 +337,13 @@ 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 WHERE status IN ('pending','draft') AND product_code IN ({placeholders})", codes) pending = query(f"""SELECT product_code, formal_order_qty FROM pending_orders
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 and qty_map[pc] > 0: if pc in qty_map:
row['正式下单数量'] = qty_map[pc] row['正式下单数量'] = qty_map[pc]
@ -720,6 +723,9 @@ 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():
@ -936,6 +942,7 @@ 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)
@ -970,7 +977,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','批注','产品分级','在途进度',
'昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存', '昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存',
'云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨', '云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨',
'生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数', '生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数',
@ -1303,16 +1310,18 @@ def planning_column_values():
'库存工厂待生产可支撑天数','总支撑天数', '库存工厂待生产可支撑天数','总支撑天数',
'最低销量偏差率','最高销量偏差率','14天波动率', '最低销量偏差率','最高销量偏差率','14天波动率',
} }
if field in COMPUTED_NUMERIC_FIELDS: COMPUTED_TEXT_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_NUMERIC_FIELDS: if not db_col and field not in COMPUTED_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_NUMERIC_FIELDS: if field in COMPUTED_FIELDS:
# 计算字段:获取全量产品,走 enrichment 提取唯一值 # 计算字段:获取全量产品,走 enrichment 提取唯一值
try: try:
all_rows = query(f""" all_rows = query(f"""
@ -1344,6 +1353,7 @@ 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 = []
@ -1362,6 +1372,7 @@ 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,5 +1,6 @@
"""产品管理路由""" """产品管理路由"""
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,
@ -19,6 +20,7 @@ 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__)
@ -1008,6 +1010,16 @@ 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()
@ -1089,6 +1101,7 @@ 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
} }
@ -1152,6 +1165,7 @@ 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,6 +23,7 @@ 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__)
@ -1064,9 +1065,12 @@ 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)
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。 # 先复用当前待下单行,否则复用计划预测保存的草稿;ordered/rolled_back 历史不参与。
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,)) existing_pending = query("""SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at, status
if existing_pending and not is_update: 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""", (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物料清单
@ -1191,20 +1195,44 @@ 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():
"""更新待下单记录的备注等字段""" """保存计划预测下单信息或更新待下单记录;未下单的内容保存在隐藏草稿中。"""
denied = _require_purchase_operation('purchase', 'edit') if not (operation_required('purchase', 'edit') or operation_required('planning', 'edit')):
if denied: return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"})
return denied data = request.get_json(silent=True) or {}
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 remark, bom_items FROM pending_orders WHERE product_code=%s AND status='pending' LIMIT 1", "SELECT id, remark, bom_items, formal_order_qty, 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",
(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 = []
# 支持更新的字段 # 支持更新的字段
@ -1213,11 +1241,10 @@ 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 WHERE product_code=%s AND status='pending'", (remark, product_code)) query("UPDATE pending_orders SET remark=%s, updated_at=NOW() WHERE id=%s", (remark, current['id']))
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)
@ -1286,12 +1313,13 @@ 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 WHERE product_code=%s AND status='pending'", (bom_str, product_code)) query("UPDATE pending_orders SET bom_items=%s, updated_at=NOW() WHERE id=%s", (bom_str, current['id']))
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 WHERE product_code=%s AND status='pending'", (product_actual_qty, bom_str, product_code)) query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s, updated_at=NOW() WHERE id=%s",
(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)
@ -1776,6 +1804,9 @@ 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,))
@ -1785,6 +1816,10 @@ 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', ''))
@ -1792,6 +1827,19 @@ 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,
@ -2470,7 +2518,7 @@ def _arrival_operator_name():
def _ensure_arrival_batches_table(): def _ensure_arrival_batches_table():
"""Ensure arrival batch storage includes the defective quantity field.""" """Ensure arrival batch storage records which immutable fields were actually filled."""
global _ARRIVAL_BATCHES_SCHEMA_READY global _ARRIVAL_BATCHES_SCHEMA_READY
if _ARRIVAL_BATCHES_SCHEMA_READY: if _ARRIVAL_BATCHES_SCHEMA_READY:
return return
@ -2483,6 +2531,9 @@ 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
)""") )""")
@ -2500,21 +2551,46 @@ 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 _arrival_batch_quantities(data): def _optional_int(data, key, label):
"""Parse and validate gross/defective arrival quantities.""" """Return (value, filled), preserving the distinction between blank and zero."""
raw = data.get(key)
if raw in (None, ''):
return 0, False
try: try:
arrival_qty = int(data.get('arrival_qty') or 0) return int(raw), True
defective_qty = int(data.get('defective_qty') or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError('到货数量和不良品数量必须为整数') raise ValueError(f'{label}必须为整数')
if arrival_qty < 0 or defective_qty < 0:
raise ValueError('到货数量和不良品数量不能为负数')
def _arrival_batch_quantities(data, order_type='domestic'):
"""Parse quantities; packaging defective quantity may be any integer."""
arrival_qty, arrival_qty_filled = _optional_int(data, 'arrival_qty', '到货数量')
defective_qty, defective_qty_filled = _optional_int(data, 'defective_qty', '不良品数量')
if arrival_qty_filled and arrival_qty < 0:
raise ValueError('到货数量不能为负数')
if order_type != 'packaging' and defective_qty_filled:
if defective_qty < 0:
raise ValueError('不良品数量不能为负数')
if defective_qty > arrival_qty: if defective_qty > arrival_qty:
raise ValueError('不良品数量不能大于到货数量') raise ValueError('不良品数量不能大于到货数量')
return arrival_qty, defective_qty 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):
@ -2617,7 +2693,8 @@ 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
@ -2658,22 +2735,28 @@ 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_batch_quantities(data) arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled = _arrival_batch_quantities(data, order_type)
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, remark) (order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at,
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", ( arrival_date_filled, arrival_qty_filled, defective_qty_filled, remark)
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, data.get('remark') or '' arrival_qty, defective_qty, operator_name, operated_at,
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),
}}) }})
@ -2691,26 +2774,64 @@ 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()
if str(current_date or '')[:10] != str(data.get('arrival_date') or '')[:10]: requested_date = 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, ''):
if int(data.get('arrival_qty') or 0) != int(current.get('arrival_qty') or 0): try:
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, ''):
if int(data.get('defective_qty') or 0) != int(current.get('defective_qty') or 0): try:
requested_defective = int(data.get('defective_qty'))
except (TypeError, ValueError):
return jsonify({"code": 400, "msg": "不良品数量必须为整数"}), 400
if order_type != 'packaging' and requested_defective < 0:
return jsonify({"code": 400, "msg": "不良品数量不能为负数"}), 400
effective_arrival_qty = (
requested_qty if 'requested_qty' in locals() and data.get('arrival_qty') not in (None, '')
else int(current.get('arrival_qty') or 0)
)
if order_type != 'packaging' and requested_defective > effective_arrival_qty:
return jsonify({"code": 400, "msg": "不良品数量不能大于到货数量"}), 400
defective_locked = order_type != 'packaging' or 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 order_type == 'packaging' and 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 '')
@ -2723,10 +2844,14 @@ 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 remark=%s WHERE id=%s AND order_type=%s", "UPDATE arrival_batches SET " + ', '.join(update_fields) + " WHERE id=%s AND order_type=%s",
(final_remark, batch_id, order_type), tuple(update_params) + (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(
@ -2735,11 +2860,21 @@ 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,
}}) }})
@ -3503,44 +3638,7 @@ def packaging_orders_delete():
# ─── 成品提货表 API ─── # ─── 成品提货表 API ───
def _finished_order_progress_map(): def _finished_order_progress_map():
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。""" """读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
rows = [] return unfinished_order_progress_map()
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

@ -769,6 +769,256 @@ 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")
@ -935,6 +1185,15 @@ 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

@ -59,9 +59,36 @@ 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('_code','').replace('_id',''),str(value)) return (key.replace('_id',''),str(value))
return ('','') return ('','')

View File

@ -28,6 +28,7 @@ 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

@ -0,0 +1,85 @@
"""国内/海外订单统一在途进度口径。"""
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

@ -2442,19 +2442,30 @@ 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 thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px">' + escHtml(c) + '</th>'; }).join(''); var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1);
var tbody = rows.slice(0, 100).map(function(r) { window._aiResults = window._aiResults || {};
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 10px;font-size:12px;white-space:nowrap">' + escHtml(s) + '</td>'; 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>';
}).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>'
@ -2466,11 +2477,22 @@ 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>';
return toolbar + '<div style="overflow-x:auto;max-height:320px;overflow-y:auto"><table class="data-table" style="font-size:12px;min-width:100%">' + var tableStyle = operationHistoryTable
'<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint + ? 'font-size:12px;table-layout:fixed;width:1570px;min-width:1570px;border-collapse:collapse'
: '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];
@ -7627,12 +7649,7 @@ 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 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>
<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>
@ -7647,8 +7664,13 @@ function loadInventoryBoardPage(container) {
var monthInput = document.getElementById('invBoardMonth'); var monthInput = document.getElementById('invBoardMonth');
if (monthInput) { if (monthInput) {
monthInput.value = '2026-08'; var initialMonth = window._inventoryBoardSelectedMonth || inventoryBoardCurrentMonth();
monthInput.onchange = function() { reloadInventoryBoard(false); }; monthInput.innerHTML = inventoryBoardMonthOptions(initialMonth, 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())
@ -7664,9 +7686,36 @@ 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) || new Date().toISOString().slice(0, 7); return (el && el.value) || inventoryBoardCurrentMonth();
} }
function reloadInventoryBoard(force) { function reloadInventoryBoard(force) {
var month = inventoryBoardMonth(); var month = inventoryBoardMonth();
@ -7707,21 +7756,12 @@ function scheduleInventoryBoardFreshPoll(d) {
}, 1500); }, 1500);
} }
function inventoryManualMonthOptions() { function inventoryManualMonthOptions() {
var options = '<option value="">请选择月份</option>'; return inventoryBoardMonthOptions(inventoryBoardMonth(), true);
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','老品缺货率']]]
]; ];
@ -7800,10 +7840,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'},
@ -7911,12 +7951,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) {
@ -7974,8 +8014,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))) {
@ -8016,14 +8056,13 @@ function saveInventoryExternal(key) {
var WH_INFO = { var WH_INFO = {
'all': { 'all': {
title: '全部仓库成品', title: '全部仓库成品',
desc: '代表全部序号仓库数据之和', desc: '仓库信息管理中全部启用仓库的成品库存之和(排除20号原材料仓)'
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: '代表以下仓库数据之和', whs: '1、2、3、4、5、6、7、8、16、24、27、29号仓'}, 'cloud': {title: '云仓成品', desc: '仓库信息管理中已启用且勾选“云仓”属性的仓库之和(排除1号仓)'},
'overseas': {title: '海外仓成品', desc: '代表以下仓库数据之和', whs: '9、10、11、12、25、26号仓'}, 'overseas': {title: '海外仓成品', desc: '仓库信息管理中已启用且勾选“海外仓”属性的仓库之和'},
'domestic_self': {title: '国内平台自营仓成品', desc: '代表以下仓库数据之和', whs: '13、14号仓'} 'domestic_self': {title: '国内平台自营仓成品', desc: '已启用的国内平台自营仓之和(13、14号仓及名称含“自营”的非海外仓,排除1号仓)'}
}; };
// 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示) // 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示)
@ -8046,12 +8085,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: '月末所有在仓 SKU 库存数量(库存 ≥ 1)'}, 'total_sku': {title: '总SKU数量(库存≥1)', desc: '月末启用仓库中成品库存合计达到 1 件的去重 SKU 数'},
'total_sku_200': {title: '总SKU数量(库存>=200)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 200)'}, 'total_sku_200': {title: '总SKU数量(库存≥200)', desc: '月末启用仓库中成品库存合计达到 200 件的去重 SKU 数'},
'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) {
@ -14329,6 +14368,7 @@ 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},
@ -14425,7 +14465,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,
@ -15185,7 +15225,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['实际下单数量'] || 0; var actQty = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : (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>';
} }
@ -15314,7 +15354,8 @@ 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 currentVal = parseInt(r['实际下单数量']) || 0; var savedFormalQty = r['正式下单数量'];
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', '正式下单数量');
@ -15361,7 +15402,8 @@ function renderPlanTable(rows) {
}).join(''); }).join('');
var code = r.product_code || ''; var code = r.product_code || '';
// 序列化下单所需数据 // 序列化下单所需数据
var formalQty = parseInt(r['实际下单数量']) || 0; var formalQtyValue = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : r['实际下单数量'];
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 || '',
@ -18953,7 +18995,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) orderRemark = m.remark; if (m.remark !== undefined && m.remark !== null) orderRemark = m.remark;
} }
}); });
} }
@ -18965,6 +19007,23 @@ 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 || [];
// 获取正式下单数量(从计划预测页面元素获取) // 获取正式下单数量(从计划预测页面元素获取)
@ -21819,6 +21878,10 @@ function showOrderDetail(encodedStr, title) {
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);
@ -21827,9 +21890,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 += '<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>' + abHtml += renderArrivalBatchStoredCell('date', 'batch-date', dateVal, dateFilled, allowBlankFill, '到货日期') +
'<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-qty', qtyFilled ? qty : '', qtyFilled, allowBlankFill, '到货数量', true) +
(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>' : '') + (showDefectiveQty ? renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveFilled ? defectiveQty : '', defectiveFilled, true, '不良品数量', false) : '') +
'<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>' +
@ -21846,7 +21909,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>';
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>'; if (showDefectiveQty) 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>';
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>';
@ -21890,6 +21953,16 @@ function showOrderDetail(encodedStr, title) {
})(); })();
} }
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) {
@ -21923,7 +21996,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>' +
(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>' : '') + (orderType === 'packaging' ? '<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>' : '') +
'<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>' +
@ -22021,11 +22094,15 @@ 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 qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0; var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0; var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
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 && !qty) { alert('请输入到货日期或数量'); return; } if (!date && qtyRaw === '') { alert('请输入到货日期或数量'); return; }
if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; } if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; }
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
if (orderType !== 'packaging' && defectiveRaw !== '' && (defectiveQty < 0 || defectiveQty > (qty || 0))) { alert('不良品数量不能为负数或大于到货数量'); return; }
btn.disabled = true; btn.disabled = true;
btn.textContent = '⋯'; btn.textContent = '⋯';
fetch('/api/domestic/orders/add_batch', { fetch('/api/domestic/orders/add_batch', {
@ -22044,9 +22121,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>' +
'<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('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-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' + renderArrivalBatchStoredCell('number', 'batch-qty', qty, !!(res.data && res.data.arrival_qty_filled), orderType === 'packaging', '到货数量', true) +
(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>' : '') + (orderType === 'packaging' ? renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, !!(res.data && res.data.defective_qty_filled), true, '不良品数量', false) : '') +
'<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();
@ -22069,10 +22146,14 @@ 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 qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0; var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0; var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
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 (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; } if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; }
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
if (orderType !== 'packaging' && defectiveRaw !== '' && (defectiveQty < 0 || defectiveQty > (qty || 0))) { alert('不良品数量不能为负数或大于到货数量'); return; }
btn.disabled = true; btn.disabled = true;
btn.textContent = '⋯'; btn.textContent = '⋯';
fetch('/api/domestic/orders/update_batch', { fetch('/api/domestic/orders/update_batch', {
@ -22098,6 +22179,14 @@ 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');
var defectiveCell = df && df.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);
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);
@ -22253,10 +22342,12 @@ 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 q = parseInt(tr.querySelector('.batch-qty')?.value) || 0; var qRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
var f = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0; var fRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
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 || q || f) { if (d || qRaw !== '' || fRaw !== '') {
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 {
@ -22271,16 +22362,19 @@ 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: parseInt(q) || 0, defective_qty: parseInt(f) || 0, remark: r}); batchUpdates.push({id: bid, arrival_date: d, arrival_qty: q === '' ? '' : parseInt(q, 10), defective_qty: f === '' ? '' : parseInt(f, 10), remark: r});
} }
} }
}); });
var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) { var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) {
return b.defective_qty < 0 || b.defective_qty > b.arrival_qty; if (b.arrival_qty !== '' && (!Number.isInteger(b.arrival_qty) || b.arrival_qty < 0)) return true;
if (b.defective_qty !== '' && !Number.isInteger(b.defective_qty)) return true;
return orderType !== 'packaging' && b.defective_qty !== '' &&
(b.defective_qty < 0 || b.defective_qty > (b.arrival_qty || 0));
}); });
if (invalidBatch) { if (invalidBatch) {
showCenteredAlert('提示', '不良品数量不能为负数或大于到货数量'); showCenteredAlert('提示', orderType === 'packaging' ? '到货数量必须为非负整数,不良品数量必须为整数' : '数量格式错误,且不良品数量不能为负数或大于到货数量');
return; return;
} }
@ -28712,7 +28806,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', '新旧品', '产品分级', '在途进度',
'昨日销量', '销售趋势', '昨日销量', '销售趋势',
'库存', '加工厂库存', '生产在途', '采购在途', '库存', '加工厂库存', '生产在途', '采购在途',
'日销量预测', '日销量预测',
@ -28733,6 +28827,7 @@ var POPUP_FIELD_LABELS = {
'product_code': '货品编号', 'product_code': '货品编号',
'product_name': '货品名称', 'product_name': '货品名称',
'产品分级': '产品分级', '产品分级': '产品分级',
'在途进度': '在途进度',
'brand': '品牌', 'brand': '品牌',
'specification': '规格', 'specification': '规格',
'cost_price': '成本价', 'cost_price': '成本价',
@ -29207,7 +29302,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) savedRemark = m.remark; if (m.remark !== undefined && m.remark !== null) savedRemark = m.remark;
} }
}); });
} }
@ -29408,10 +29503,11 @@ 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 = '';
@ -29460,8 +29556,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 < salesFields.length; i++) { for (var i = 0; i < scopedSalesFields.length; i++) {
var cloudField = salesFields[i]; var cloudField = scopedSalesFields[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);
@ -29474,8 +29570,8 @@ window.showProductDetail = function(productCode) {
// 外仓数据与默认数据使用相同字段顺序和四列布局。 // 外仓数据与默认数据使用相同字段顺序和四列布局。
var externalSalesHtml = ''; var externalSalesHtml = '';
var externalSales = p['外仓数据'] || {}; var externalSales = p['外仓数据'] || {};
for (var i = 0; i < salesFields.length; i++) { for (var i = 0; i < scopedSalesFields.length; i++) {
var externalField = salesFields[i]; var externalField = scopedSalesFields[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

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

@ -0,0 +1,43 @@
"""库存数据看板月份与统计口径回归测试。"""
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,6 +22,8 @@ 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

@ -0,0 +1,38 @@
"""包材订单到货批次不良品补填规则回归测试。"""
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_other_order_types_keep_existing_validation(self):
from routes.purchase import _arrival_batch_quantities
with self.assertRaisesRegex(ValueError, '不能为负数'):
_arrival_batch_quantities({'arrival_qty': '10', 'defective_qty': '-1'}, 'domestic')
with self.assertRaisesRegex(ValueError, '不能大于到货数量'):
_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.assertIn("orderType !== 'packaging' && defectiveRaw !== ''", source)
self.assertIn("renderArrivalBatchStoredCell('number', 'batch-defective-qty'", source)
self.assertNotIn('min="0" class="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 query.lstrip().startswith('SELECT id, bom_items'): if 'SELECT id, bom_items, new_old_product' in query:
return [existing] if existing else [] return [existing] if existing else []
if query.lstrip().startswith('SELECT'): if query.lstrip().startswith('SELECT'):
return [] return []
@ -67,6 +67,38 @@ 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):
@ -87,6 +119,31 @@ 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,3 +19,4 @@
{"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-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"} {"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"}

View File

@ -1 +1 @@
{"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"} {"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"}

View File