diff --git a/routes/ai.py b/routes/ai.py index e0bd189..627406d 100644 --- a/routes/ai.py +++ b/routes/ai.py @@ -40,7 +40,7 @@ def _save_history(user_id, question, summary, datasource, result_type, content=N # 数据源使用说明(供 AI 参考参数格式) AI_PARAMS_GUIDE = """ 数据源参数说明(params 为 JSON 对象,可选字段): -- product_code: 按产品编码/名称筛选(模糊匹配) +- product_code: 按产品编码/名称筛选(模糊匹配);operation_history 也支持 product_name 按产品名称检索旧日志文本 - brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit / material_inventory / inventory_value / brand_overview 均支持) - sort_by: 排序字段(必须是该数据源返回的字段名) @@ -48,6 +48,8 @@ AI_PARAMS_GUIDE = """ - limit: 返回行数上限(默认50,最大200) - kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品 - 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 +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): """导出权限:显式配置优先,未配置时跟随页面查看权限""" if is_admin_role(role): @@ -435,7 +482,28 @@ def ai_ask(): denied.append(ds.get('name') or key) continue 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) datasets.append({ 'key': key, @@ -468,8 +536,35 @@ def ai_ask(): json.dumps(payload, ensure_ascii=False, default=str)) 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 + # 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。 - analysis = _apply_authoritative_inventory_summary(None, datasets, question) + if analysis is None: + analysis = _apply_authoritative_inventory_summary(None, datasets, question) if analysis is None: try: analysis = _synthesize_analysis(question, plan, datasets, denied, failed) @@ -555,7 +650,7 @@ def _normalize_tasks(raw_tasks): # 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。 allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days', '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} try: params['limit'] = max(1, min(int(params.get('limit') or 50), 200)) @@ -566,6 +661,10 @@ def _normalize_tasks(raw_tasks): seen.add(key) if len(tasks) >= 6: break + # 查询操作日志时仅允许读取该数据源,避免同一问题额外触发业务数据查询。 + history_tasks = [task for task in tasks if task['datasource'] == 'operation_history'] + if history_tasks: + return history_tasks[:1] return tasks @@ -639,6 +738,9 @@ def _plan_analysis(question, history, last_result): '7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,' '所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。' '全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n' + '8. 问某产品或板块的手动编辑、谁修改过、修改前后、操作历史时,使用operation_history;' + '优先传入product_code;用户只提供产品名称时传product_name,指定板块/人员/日期时传module/operator/start_date/end_date;' + '不要为查询操作记录调用其他业务数据源。\n' '只输出JSON对象,格式:' '{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",' '"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],' diff --git a/routes/dashboard.py b/routes/dashboard.py index b78c70a..3f2e9fb 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -6,7 +6,11 @@ from flask import Blueprint, render_template, request, jsonify, session, redirec from services.db import query 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.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.audit_schema import ensure_audit_schema import config as app_config @@ -668,12 +672,12 @@ def _inventory_board_core(month, force_system_refresh=False): 口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料 板块2 经营效率: - 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径) - - 月销货成本:daily_outbound 近 30 天 SUM(货品成本) + - 月销货成本:daily_outbound 所选自然月 SUM(货品成本) - 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位 - - 月采购入库:arrival_batches 近 30 天 SUM(arrival_qty) + - 月采购入库:arrival_batches 所选自然月 SUM(arrival_qty) - 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率 - - 动销SKU(>30/>100):近 30/100 天有出库记录的成品口径 SKU(排除内料) - - 总SKU:products 成品/清库存/现货/赠品计数;动销率 = 动销SKU(30天)/总SKU + - 动销SKU(≥30/≥100):所选月累计出库达到 30/100 件的成品口径 SKU(排除内料) + - 总SKU:月末启用仓库库存分别达到 1/200 件的成品口径去重 SKU 板块3 呆滞治理: - 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天 首次入库 = 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) # 此看板包含按月份人工保存的数据。Gunicorn 多 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: result = { '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_chunyue': '缺货SKU次数(国际-春跃)', } - # 这6个输入框以月份外部录入表为唯一数据源,不能被系统计算值或“人工新建”旧值覆盖。 + # 有外部录入时覆盖系统值;未录入或清空时回退系统计算值。 for key in external_fields: - value = external_map.get(key) - if value in ('', None): - result[key] = 0 + if key not in external_map or external_map.get(key) in ('', None): continue + value = external_map[key] try: result[key] = float(value) except (TypeError, ValueError): - result[key] = 0 + continue # 根据当月手工值实时计算派生指标。 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 month_days = (next_month - month_start).days 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( "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 " - "FROM inventory_detail WHERE data_date=%s", (latest_date,))[0] - _cache_key = 'inventory_board_system:%s:%s:%s:%s:%s' % ( - month, latest_date, _inventory_signature['row_count'], - _inventory_signature['qty_sum'], _inventory_signature['updated_signature'] or '') + f"FROM inventory_detail WHERE data_date=%s AND {active_warehouse_condition('inventory_detail')}", + (latest_date,), + )[0] + # 仓库启停/属性变化也必须使缓存失效;使用短摘要避免 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: _cached = cache_get(_cache_key) if _cached is not None: @@ -923,42 +943,65 @@ def _inventory_board_system(month, latest_date, force_refresh=False): rows = query(f""" SELECT id.product_code AS code, SUM(id.available_qty) AS qty FROM inventory_detail id - LEFT JOIN products p ON id.product_code = p.product_code - WHERE id.data_date = %s AND id.warehouse_code != '20' AND {wh_cond} - AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL) + WHERE id.data_date = %s + AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20) + 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 GROUP BY id.product_code """, (target_date,) + tuple(params) + ('%-NL', '%内料%')) 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 = { 'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品 - 'wh01_amount': _wh_finished_amount("id.warehouse_code = %s", ('01',)), # 蝶雅仓成品 - 'wh15_amount': _wh_finished_amount("id.warehouse_code = %s", ('15',)), # 加工厂仓成品 + 'wh01_amount': _wh_finished_amount( + "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( "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: - _ph = ','.join(['%s'] * len(_cloud_codes)) - amounts['cloud_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _cloud_codes) + _cond, _params = _warehouse_match(_cloud_codes) + amounts['cloud_amount'] = _wh_finished_amount(_cond, _params) else: amounts['cloud_amount'] = 0.0 _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: - _ph = ','.join(['%s'] * len(_overseas_codes)) - amounts['overseas_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _overseas_codes) + _cond, _params = _warehouse_match(_overseas_codes) + amounts['overseas_amount'] = _wh_finished_amount(_cond, _params) else: amounts['overseas_amount'] = 0.0 # 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓) _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')) " - "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: - _ph = ','.join(['%s'] * len(_self_codes)) - amounts['domestic_self_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _self_codes) + _cond, _params = _warehouse_match(_self_codes) + amounts['domestic_self_amount'] = _wh_finished_amount(_cond, _params) else: amounts['domestic_self_amount'] = 0.0 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_days = round(30 / turnover_rate, 1) if turnover_rate else None - # 9. 动销SKU数量:>30天 / >100天 两个口径(近 N 天有出库、成品口径、排除内料) - def _active_sku(days): - return int(query(""" - SELECT /*+ INDEX(d idx_date_product) */ COUNT(DISTINCT d.货品编号) AS v - FROM daily_outbound d - LEFT JOIN products p ON d.货品编号 = p.product_code - 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 - """, (max(month_start, latest_date - timedelta(days=days - 1)), next_month, '%内料%', '%-NL'))[0]['v']) + # 9. 动销SKU数量:所选自然月累计出库分别达到 30 / 100 件的 SKU 数。 + _monthly_sales = query(""" + SELECT d.货品编号 AS code, SUM(d.货品数量) AS qty + FROM daily_outbound d + WHERE d.日期 >= %s AND d.日期 < %s + AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s + 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天) - active_sku_100 = _active_sku(100) # 动销SKU数量(>100天) - - # 10. 总SKU数量 - total_sku = int(query( - "SELECT COUNT(*) AS v FROM products p " - "WHERE p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL" - )[0]['v']) + # 10. 总SKU数量:月末启用仓库的成品库存分别达到 1 / 200 件的去重 SKU 数。 + _month_end_sku_rows = query(f""" + SELECT id.product_code AS code, SUM(id.available_qty) AS qty + FROM inventory_detail id + WHERE id.data_date = %s AND {active_warehouse_condition('id')} + AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20) + AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s + 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天动销口径) 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+ _first_date = {r['product_code']: r['first_date'] for r in query(""" SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail + WHERE """ + active_warehouse_condition('inventory_detail') + """ GROUP BY product_code """)} # 近30天销量(日均销量分母) @@ -1025,12 +1082,15 @@ def _inventory_board_system(month, latest_date, force_refresh=False): GROUP BY 货品编号 """, (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 FROM inventory_detail id - LEFT JOIN products p ON id.product_code = p.product_code - WHERE id.data_date = %s - AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL) + WHERE id.data_date = %s AND {active_warehouse_condition('id')} + 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 GROUP BY id.product_code """, (latest_date, '%-NL', '%内料%')) @@ -1060,15 +1120,20 @@ def _inventory_board_system(month, latest_date, force_refresh=False): # 板块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 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 - AND id.available_qty = 0 - AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL) + AND {active_warehouse_condition('id')} + 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 - 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', '%内料%')) _stockout_codes = set() _stockout_by_code = {} @@ -1088,6 +1153,8 @@ def _inventory_board_system(month, latest_date, force_refresh=False): _stockout_codes.add(_prev_code) _run = 0 _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(""" SELECT product_code AS code, platform FROM products 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': active_sku_30, # 兼容旧字段(外部录入覆盖) 'total_sku': total_sku, - 'total_sku_200': 0, + 'total_sku_200': total_sku_200, 'active_rate': active_rate, 'active_rate_100': 0, # 板块3 呆滞治理 diff --git a/routes/planning.py b/routes/planning.py index 5f33104..561121a 100644 --- a/routes/planning.py +++ b/routes/planning.py @@ -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 services.field_perms import filter_rows as _filter_field_rows 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 planning_bp = Blueprint('planning', __name__) @@ -336,11 +337,13 @@ def _merge_pending_formal_qty(rows): if not codes: return 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} for row in rows: pc = row.get('product_code', '') - if pc in qty_map and qty_map[pc] > 0: + if pc in qty_map: row['正式下单数量'] = qty_map[pc] @@ -720,6 +723,9 @@ def planning_list(filters=None): extended_sales=extended_sales) row['连续建议天数'] = snapshot_map.get(str(row.get('product_code') or ''), 0) + attach_unfinished_order_progress(all_rows) + + for row in all_rows: # 筛选 match = True 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, extended_sales=extended_sales) attach_consecutive_days(rows) + attach_unfinished_order_progress(rows) # 获取所有匹配的产品编号(用于全选) 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 # 只允许计划预测字段,排除操作列及未知字段。 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天波动率', } - if field in COMPUTED_NUMERIC_FIELDS: + COMPUTED_TEXT_FIELDS = {'在途进度'} + COMPUTED_FIELDS = COMPUTED_NUMERIC_FIELDS | COMPUTED_TEXT_FIELDS + if field in COMPUTED_FIELDS: # 计算字段:走全量查询+enrichment 后提取唯一值 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": []}) from services.db import query - if field in COMPUTED_NUMERIC_FIELDS: + if field in COMPUTED_FIELDS: # 计算字段:获取全量产品,走 enrichment 提取唯一值 try: 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()} gc = _ensure_global_config() 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 = {} values = [] @@ -1362,6 +1372,7 @@ def planning_column_values(): extended_sales=extended_sales ) enriched['连续建议天数'] = snapshot_map.get(str(enriched.get('product_code') or ''), 0) + enriched['在途进度'] = progress_map.get(str(enriched.get('product_code') or ''), '') # 联动筛选:有 plan_filter 时过滤其他字段的条件 if plan_filter_str: try: diff --git a/routes/products.py b/routes/products.py index 46927d6..1b34bdd 100644 --- a/routes/products.py +++ b/routes/products.py @@ -1,5 +1,6 @@ """产品管理路由""" import os +import json import uuid from datetime import datetime 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) from routes.auth import login_required, role_required, operation_required 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__) @@ -1008,6 +1010,16 @@ def product_detail_with_bom(): SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark FROM bom_packaging WHERE product_code=%s """, (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 = {} for pr in pkg_rows: 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) }, "bom_materials": bom_materials, + "pending_order": pending_snapshot, "sales": sales_data, "orders_in_transit": orders_in_transit } @@ -1152,6 +1165,7 @@ def product_planning_detail(): ext_inv_map=ext_inv_map, extended_sales=extended_sales, ) + row['在途进度'] = unfinished_order_progress_map([pc]).get(pc, '') inv15_val = float(row.get('加工厂库存', 0) or 0) # 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。 diff --git a/routes/purchase.py b/routes/purchase.py index 6abc257..6fb8914 100644 --- a/routes/purchase.py +++ b/routes/purchase.py @@ -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, _personnel_group_members, _personnel_group_for_value) from services.planning_order_flag import normalize_new_old +from services.order_progress import unfinished_order_progress_map 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'} is_update = bool(keys & update_fields) - # 待下单当前行按 status='pending' 定位(同一商品可保留多条 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,)) - if existing_pending and not is_update: + # 先复用当前待下单行,否则复用计划预测保存的草稿;ordered/rolled_back 历史不参与。 + existing_pending = query("""SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at, 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""", (pc,)) + if existing_pending and existing_pending[0].get('status') == 'pending' and not is_update: return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"}) # 查询该产品的BOM物料清单 @@ -1191,20 +1195,44 @@ def purchase_add_pending(): @purchase_bp.route('/api/purchase/update_pending', methods=['POST']) @login_required def purchase_update_pending(): - """更新待下单记录的备注等字段""" - denied = _require_purchase_operation('purchase', 'edit') - if denied: - return denied - data = request.json + """保存计划预测下单信息或更新待下单记录;未下单的内容保存在隐藏草稿中。""" + if not (operation_required('purchase', 'edit') or operation_required('planning', 'edit')): + return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"}) + data = request.get_json(silent=True) or {} product_code = data.get('product_code', '') if not product_code: return jsonify({"code": 400, "msg": "参数错误"}) 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,) ) 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 = [] # 支持更新的字段 @@ -1213,11 +1241,10 @@ def purchase_update_pending(): old_remark = current.get('remark') or '' if old_remark != remark: 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') if bom_items is not None: - import json normalized_bom_items = _normalize_pending_bom_items(bom_items) product_actual_qty = _extract_product_actual_qty(normalized_bom_items, product_code) 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}' ) 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: old_formal_qty = int((current.get('formal_order_qty') or 0)) if 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 真源,保存时同步更新。 _sync_pending_pre_packaging(product_code, normalized_bom_items) @@ -1776,6 +1804,9 @@ def purchase_batch_add_pending(): if existing: skipped += 1 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, {}) # 查询BOM物料清单 bom_rows = query("SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s", (pc,)) @@ -1785,13 +1816,30 @@ def purchase_batch_add_pending(): 'material_name': r['material_name'], 'quantity': float(r['quantity']) } for r in bom_rows], ensure_ascii=False) if bom_rows else None - try: - sales_snapshot = _build_planning_sales_snapshot( - pc, p.get('product_name', ''), p.get('product_type', '')) - except Exception as exc: - return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"}) - sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str) - sales_snapshot_at = datetime.now() + 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: + sales_snapshot = _build_planning_sales_snapshot( + pc, p.get('product_name', ''), p.get('product_type', '')) + except Exception as exc: + return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"}) + sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str) + 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(""" INSERT INTO pending_orders (product_code, product_name, product_type, product_grade, supplier_name, @@ -2470,7 +2518,7 @@ def _arrival_operator_name(): 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 if _ARRIVAL_BATCHES_SCHEMA_READY: return @@ -2483,6 +2531,9 @@ def _ensure_arrival_batches_table(): defective_qty INT NOT NULL DEFAULT 0, operator_name VARCHAR(100) NOT NULL DEFAULT '', 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, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )""") @@ -2500,21 +2551,46 @@ def _ensure_arrival_batches_table(): query("""ALTER TABLE arrival_batches ADD COLUMN operated_at DATETIME NULL AFTER operator_name""") 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 -def _arrival_batch_quantities(data): - """Parse and validate gross/defective arrival quantities.""" +def _optional_int(data, key, label): + """Return (value, filled), preserving the distinction between blank and zero.""" + raw = data.get(key) + if raw in (None, ''): + return 0, False try: - arrival_qty = int(data.get('arrival_qty') or 0) - defective_qty = int(data.get('defective_qty') or 0) + return int(raw), True except (TypeError, ValueError): - raise ValueError('到货数量和不良品数量必须为整数') - if arrival_qty < 0 or defective_qty < 0: - raise ValueError('到货数量和不良品数量不能为负数') - if defective_qty > arrival_qty: - raise ValueError('不良品数量不能大于到货数量') - return arrival_qty, defective_qty + raise ValueError(f'{label}必须为整数') + + +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: + raise ValueError('不良品数量不能大于到货数量') + return arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled def _domestic_order_pre_packaging(order_id): @@ -2617,7 +2693,8 @@ def domestic_orders_detail_extras(): _ensure_arrival_batches_table() 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 WHERE order_id=%s AND order_type=%s ORDER BY arrival_date DESC, id DESC @@ -2658,22 +2735,28 @@ def domestic_orders_add_batch(): return jsonify({"code": 400, "msg": "订单类型错误"}) _ensure_arrival_batches_table() 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: return jsonify({"code": 400, "msg": str(exc)}) operator_name = _arrival_operator_name() operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') row_id = query("""INSERT INTO arrival_batches - (order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", ( + (order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, + 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, - 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) 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='到货批次及数量') 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"}) _ensure_arrival_batches_table() 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", (batch_id, order_type), ) if not batch_rows: return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"}) current = batch_rows[0] - # 已保存批次的核心字段不可修改,只允许在备注末尾追加留痕。 + # 已填写字段不可修改;首次保存时留空的字段允许补填一次,补填后立即锁定。 + update_fields = [] + update_params = [] + supplemented = [] if data.get('arrival_date') not in (None, ''): current_date = current.get('arrival_date') if hasattr(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 + 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 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 + 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 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 + 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() operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') old_remark = str(current.get('remark') or '') @@ -2723,10 +2844,14 @@ def domestic_orders_update_batch(): if added: trace = f"[{operated_at} {operator_name or '未知账号'}] {added}" final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace - query( - "UPDATE arrival_batches SET remark=%s WHERE id=%s AND order_type=%s", - (final_remark, batch_id, order_type), - ) + update_fields.append('remark=%s') + update_params.append(final_remark) + if update_fields: + query( + "UPDATE arrival_batches SET " + ', '.join(update_fields) + " WHERE id=%s AND order_type=%s", + 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) if final_remark != old_remark: add_operation_log( @@ -2735,11 +2860,21 @@ def domestic_orders_update_batch(): page=page_name, 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": { "remark": final_remark, "operator_name": current.get('operator_name') or '', "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 ─── def _finished_order_progress_map(): """读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。""" - rows = [] - for source, sql in ( - ('domestic', """ - SELECT `货品编号` AS product_code, `进度`, - CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR) - ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date, - id - FROM domestic_orders - WHERE `状态`='未完结' - """), - ('overseas', """ - SELECT `产品编码` AS product_code, `进度`, - CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR) - ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date, - id - FROM overseas_orders - WHERE `状态`='未完结' - """), - ): - for row in query(sql): - row['_source_order'] = 0 if source == 'domestic' else 1 - rows.append(row) - - def sort_key(row): - order_date = row.get('order_date') - if order_date and hasattr(order_date, 'strftime'): - date_text = order_date.strftime('%Y-%m-%d %H:%M:%S') - else: - date_text = str(order_date or '').strip() - return (0 if date_text else 1, date_text, row.get('_source_order', 0), row.get('id') or 0) - - rows.sort(key=sort_key) - progress_map = {} - for row in rows: - product_code = str(row.get('product_code') or '').strip() - if product_code and product_code not in progress_map: - progress_map[product_code] = row.get('进度') or '' - return progress_map + return unfinished_order_progress_map() def _finished_inventory_maps(): diff --git a/services/ai_datasources.py b/services/ai_datasources.py index d4ff277..1e39f57 100644 --- a/services/ai_datasources.py +++ b/services/ai_datasources.py @@ -769,6 +769,256 @@ def _h_supplier_delivery(params): rows = _apply_filters(rows, params) 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'(?查询无数据'; if (canExport === undefined || canExport === null) canExport = true; - var thead = columns.map(function(c) { return '' + escHtml(c) + ''; }).join(''); - var tbody = rows.slice(0, 100).map(function(r) { + var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1); + 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 ? '' + columns.map(function(c) { + return ''; + }).join('') + '' : ''; + var thead = columns.map(function(c) { return '' + escHtml(c) + ''; }).join(''); + var tbody = rows.slice(0, 100).map(function(r, rowIndex) { return '' + columns.map(function(c) { var v = r[c]; if (v === null || v === undefined) v = ''; var s = String(v); + if (c === '修改内容') { + var preview = s.length > 72 ? s.slice(0, 72) + '…' : s; + return '' + + '
' + escHtml(preview) + '' + + (s.length > 72 ? '' : '') + '
'; + } if (s.length > 60) s = s.slice(0, 60) + '…'; - return '' + escHtml(s) + ''; + return '' + escHtml(s) + ''; }).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 hint = total > rows.slice(0, 100).length ? '
共 ' + total + ' 行,当前仅展示前 ' + rows.slice(0, 100).length + ' 行,可点「导出CSV」查看全部
' @@ -2466,11 +2477,22 @@ function aiRenderTable(columns, rows, sql, totalRows, canExport) { : '(无导出权限)') + '共 ' + total + ' 行' + ''; - return toolbar + '
' + - '' + thead + '' + tbody + '
' + hint + + var tableStyle = operationHistoryTable + ? 'font-size:12px;table-layout:fixed;width:1570px;min-width:1570px;border-collapse:collapse' + : 'font-size:12px;min-width:100%'; + return toolbar + '
' + + colgroup + '' + thead + '' + tbody + '
' + hint + (sql ? '
SQL:' + escHtml(sql) + '
' : ''); } +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', ''); +}; + // 导出 CSV(带 BOM,Excel 直接打开不乱码) window.aiExportCsv = function(rid) { var res = (window._aiResults || {})[rid]; @@ -7627,12 +7649,7 @@ function loadInventoryBoardPage(container) {

库存数据看板

@@ -7647,8 +7664,13 @@ function loadInventoryBoardPage(container) { var monthInput = document.getElementById('invBoardMonth'); if (monthInput) { - monthInput.value = '2026-08'; - monthInput.onchange = function() { reloadInventoryBoard(false); }; + var initialMonth = window._inventoryBoardSelectedMonth || inventoryBoardCurrentMonth(); + 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' } }) .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 ? '' : ''; + 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 += ''; + }); + return options; +} function inventoryBoardMonth() { var el = document.getElementById('invBoardMonth'); - return (el && el.value) || new Date().toISOString().slice(0, 7); + return (el && el.value) || inventoryBoardCurrentMonth(); } function reloadInventoryBoard(force) { var month = inventoryBoardMonth(); @@ -7707,21 +7756,12 @@ function scheduleInventoryBoardFreshPoll(d) { }, 1500); } function inventoryManualMonthOptions() { - var options = ''; - 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 += ''; - } - } - return options; + return inventoryBoardMonthOptions(inventoryBoardMonth(), true); } function openInventoryManualModal() { var groups = [ ['库存金额',[['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','呆滞库存比例']]], ['缺货监控',[['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_refund_amount': {label:'月退款金额', unit:'万元', icon:'💸', color:'#eb2f96'}, 'monthly_purchase_inbound': {label:'月采购入库', unit:'件', icon:'🚚', color:'#13c2c2'}, - 'active_sku': {label:'动销SKU数量(大于30)', unit:'个', icon:'📊', color:'#52c41a'}, - 'active_sku_100': {label:'动销SKU数量(>100天)', unit:'个', icon:'📈', color:'#a0d911'}, - 'total_sku': {label:'总SKU数量(库存>=1)', unit:'个', icon:'🗃️', color:'#1890ff'}, - 'total_sku_200': {label:'总SKU数量(库存>=200)', unit:'个', icon:'📚', color:'#2f54eb'}, + 'active_sku': {label:'动销SKU数量(≥30件)', unit:'个', icon:'📊', color:'#52c41a'}, + 'active_sku_100': {label:'动销SKU数量(≥100件)', unit:'个', icon:'📈', color:'#a0d911'}, + 'total_sku': {label:'总SKU数量(库存≥1)', unit:'个', icon:'🗃️', color:'#1890ff'}, + 'total_sku_200': {label:'总SKU数量(库存≥200)', unit:'个', icon:'📚', color:'#2f54eb'}, 'slow_amount': {label:'呆滞库存金额', unit:'万元', icon:'🕸️', color:'#faad14'}, 'stockout_sku_domestic': {label:'缺货SKU次数(国内)', unit:'次', icon:'🇨🇳', color:'#f5222d'}, '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:'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:'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:'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:'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_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:'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:'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'} ]; var sec2Html = ''; cards2.forEach(function(s) { @@ -7974,8 +8014,8 @@ function saveInventoryExternal(key) { var val = (input.value || '').trim(); var label = {'monthly_ship_cost':'月销货成本','monthly_return_cost':'月退货成本', 'monthly_refund_amount':'月退款金额','monthly_purchase_inbound':'月采购入库', - 'active_sku':'动销SKU数量(大于30)','active_sku_100':'动销SKU数量(>100天)', - 'total_sku':'总SKU数量(库存>=1)','total_sku_200':'总SKU数量(库存>=200)', + 'active_sku':'动销SKU数量(≥30件)','active_sku_100':'动销SKU数量(≥100件)', + 'total_sku':'总SKU数量(库存≥1)','total_sku_200':'总SKU数量(库存≥200)', 'slow_amount':'呆滞库存金额'}[key] || key; // 空值 = 清空外部值回退内部计算 if (val !== '' && isNaN(parseFloat(val))) { @@ -8016,14 +8056,13 @@ function saveInventoryExternal(key) { var WH_INFO = { 'all': { title: '全部仓库成品', - desc: '代表全部序号仓库数据之和', - whs: '1、2、3、4、5、6、7、8、9、10、11、12、13、14、15、16、20、24、25、26、27、29号仓' + desc: '仓库信息管理中全部启用仓库的成品库存之和(排除20号原材料仓)' }, 'wh01': {title: '蝶雅仓成品', desc: '代表 1 号仓数据', whs: '1号仓(蝶雅广州新仓)'}, 'wh15': {title: '加工厂仓成品', desc: '代表 15 号仓数据', whs: '15号仓(加工厂储存仓)'}, - 'cloud': {title: '云仓成品', desc: '代表以下仓库数据之和', whs: '1、2、3、4、5、6、7、8、16、24、27、29号仓'}, - 'overseas': {title: '海外仓成品', desc: '代表以下仓库数据之和', whs: '9、10、11、12、25、26号仓'}, - 'domestic_self': {title: '国内平台自营仓成品', desc: '代表以下仓库数据之和', whs: '13、14号仓'} + 'cloud': {title: '云仓成品', desc: '仓库信息管理中已启用且勾选“云仓”属性的仓库之和(排除1号仓)'}, + 'overseas': {title: '海外仓成品', desc: '仓库信息管理中已启用且勾选“海外仓”属性的仓库之和'}, + 'domestic_self': {title: '国内平台自营仓成品', desc: '已启用的国内平台自营仓之和(13、14号仓及名称含“自营”的非海外仓,排除1号仓)'} }; // 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示) @@ -8046,12 +8085,12 @@ var CALC_INFO = { 'monthly_purchase_inbound': {title: '月采购入库', desc: '取自财务数据报表'}, 'turnover_rate': {title: '月库存周转率', desc: '月销货成本 ÷ 库存总金额'}, 'turnover_days': {title: '库存周转天数', desc: '当月天数 ÷ 月库存周转率'}, - 'active_sku': {title: '动销SKU数量(大于30)', desc: '当月至少销售发货 30 件的 SKU 数量'}, - 'active_sku_100': {title: '动销SKU数量(>100天)', desc: '当月至少销售发货 100 件的 SKU 数量'}, - 'total_sku': {title: '总SKU数量(库存>=1)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 1)'}, - 'total_sku_200': {title: '总SKU数量(库存>=200)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 200)'}, - 'active_rate': {title: '动销率(>30)', desc: '动销SKU数量(>30)÷ 总SKU数量(库存>=1)× 100%'}, - 'active_rate_100': {title: '动销率(>100)', desc: '动销SKU数量(>100)÷ 总SKU数量(库存>=1)× 100%'} + 'active_sku': {title: '动销SKU数量(≥30件)', desc: '所选自然月累计销售发货达到 30 件的 SKU 数量'}, + 'active_sku_100': {title: '动销SKU数量(≥100件)', desc: '所选自然月累计销售发货达到 100 件的 SKU 数量'}, + 'total_sku': {title: '总SKU数量(库存≥1)', desc: '月末启用仓库中成品库存合计达到 1 件的去重 SKU 数'}, + 'total_sku_200': {title: '总SKU数量(库存≥200)', desc: '月末启用仓库中成品库存合计达到 200 件的去重 SKU 数'}, + 'active_rate': {title: '动销率(≥30件)', desc: '动销SKU数量(≥30件)÷ 总SKU数量(库存≥1)× 100%'}, + 'active_rate_100': {title: '动销率(≥100件)', desc: '动销SKU数量(≥100件)÷ 总SKU数量(库存≥1)× 100%'} }; function showWhTip(key, el) { @@ -14329,6 +14368,7 @@ const PLAN_COLUMNS = [ {field: '新旧品', label: '新旧品', defaultShow: true}, {field: 'bom', label: '下单清单', defaultShow: true, filterable: false}, {field: '产品分级', label: '产品分级', defaultShow: true}, + {field: '在途进度', label: '在途进度', defaultShow: true}, {field: '昨日销量', label: '昨日销量', defaultShow: true}, {field: '云仓昨日销量', label: '云仓昨日销量', defaultShow: false}, {field: '外仓昨日销量', label: '外仓昨日销量', defaultShow: false}, @@ -14425,7 +14465,7 @@ const PLAN_FILTER_OPTIONS = { // 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询) 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, @@ -15185,7 +15225,7 @@ function renderPlanTable(rows) { var pcode = (r.product_code || ''); var pname = r.product_name || ''; var suggQty = r['建议下单数量'] || 0; - var actQty = r['实际下单数量'] || 0; + var actQty = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : (r['实际下单数量'] || 0); var remark = r['下单备注'] || ''; return ''; } @@ -15314,7 +15354,8 @@ function renderPlanTable(rows) { return '' + val + ''; } 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 codeHtml = escHtml(code); var canEditFormal = window._canEditField && window._canEditField('planning', '正式下单数量'); @@ -15361,7 +15402,8 @@ function renderPlanTable(rows) { }).join(''); 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 orderData = JSON.stringify({ product_code: r.product_code || '', @@ -18953,7 +18995,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR if (m.material_code === '成品_' + productCode) { // 修复:允许恢复为0(用户手动设0表示不下单) 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('❌ 获取物料清单失败'); 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 || []; // 获取正式下单数量(从计划预测页面元素获取) @@ -21819,6 +21878,10 @@ function showOrderDetail(encodedStr, title) { data.arrival_batches.forEach(function(b, idx) { var qty = parseInt(b.arrival_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; totalDefectiveQty += defectiveQty; var dateVal = (b.arrival_date || '').substring(0, 10); @@ -21827,9 +21890,9 @@ function showOrderDetail(encodedStr, title) { abHtml += '' + (idx + 1) + ''; abHtml += '' + escHtml(b.operator_name || '-') + ''; abHtml += '' + escHtml(b.operated_at || '-') + ''; - abHtml += '' + escHtml(dateVal || '-') + '' + - '' + qty + '' + - (showDefectiveQty ? '' + defectiveQty + '' : '') + + abHtml += renderArrivalBatchStoredCell('date', 'batch-date', dateVal, dateFilled, allowBlankFill, '到货日期') + + renderArrivalBatchStoredCell('number', 'batch-qty', qtyFilled ? qty : '', qtyFilled, allowBlankFill, '到货数量', true) + + (showDefectiveQty ? renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveFilled ? defectiveQty : '', defectiveFilled, true, '不良品数量', false) : '') + '0' + '
' + '' + @@ -21846,7 +21909,7 @@ function showOrderDetail(encodedStr, title) { abHtml += '-'; abHtml += ''; abHtml += ''; - if (showDefectiveQty) abHtml += ''; + if (showDefectiveQty) abHtml += ''; abHtml += '-'; abHtml += '
'; abHtml += ''; @@ -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 ''; + } + return '' + escHtml(safeValue || '-') + ''; +} + function appendArrivalBatchEditingRow(btn) { var overlay = btn.closest('.modal-overlay'); if (!overlay) { @@ -21923,7 +21996,7 @@ function appendArrivalBatchEditingRow(btn) { '-' + '' + '' + - (orderType === 'packaging' ? '' : '') + + (orderType === 'packaging' ? '' : '') + '' + '
' + '' + @@ -22021,11 +22094,15 @@ function saveNewArrivalBatch(btn, orderId, orderType) { var tr = btn.closest('tr'); if (!tr) return; var date = tr.querySelector('.batch-date')?.value || ''; - var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0; - var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0; + var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim(); + 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 || ''; - if (!date && !qty) { alert('请输入到货日期或数量'); return; } - if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; } + if (!date && qtyRaw === '') { 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.textContent = '⋯'; fetch('/api/domestic/orders/add_batch', { @@ -22044,9 +22121,9 @@ function saveNewArrivalBatch(btn, orderId, orderType) { '' + batchNum + '' + '' + escHtml(res.data && res.data.operator_name || '-') + '' + '' + escHtml(res.data && res.data.operated_at || '-') + '' + - '' + escHtml(date || '-') + '' + - '' + qty + '' + - (orderType === 'packaging' ? '' + defectiveQty + '' : '') + + renderArrivalBatchStoredCell('date', 'batch-date', date, !!(res.data && res.data.arrival_date_filled), orderType === 'packaging', '到货日期') + + renderArrivalBatchStoredCell('number', 'batch-qty', qty, !!(res.data && res.data.arrival_qty_filled), orderType === 'packaging', '到货数量', true) + + (orderType === 'packaging' ? renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, !!(res.data && res.data.defective_qty_filled), true, '不良品数量', false) : '') + '0' + '
'; recalcArrivalUnfinished(); @@ -22069,10 +22146,14 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) { var tr = btn.closest('tr'); if (!tr) return; var date = tr.querySelector('.batch-date')?.value || ''; - var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0; - var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0; + var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim(); + 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 || ''; - 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.textContent = '⋯'; fetch('/api/domestic/orders/update_batch', { @@ -22098,6 +22179,14 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) { var opt = tr.querySelector('.batch-operated-at'); if (op) op.textContent = (res.data && res.data.operator_name) || '-'; 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(); refreshOrderListFromArrivalModal(btn); setTimeout(function() { btn.textContent = '✅'; }, 500); @@ -22253,10 +22342,12 @@ function saveDetailChanges(btn) { if (tr.classList.contains('batch-placeholder') || tr.classList.contains('batch-editing')) { // 新增行:有日期或数量才保存 var d = tr.querySelector('.batch-date')?.value || ''; - var q = parseInt(tr.querySelector('.batch-qty')?.value) || 0; - var f = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0; + var qRaw = (tr.querySelector('.batch-qty')?.value || '').trim(); + 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 || ''; - if (d || q || f) { + if (d || qRaw !== '' || fRaw !== '') { batchAdds.push({arrival_date: d, arrival_qty: q, defective_qty: f, remark: r}); } } else { @@ -22271,16 +22362,19 @@ function saveDetailChanges(btn) { var of = tr.querySelector('.batch-defective-qty')?.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_)) { - 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) { - 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) { - showCenteredAlert('提示', '不良品数量不能为负数或大于到货数量'); + showCenteredAlert('提示', orderType === 'packaging' ? '到货数量必须为非负整数,不良品数量必须为整数' : '数量格式错误,且不良品数量不能为负数或大于到货数量'); return; } @@ -28712,7 +28806,7 @@ window.exportBom = function() { // ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)─── 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_name': '货品名称', '产品分级': '产品分级', + '在途进度': '在途进度', 'brand': '品牌', 'specification': '规格', 'cost_price': '成本价', @@ -29207,7 +29302,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) { window._pendingBomData[data.product_code].forEach(function(m) { if (m.material_code === '成品_' + data.product_code) { 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', '新旧品', '\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', '\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']; + var scopedSalesFields = salesFields.filter(function(field) { return field !== '在途进度'; }); // \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09 var basicHtml = ''; @@ -29460,8 +29556,8 @@ window.showProductDetail = function(productCode) { }; return labels[field] || POPUP_FIELD_LABELS[field] || field; }; - for (var i = 0; i < salesFields.length; i++) { - var cloudField = salesFields[i]; + for (var i = 0; i < scopedSalesFields.length; i++) { + var cloudField = scopedSalesFields[i]; var cloudLabel = scopedFieldLabel(cloudField, '云仓'); var cloudVal = cloudSales[cloudField] !== undefined ? cloudSales[cloudField] : '-'; var cloudValHtml = formatPopupFieldValue(cloudField, cloudVal, cloudSales); @@ -29474,8 +29570,8 @@ window.showProductDetail = function(productCode) { // 外仓数据与默认数据使用相同字段顺序和四列布局。 var externalSalesHtml = ''; var externalSales = p['外仓数据'] || {}; - for (var i = 0; i < salesFields.length; i++) { - var externalField = salesFields[i]; + for (var i = 0; i < scopedSalesFields.length; i++) { + var externalField = scopedSalesFields[i]; var externalLabel = scopedFieldLabel(externalField, '外仓'); var externalVal = externalSales[externalField] !== undefined ? externalSales[externalField] : '-'; var externalValHtml = formatPopupFieldValue(externalField, externalVal, externalSales); diff --git a/tests/test_ai_operation_history.py b/tests/test_ai_operation_history.py new file mode 100644 index 0000000..076b1a3 --- /dev/null +++ b/tests/test_ai_operation_history.py @@ -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() diff --git a/tests/test_inventory_board_logic.py b/tests/test_inventory_board_logic.py new file mode 100644 index 0000000..944a63d --- /dev/null +++ b/tests/test_inventory_board_logic.py @@ -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() diff --git a/tests/test_inventory_board_stale_cache.py b/tests/test_inventory_board_stale_cache.py index 4dfcba6..9873c3c 100644 --- a/tests/test_inventory_board_stale_cache.py +++ b/tests/test_inventory_board_stale_cache.py @@ -22,6 +22,8 @@ class InventoryBoardStaleCacheTest(unittest.TestCase): normalized = ' '.join(str(sql).split()).lower() if normalized.startswith('select count(*) as row_count'): 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'): return 0 if normalized.startswith('select payload, updated_at from inventory_board_system_cache'): diff --git a/tests/test_packaging_arrival_defective.py b/tests/test_packaging_arrival_defective.py new file mode 100644 index 0000000..a190584 --- /dev/null +++ b/tests/test_packaging_arrival_defective.py @@ -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() diff --git a/tests/test_planning_new_old.py b/tests/test_planning_new_old.py index 20e3e7e..635dc65 100644 --- a/tests/test_planning_new_old.py +++ b/tests/test_planning_new_old.py @@ -49,7 +49,7 @@ class PlanningNewOldTest(unittest.TestCase): for existing, captured in [(None, '新品'), ({'id': 5, 'bom_items': None, 'new_old_product': '旧品'}, '旧品')]: writes = [] def sql(query, params=None): - if query.lstrip().startswith('SELECT id, bom_items'): + if 'SELECT id, bom_items, new_old_product' in query: return [existing] if existing else [] if query.lstrip().startswith('SELECT'): return [] @@ -67,6 +67,38 @@ class PlanningNewOldTest(unittest.TestCase): params = writes[0][1] 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): writes = [] def sql(query, params=None): @@ -87,6 +119,31 @@ class PlanningNewOldTest(unittest.TestCase): self.assertEqual(response.get_json()['data']['added'], 2) 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): for value in ('新品', '旧品'): writes = [] diff --git a/var/ding-sync/local-cron.log b/var/ding-sync/local-cron.log index b04abc4..c67f6a5 100644 --- a/var/ding-sync/local-cron.log +++ b/var/ding-sync/local-cron.log @@ -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-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-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"} diff --git a/var/ding-sync/local-ledger.json b/var/ding-sync/local-ledger.json index d620d7f..e759b2b 100644 --- a/var/ding-sync/local-ledger.json +++ b/var/ding-sync/local-ledger.json @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/var/ding-sync/local-status.json b/var/ding-sync/local-status.json index b820c2f..c860690 100644 --- a/var/ding-sync/local-status.json +++ b/var/ding-sync/local-status.json @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/var/stagnant-monthly-snapshot.lock b/var/stagnant-monthly-snapshot.lock new file mode 100644 index 0000000..e69de29