全部优化
This commit is contained in:
parent
862f868979
commit
c088fdb823
108
routes/ai.py
108
routes/ai.py
@ -40,7 +40,7 @@ def _save_history(user_id, question, summary, datasource, result_type, content=N
|
|||||||
# 数据源使用说明(供 AI 参考参数格式)
|
# 数据源使用说明(供 AI 参考参数格式)
|
||||||
AI_PARAMS_GUIDE = """
|
AI_PARAMS_GUIDE = """
|
||||||
数据源参数说明(params 为 JSON 对象,可选字段):
|
数据源参数说明(params 为 JSON 对象,可选字段):
|
||||||
- product_code: 按产品编码/名称筛选(模糊匹配)
|
- product_code: 按产品编码/名称筛选(模糊匹配);operation_history 也支持 product_name 按产品名称检索旧日志文本
|
||||||
- brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit /
|
- brand: 按品牌筛选(product_inventory / product_sales / planning_metrics / orders_in_transit /
|
||||||
material_inventory / inventory_value / brand_overview 均支持)
|
material_inventory / inventory_value / brand_overview 均支持)
|
||||||
- sort_by: 排序字段(必须是该数据源返回的字段名)
|
- sort_by: 排序字段(必须是该数据源返回的字段名)
|
||||||
@ -48,6 +48,8 @@ AI_PARAMS_GUIDE = """
|
|||||||
- limit: 返回行数上限(默认50,最大200)
|
- limit: 返回行数上限(默认50,最大200)
|
||||||
- kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品
|
- kind: 仅用于出库统计排行数据源,brand=按品牌 / shop=按店铺 / product=按单品
|
||||||
- days: 出库统计排行和按日出库统计均支持,最近 N 天(如问"最近30天"传 30)
|
- days: 出库统计排行和按日出库统计均支持,最近 N 天(如问"最近30天"传 30)
|
||||||
|
- operation_history 专用参数:module=板块名称,field=修改字段,operator=操作人,start_date/end_date=YYYY-MM-DD。
|
||||||
|
- 查询修改记录时必须提供 product_code 或明确的板块/人员/日期条件;不做无条件全库扫描。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@ -86,6 +88,51 @@ def _can_view_datasource(uid, role, key):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_operation_history_permissions(uid, role, rows):
|
||||||
|
"""日志查询逐条遵守原业务页面查看权限及字段查看权限。"""
|
||||||
|
if is_admin_role(role):
|
||||||
|
return rows
|
||||||
|
from routes.auth import _has_effective_page_permission
|
||||||
|
from services.field_perms import field_perms_for_page
|
||||||
|
page_fields = {
|
||||||
|
'planning': 'planning', '产品信息管理': 'products', 'products': 'products',
|
||||||
|
'计划预测': 'planning', '国内订单管理': 'order_domestic', 'order_domestic': 'order_domestic',
|
||||||
|
'海外订单管理': 'order_overseas', 'order_overseas': 'order_overseas',
|
||||||
|
'包材订单管理': 'order_packaging', 'order_packaging': 'order_packaging',
|
||||||
|
'待下单管理': 'purchase_pending', '下单备份管理': 'purchase_backup',
|
||||||
|
'物料清单管理': 'bom', 'bom': 'bom', '供应商管理': 'suppliers', 'suppliers': 'suppliers',
|
||||||
|
'现货采样申请': 'spot_application', '现货采样确认': 'spot_confirmation',
|
||||||
|
'现货销量管理': 'spot_sales',
|
||||||
|
}
|
||||||
|
page_ids = set(page_fields.values())
|
||||||
|
fields_cache = {}
|
||||||
|
filtered = []
|
||||||
|
for row in rows:
|
||||||
|
page = str(row.get('板块') or '')
|
||||||
|
page_id = page_fields.get(page, page if page in page_ids else '')
|
||||||
|
if not page_id:
|
||||||
|
row = dict(row, **{'修改内容': '该历史日志所属菜单无法确认,修改内容已隐藏'})
|
||||||
|
filtered.append(row)
|
||||||
|
continue
|
||||||
|
if page_id not in fields_cache:
|
||||||
|
fields_cache[page_id] = field_perms_for_page(uid, role, page_id)
|
||||||
|
field_perms = fields_cache[page_id]
|
||||||
|
content = row.get('修改内容') or ''
|
||||||
|
# 修改内容由“字段: 旧值 → 新值”组成;无结构化字段时隐藏原文,避免泄露未授权字段。
|
||||||
|
if ' → ' in content and field_perms:
|
||||||
|
visible_changes = []
|
||||||
|
for change in content.split(';'):
|
||||||
|
key = re.split(r'[::]\s*', change, maxsplit=1)[0]
|
||||||
|
if field_perms.get(key, {}).get('view', False):
|
||||||
|
visible_changes.append(change)
|
||||||
|
content = ';'.join(visible_changes) or '修改字段内容因字段权限不可见'
|
||||||
|
else:
|
||||||
|
content = '修改内容未提供字段级权限定义,已隐藏'
|
||||||
|
row = dict(row, **{'修改内容': content})
|
||||||
|
filtered.append(row)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def _can_export_ai(uid, role):
|
def _can_export_ai(uid, role):
|
||||||
"""导出权限:显式配置优先,未配置时跟随页面查看权限"""
|
"""导出权限:显式配置优先,未配置时跟随页面查看权限"""
|
||||||
if is_admin_role(role):
|
if is_admin_role(role):
|
||||||
@ -435,7 +482,28 @@ def ai_ask():
|
|||||||
denied.append(ds.get('name') or key)
|
denied.append(ds.get('name') or key)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
result = ds['handler'](task.get('params') or {}) or {}
|
params = dict(task.get('params') or {})
|
||||||
|
if key == 'operation_history' and not is_admin_role(role):
|
||||||
|
from routes.auth import _has_effective_page_permission
|
||||||
|
page_ids = ['planning', 'products', 'order_domestic', 'order_overseas', 'order_packaging',
|
||||||
|
'purchase_pending', 'purchase_backup', 'bom', 'suppliers', 'spot_application',
|
||||||
|
'spot_confirmation', 'spot_sales']
|
||||||
|
allowed_ids = {page for page in page_ids if _has_effective_page_permission(uid, role, page)}
|
||||||
|
params['_allowed_pages'] = [page for page, page_id in {
|
||||||
|
'planning': 'planning', '计划预测': 'planning', 'products': 'products', '产品信息管理': 'products',
|
||||||
|
'order_domestic': 'order_domestic', '国内订单管理': 'order_domestic',
|
||||||
|
'order_overseas': 'order_overseas', '海外订单管理': 'order_overseas',
|
||||||
|
'order_packaging': 'order_packaging', '包材订单管理': 'order_packaging',
|
||||||
|
'purchase_pending': 'purchase_pending', '待下单管理': 'purchase_pending',
|
||||||
|
'purchase_backup': 'purchase_backup', '下单备份管理': 'purchase_backup',
|
||||||
|
'bom': 'bom', '物料清单管理': 'bom', 'suppliers': 'suppliers', '供应商管理': 'suppliers',
|
||||||
|
'spot_application': 'spot_application', '现货采样申请': 'spot_application',
|
||||||
|
'spot_confirmation': 'spot_confirmation', '现货采样确认': 'spot_confirmation',
|
||||||
|
'spot_sales': 'spot_sales', '现货销量管理': 'spot_sales',
|
||||||
|
}.items() if page_id in allowed_ids]
|
||||||
|
result = ds['handler'](params) or {}
|
||||||
|
if key == 'operation_history':
|
||||||
|
result['rows'] = _filter_operation_history_permissions(uid, role, result.get('rows') or [])
|
||||||
meta = datasource_meta(key)
|
meta = datasource_meta(key)
|
||||||
datasets.append({
|
datasets.append({
|
||||||
'key': key,
|
'key': key,
|
||||||
@ -468,7 +536,34 @@ def ai_ask():
|
|||||||
json.dumps(payload, ensure_ascii=False, default=str))
|
json.dumps(payload, ensure_ascii=False, default=str))
|
||||||
return jsonify({"code": 200, "data": payload})
|
return jsonify({"code": 200, "data": payload})
|
||||||
|
|
||||||
|
# 操作日志结果直接确定性总结,避免模型改写或混淆精确匹配与历史文本匹配。
|
||||||
|
if all(d['key'] == 'operation_history' for d in datasets):
|
||||||
|
ds = datasets[0]
|
||||||
|
total = int((ds.get('summary') or {}).get('row_count') or 0)
|
||||||
|
returned = len(ds.get('rows') or [])
|
||||||
|
exact = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '精确对象匹配')
|
||||||
|
fallback = sum(1 for row in ds.get('rows') or [] if row.get('匹配方式') == '历史文本匹配')
|
||||||
|
subject = ((ds.get('params') or {}).get('product_code')
|
||||||
|
or (ds.get('params') or {}).get('product_name') or '指定条件')
|
||||||
|
summary = '找到%s条与%s相关的操作记录。' % (total, subject) if total else '没有找到符合条件的操作记录。'
|
||||||
|
limitations = []
|
||||||
|
if total > returned:
|
||||||
|
limitations.append('当前明细最多展示%s条,匹配总数为%s条。' % (returned, total))
|
||||||
|
if fallback:
|
||||||
|
limitations.append('%s条为历史文本匹配,旧日志可能未保存完整的修改前值。' % fallback)
|
||||||
|
if exact:
|
||||||
|
limitations.append('%s条为精确业务对象匹配。' % exact)
|
||||||
|
analysis = {
|
||||||
|
'title': '操作记录查询', 'summary': summary,
|
||||||
|
'findings': [{'title': '匹配结果', 'detail': '符合条件%s条,返回%s条;其中精确对象匹配%s条,历史文本匹配%s条。' % (total, returned, exact, fallback), 'level': '信息'}],
|
||||||
|
'risks': [], 'actions': [], 'evidence': [], 'follow_ups': [], 'confidence': '高' if not fallback else '中',
|
||||||
|
'limitations': limitations, 'chart': {'type': 'none'},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
analysis = None
|
||||||
|
|
||||||
# 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。
|
# 库存查数直接生成经过计算的回复,不让模型重新算数或留下矛盾的证据/风险。
|
||||||
|
if analysis is None:
|
||||||
analysis = _apply_authoritative_inventory_summary(None, datasets, question)
|
analysis = _apply_authoritative_inventory_summary(None, datasets, question)
|
||||||
if analysis is None:
|
if analysis is None:
|
||||||
try:
|
try:
|
||||||
@ -555,7 +650,7 @@ def _normalize_tasks(raw_tasks):
|
|||||||
# 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。
|
# 仅保留受控参数,杜绝模型传SQL、表名或其他自由执行内容。
|
||||||
allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days',
|
allowed = {'product_code', 'brand', 'sort_by', 'sort_order', 'limit', 'kind', 'days',
|
||||||
'supplier', 'status', 'product_type', 'planner', 'follower', 'operator',
|
'supplier', 'status', 'product_type', 'planner', 'follower', 'operator',
|
||||||
'material_code'}
|
'material_code', 'module', 'field', 'start_date', 'end_date', 'product_name'}
|
||||||
params = {k: v for k, v in params.items() if k in allowed}
|
params = {k: v for k, v in params.items() if k in allowed}
|
||||||
try:
|
try:
|
||||||
params['limit'] = max(1, min(int(params.get('limit') or 50), 200))
|
params['limit'] = max(1, min(int(params.get('limit') or 50), 200))
|
||||||
@ -566,6 +661,10 @@ def _normalize_tasks(raw_tasks):
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
if len(tasks) >= 6:
|
if len(tasks) >= 6:
|
||||||
break
|
break
|
||||||
|
# 查询操作日志时仅允许读取该数据源,避免同一问题额外触发业务数据查询。
|
||||||
|
history_tasks = [task for task in tasks if task['datasource'] == 'operation_history']
|
||||||
|
if history_tasks:
|
||||||
|
return history_tasks[:1]
|
||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
@ -639,6 +738,9 @@ def _plan_analysis(question, history, last_result):
|
|||||||
'7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,'
|
'7. 问某品牌库存数量、库存乘单价的总成本、库存金额时,使用inventory_value或brand_overview,'
|
||||||
'所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。'
|
'所有相关任务传同一个brand;不得用inventory_cost_summary代替品牌筛选。'
|
||||||
'全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n'
|
'全量总数与总金额由后端summary计算,limit和sort_by只控制明细。\n'
|
||||||
|
'8. 问某产品或板块的手动编辑、谁修改过、修改前后、操作历史时,使用operation_history;'
|
||||||
|
'优先传入product_code;用户只提供产品名称时传product_name,指定板块/人员/日期时传module/operator/start_date/end_date;'
|
||||||
|
'不要为查询操作记录调用其他业务数据源。\n'
|
||||||
'只输出JSON对象,格式:'
|
'只输出JSON对象,格式:'
|
||||||
'{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",'
|
'{"intent":"analysis|lookup|explain|unsupported","title":"分析标题",'
|
||||||
'"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],'
|
'"tasks":[{"datasource":"key","params":{},"purpose":"为什么需要该数据"}],'
|
||||||
|
|||||||
@ -6,7 +6,11 @@ from flask import Blueprint, render_template, request, jsonify, session, redirec
|
|||||||
from services.db import query
|
from services.db import query
|
||||||
from routes.auth import login_required, role_required, operation_required
|
from routes.auth import login_required, role_required, operation_required
|
||||||
from services.simple_cache import get as cache_get, set as cache_set, delete as cache_del
|
from services.simple_cache import get as cache_get, set as cache_set, delete as cache_del
|
||||||
from services.inventory_calc import resolve_cost_price, compute_inventory_cost_summary
|
from services.inventory_calc import (
|
||||||
|
active_warehouse_condition,
|
||||||
|
compute_inventory_cost_summary,
|
||||||
|
resolve_cost_price,
|
||||||
|
)
|
||||||
from services.field_perms import filter_rows as _filter_field_rows
|
from services.field_perms import filter_rows as _filter_field_rows
|
||||||
from services.audit_schema import ensure_audit_schema
|
from services.audit_schema import ensure_audit_schema
|
||||||
import config as app_config
|
import config as app_config
|
||||||
@ -668,12 +672,12 @@ def _inventory_board_core(month, force_system_refresh=False):
|
|||||||
口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料
|
口径 = 最新库存日 available_qty × 成本价(特殊成本价规则),成品口径,排除内料
|
||||||
板块2 经营效率:
|
板块2 经营效率:
|
||||||
- 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径)
|
- 库存总金额:成品+包材库存金额(compute_inventory_cost_summary 口径)
|
||||||
- 月销货成本:daily_outbound 近 30 天 SUM(货品成本)
|
- 月销货成本:daily_outbound 所选自然月 SUM(货品成本)
|
||||||
- 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位
|
- 月退货成本/月退款金额/退货率:系统暂无退货/退款数据源,返回 None 占位
|
||||||
- 月采购入库:arrival_batches 近 30 天 SUM(arrival_qty)
|
- 月采购入库:arrival_batches 所选自然月 SUM(arrival_qty)
|
||||||
- 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率
|
- 周转率 = 月销货成本 / 库存总金额;周转天数 = 30 / 周转率
|
||||||
- 动销SKU(>30/>100):近 30/100 天有出库记录的成品口径 SKU(排除内料)
|
- 动销SKU(≥30/≥100):所选月累计出库达到 30/100 件的成品口径 SKU(排除内料)
|
||||||
- 总SKU:products 成品/清库存/现货/赠品计数;动销率 = 动销SKU(30天)/总SKU
|
- 总SKU:月末启用仓库库存分别达到 1/200 件的成品口径去重 SKU
|
||||||
板块3 呆滞治理:
|
板块3 呆滞治理:
|
||||||
- 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天
|
- 口径:首次入库距今 ≥90 天 且 可销天数 ≥90 天
|
||||||
首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量
|
首次入库 = inventory_detail 最早 data_date;可销天数 = 当前库存/近30天日均销量
|
||||||
@ -692,8 +696,11 @@ def _inventory_board_core(month, force_system_refresh=False):
|
|||||||
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
next_month = (month_start.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||||
# 此看板包含按月份人工保存的数据。Gunicorn 多 worker 下进程内缓存无法同步,
|
# 此看板包含按月份人工保存的数据。Gunicorn 多 worker 下进程内缓存无法同步,
|
||||||
# 会导致保存后请求落到其他 worker 时回显旧值,因此该接口始终读取数据库最新值。
|
# 会导致保存后请求落到其他 worker 时回显旧值,因此该接口始终读取数据库最新值。
|
||||||
# 月末可用库存快照。若所选月份早于数据源起始日期,不再回退到最新月份,全部系统计算值按 0 处理。
|
# 所选月内最后一个库存快照。该月没有快照时不借用上月/未来月份数据,避免月份与数据日期错位。
|
||||||
latest_date = query("SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date < %s", (next_month,))[0]['d']
|
latest_date = query(
|
||||||
|
"SELECT MAX(data_date) AS d FROM inventory_detail WHERE data_date >= %s AND data_date < %s",
|
||||||
|
(month_start, next_month),
|
||||||
|
)[0]['d']
|
||||||
if not latest_date:
|
if not latest_date:
|
||||||
result = {
|
result = {
|
||||||
'all_amount': 0, 'wh01_amount': 0, 'wh15_amount': 0, 'cloud_amount': 0, 'overseas_amount': 0, 'domestic_self_amount': 0,
|
'all_amount': 0, 'wh01_amount': 0, 'wh15_amount': 0, 'cloud_amount': 0, 'overseas_amount': 0, 'domestic_self_amount': 0,
|
||||||
@ -756,19 +763,22 @@ def _inventory_board_core(month, force_system_refresh=False):
|
|||||||
'stockout_sku_lingdong': '缺货SKU次数(国际-灵动)',
|
'stockout_sku_lingdong': '缺货SKU次数(国际-灵动)',
|
||||||
'stockout_sku_chunyue': '缺货SKU次数(国际-春跃)',
|
'stockout_sku_chunyue': '缺货SKU次数(国际-春跃)',
|
||||||
}
|
}
|
||||||
# 这6个输入框以月份外部录入表为唯一数据源,不能被系统计算值或“人工新建”旧值覆盖。
|
# 有外部录入时覆盖系统值;未录入或清空时回退系统计算值。
|
||||||
for key in external_fields:
|
for key in external_fields:
|
||||||
value = external_map.get(key)
|
if key not in external_map or external_map.get(key) in ('', None):
|
||||||
if value in ('', None):
|
|
||||||
result[key] = 0
|
|
||||||
continue
|
continue
|
||||||
|
value = external_map[key]
|
||||||
try:
|
try:
|
||||||
result[key] = float(value)
|
result[key] = float(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
result[key] = 0
|
continue
|
||||||
# 根据当月手工值实时计算派生指标。
|
# 根据当月手工值实时计算派生指标。
|
||||||
result['active_sku_30'] = result['active_sku']
|
result['active_sku_30'] = result['active_sku']
|
||||||
result['return_rate'] = round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2) if result['monthly_ship_cost'] else 0
|
result['return_rate'] = (
|
||||||
|
round(result['monthly_return_cost'] / result['monthly_ship_cost'] * 100, 2)
|
||||||
|
if result.get('monthly_return_cost') is not None and result.get('monthly_ship_cost')
|
||||||
|
else None
|
||||||
|
)
|
||||||
result['turnover_rate'] = round(result['monthly_ship_cost'] / result['inventory_total_amount'], 4) if result['inventory_total_amount'] else 0
|
result['turnover_rate'] = round(result['monthly_ship_cost'] / result['inventory_total_amount'], 4) if result['inventory_total_amount'] else 0
|
||||||
month_days = (next_month - month_start).days
|
month_days = (next_month - month_start).days
|
||||||
result['turnover_days'] = round(month_days / result['turnover_rate'], 1) if result['turnover_rate'] else 0
|
result['turnover_days'] = round(month_days / result['turnover_rate'], 1) if result['turnover_rate'] else 0
|
||||||
@ -833,10 +843,20 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
_inventory_signature = query(
|
_inventory_signature = query(
|
||||||
"SELECT COUNT(*) AS row_count, COALESCE(SUM(available_qty), 0) AS qty_sum, "
|
"SELECT COUNT(*) AS row_count, COALESCE(SUM(available_qty), 0) AS qty_sum, "
|
||||||
"DATE_FORMAT(MAX(updated_at), '%%Y%%m%%d%%H%%i%%s%%f') AS updated_signature "
|
"DATE_FORMAT(MAX(updated_at), '%%Y%%m%%d%%H%%i%%s%%f') AS updated_signature "
|
||||||
"FROM inventory_detail WHERE data_date=%s", (latest_date,))[0]
|
f"FROM inventory_detail WHERE data_date=%s AND {active_warehouse_condition('inventory_detail')}",
|
||||||
_cache_key = 'inventory_board_system:%s:%s:%s:%s:%s' % (
|
(latest_date,),
|
||||||
month, latest_date, _inventory_signature['row_count'],
|
)[0]
|
||||||
_inventory_signature['qty_sum'], _inventory_signature['updated_signature'] or '')
|
# 仓库启停/属性变化也必须使缓存失效;使用短摘要避免 cache_key 超出表字段长度。
|
||||||
|
import hashlib
|
||||||
|
_warehouse_signature = query(
|
||||||
|
"SELECT warehouse_code, warehouse_property, disabled FROM warehouses ORDER BY warehouse_code"
|
||||||
|
)
|
||||||
|
_signature_text = json.dumps({
|
||||||
|
'inventory': _inventory_signature,
|
||||||
|
'warehouses': _warehouse_signature,
|
||||||
|
}, ensure_ascii=False, default=str, sort_keys=True)
|
||||||
|
_cache_digest = hashlib.sha1(_signature_text.encode('utf-8')).hexdigest()[:20]
|
||||||
|
_cache_key = f'inventory_board_system:{month}:{latest_date}:{_cache_digest}'
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
_cached = cache_get(_cache_key)
|
_cached = cache_get(_cache_key)
|
||||||
if _cached is not None:
|
if _cached is not None:
|
||||||
@ -923,42 +943,65 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
rows = query(f"""
|
rows = query(f"""
|
||||||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||||||
FROM inventory_detail id
|
FROM inventory_detail id
|
||||||
LEFT JOIN products p ON id.product_code = p.product_code
|
WHERE id.data_date = %s
|
||||||
WHERE id.data_date = %s AND id.warehouse_code != '20' AND {wh_cond}
|
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
|
||||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
AND {active_warehouse_condition('id')} AND {wh_cond}
|
||||||
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||||
|
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||||
|
)
|
||||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||||
GROUP BY id.product_code
|
GROUP BY id.product_code
|
||||||
""", (target_date,) + tuple(params) + ('%-NL', '%内料%'))
|
""", (target_date,) + tuple(params) + ('%-NL', '%内料%'))
|
||||||
return round(sum(float(r['qty'] or 0) * _cmap.get(r['code'], 0) for r in rows), 2)
|
return round(sum(float(r['qty'] or 0) * _cmap.get(r['code'], 0) for r in rows), 2)
|
||||||
|
|
||||||
|
def _warehouse_match(codes):
|
||||||
|
numeric = sorted({int(str(code)) for code in codes if str(code).isdigit()})
|
||||||
|
text = sorted({str(code) for code in codes if not str(code).isdigit()})
|
||||||
|
clauses, params = [], []
|
||||||
|
if numeric:
|
||||||
|
clauses.append("(id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) IN (%s))" %
|
||||||
|
','.join(['%s'] * len(numeric)))
|
||||||
|
params.extend(numeric)
|
||||||
|
if text:
|
||||||
|
clauses.append("id.warehouse_code IN (%s)" % ','.join(['%s'] * len(text)))
|
||||||
|
params.extend(text)
|
||||||
|
return ('(' + ' OR '.join(clauses) + ')', params) if clauses else ('0=1', [])
|
||||||
|
|
||||||
amounts = {
|
amounts = {
|
||||||
'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品
|
'all_amount': _wh_finished_amount("1=1", ()), # 全部仓库成品
|
||||||
'wh01_amount': _wh_finished_amount("id.warehouse_code = %s", ('01',)), # 蝶雅仓成品
|
'wh01_amount': _wh_finished_amount(
|
||||||
'wh15_amount': _wh_finished_amount("id.warehouse_code = %s", ('15',)), # 加工厂仓成品
|
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (1,)),
|
||||||
|
'wh15_amount': _wh_finished_amount(
|
||||||
|
"id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = %s", (15,)),
|
||||||
}
|
}
|
||||||
_cloud_codes = [r['warehouse_code'] for r in query(
|
_cloud_codes = [r['warehouse_code'] for r in query(
|
||||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('云仓', warehouse_property) > 0 "
|
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('云仓', warehouse_property) > 0 "
|
||||||
"AND warehouse_code != '01'")]
|
"AND COALESCE(disabled, '否') != '是' "
|
||||||
|
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)")]
|
||||||
if _cloud_codes:
|
if _cloud_codes:
|
||||||
_ph = ','.join(['%s'] * len(_cloud_codes))
|
_cond, _params = _warehouse_match(_cloud_codes)
|
||||||
amounts['cloud_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _cloud_codes)
|
amounts['cloud_amount'] = _wh_finished_amount(_cond, _params)
|
||||||
else:
|
else:
|
||||||
amounts['cloud_amount'] = 0.0
|
amounts['cloud_amount'] = 0.0
|
||||||
_overseas_codes = [r['warehouse_code'] for r in query(
|
_overseas_codes = [r['warehouse_code'] for r in query(
|
||||||
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0")]
|
"SELECT warehouse_code FROM warehouses WHERE FIND_IN_SET('海外仓', warehouse_property) > 0 "
|
||||||
|
"AND COALESCE(disabled, '否') != '是'")]
|
||||||
if _overseas_codes:
|
if _overseas_codes:
|
||||||
_ph = ','.join(['%s'] * len(_overseas_codes))
|
_cond, _params = _warehouse_match(_overseas_codes)
|
||||||
amounts['overseas_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _overseas_codes)
|
amounts['overseas_amount'] = _wh_finished_amount(_cond, _params)
|
||||||
else:
|
else:
|
||||||
amounts['overseas_amount'] = 0.0
|
amounts['overseas_amount'] = 0.0
|
||||||
# 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓)
|
# 国内平台自营仓:名称含「自营」且非海外仓属性(排除自家 01 蝶雅自营仓)
|
||||||
_self_codes = [r['warehouse_code'] for r in query(
|
_self_codes = [r['warehouse_code'] for r in query(
|
||||||
"SELECT warehouse_code FROM warehouses WHERE (warehouse_name LIKE %s OR warehouse_code IN ('13', '14')) "
|
"SELECT warehouse_code FROM warehouses WHERE (warehouse_name LIKE %s OR warehouse_code IN ('13', '14')) "
|
||||||
"AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND warehouse_code != '01'",
|
"AND FIND_IN_SET('海外仓', warehouse_property) = 0 AND COALESCE(disabled, '否') != '是' "
|
||||||
|
"AND NOT (warehouse_code REGEXP '^[0-9]+$' AND CAST(warehouse_code AS UNSIGNED) = 1)",
|
||||||
('%自营%',))]
|
('%自营%',))]
|
||||||
if _self_codes:
|
if _self_codes:
|
||||||
_ph = ','.join(['%s'] * len(_self_codes))
|
_cond, _params = _warehouse_match(_self_codes)
|
||||||
amounts['domestic_self_amount'] = _wh_finished_amount(f"id.warehouse_code IN ({_ph})", _self_codes)
|
amounts['domestic_self_amount'] = _wh_finished_amount(_cond, _params)
|
||||||
else:
|
else:
|
||||||
amounts['domestic_self_amount'] = 0.0
|
amounts['domestic_self_amount'] = 0.0
|
||||||
return amounts
|
return amounts
|
||||||
@ -987,25 +1030,38 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
turnover_rate = round(monthly_ship_cost / inventory_total_amount, 4) if inventory_total_amount else 0
|
turnover_rate = round(monthly_ship_cost / inventory_total_amount, 4) if inventory_total_amount else 0
|
||||||
turnover_days = round(30 / turnover_rate, 1) if turnover_rate else None
|
turnover_days = round(30 / turnover_rate, 1) if turnover_rate else None
|
||||||
|
|
||||||
# 9. 动销SKU数量:>30天 / >100天 两个口径(近 N 天有出库、成品口径、排除内料)
|
# 9. 动销SKU数量:所选自然月累计出库分别达到 30 / 100 件的 SKU 数。
|
||||||
def _active_sku(days):
|
_monthly_sales = query("""
|
||||||
return int(query("""
|
SELECT d.货品编号 AS code, SUM(d.货品数量) AS qty
|
||||||
SELECT /*+ INDEX(d idx_date_product) */ COUNT(DISTINCT d.货品编号) AS v
|
|
||||||
FROM daily_outbound d
|
FROM daily_outbound d
|
||||||
LEFT JOIN products p ON d.货品编号 = p.product_code
|
|
||||||
WHERE d.日期 >= %s AND d.日期 < %s
|
WHERE d.日期 >= %s AND d.日期 < %s
|
||||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
|
||||||
AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s
|
AND d.货品名称 NOT LIKE %s AND d.货品编号 NOT LIKE %s
|
||||||
""", (max(month_start, latest_date - timedelta(days=days - 1)), next_month, '%内料%', '%-NL'))[0]['v'])
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM products p WHERE p.product_code=d.货品编号
|
||||||
|
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=d.货品编号 AND p0.deleted_at IS NULL)
|
||||||
|
)
|
||||||
|
GROUP BY d.货品编号
|
||||||
|
""", (month_start, next_month, '%内料%', '%-NL'))
|
||||||
|
active_sku_30 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 30)
|
||||||
|
active_sku_100 = sum(1 for row in _monthly_sales if float(row['qty'] or 0) >= 100)
|
||||||
|
|
||||||
active_sku_30 = _active_sku(30) # 动销SKU数量(>30天)
|
# 10. 总SKU数量:月末启用仓库的成品库存分别达到 1 / 200 件的去重 SKU 数。
|
||||||
active_sku_100 = _active_sku(100) # 动销SKU数量(>100天)
|
_month_end_sku_rows = query(f"""
|
||||||
|
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||||||
# 10. 总SKU数量
|
FROM inventory_detail id
|
||||||
total_sku = int(query(
|
WHERE id.data_date = %s AND {active_warehouse_condition('id')}
|
||||||
"SELECT COUNT(*) AS v FROM products p "
|
AND NOT (id.warehouse_code REGEXP '^[0-9]+$' AND CAST(id.warehouse_code AS UNSIGNED) = 20)
|
||||||
"WHERE p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL"
|
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||||
)[0]['v'])
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||||
|
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||||
|
)
|
||||||
|
GROUP BY id.product_code
|
||||||
|
""", (latest_date, '%-NL', '%内料%'))
|
||||||
|
total_sku = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 1)
|
||||||
|
total_sku_200 = sum(1 for row in _month_end_sku_rows if float(row['qty'] or 0) >= 200)
|
||||||
|
|
||||||
# 11. 动销率(30天动销口径)
|
# 11. 动销率(30天动销口径)
|
||||||
active_rate = round(active_sku_30 / total_sku * 100, 2) if total_sku else 0
|
active_rate = round(active_sku_30 / total_sku * 100, 2) if total_sku else 0
|
||||||
@ -1015,6 +1071,7 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
# 注:不能加内料过滤 WHERE,否则无法走 idx_product_date 覆盖索引,全表扫描 180万行需 30s+
|
# 注:不能加内料过滤 WHERE,否则无法走 idx_product_date 覆盖索引,全表扫描 180万行需 30s+
|
||||||
_first_date = {r['product_code']: r['first_date'] for r in query("""
|
_first_date = {r['product_code']: r['first_date'] for r in query("""
|
||||||
SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail
|
SELECT product_code, MIN(data_date) AS first_date FROM inventory_detail
|
||||||
|
WHERE """ + active_warehouse_condition('inventory_detail') + """
|
||||||
GROUP BY product_code
|
GROUP BY product_code
|
||||||
""")}
|
""")}
|
||||||
# 近30天销量(日均销量分母)
|
# 近30天销量(日均销量分母)
|
||||||
@ -1025,12 +1082,15 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
GROUP BY 货品编号
|
GROUP BY 货品编号
|
||||||
""", (max(month_start, latest_date - timedelta(days=29)), next_month, '%内料%', '%-NL'))}
|
""", (max(month_start, latest_date - timedelta(days=29)), next_month, '%内料%', '%-NL'))}
|
||||||
# 最新库存日:按商品聚合可得库存数量(成品口径、排除内料)
|
# 最新库存日:按商品聚合可得库存数量(成品口径、排除内料)
|
||||||
_inv_by_code = query("""
|
_inv_by_code = query(f"""
|
||||||
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
SELECT id.product_code AS code, SUM(id.available_qty) AS qty
|
||||||
FROM inventory_detail id
|
FROM inventory_detail id
|
||||||
LEFT JOIN products p ON id.product_code = p.product_code
|
WHERE id.data_date = %s AND {active_warehouse_condition('id')}
|
||||||
WHERE id.data_date = %s
|
AND (
|
||||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||||
|
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||||
|
)
|
||||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||||
GROUP BY id.product_code
|
GROUP BY id.product_code
|
||||||
""", (latest_date, '%-NL', '%内料%'))
|
""", (latest_date, '%-NL', '%内料%'))
|
||||||
@ -1060,15 +1120,20 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
|
|
||||||
# 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。
|
# 板块4 缺货监控:按月统计连续断货至少2天(即断货1天以上)的SKU次数。
|
||||||
# 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。
|
# 先按商品+日期汇总全仓可用库存,再识别连续零库存区间。
|
||||||
_stock_rows = query("""
|
_stock_rows = query(f"""
|
||||||
SELECT id.product_code AS code, id.data_date AS dt, SUM(id.available_qty) AS qty
|
SELECT id.product_code AS code, id.data_date AS dt, SUM(id.available_qty) AS qty
|
||||||
FROM inventory_detail id
|
FROM inventory_detail id
|
||||||
LEFT JOIN products p ON id.product_code = p.product_code
|
|
||||||
WHERE id.data_date >= %s AND id.data_date < %s
|
WHERE id.data_date >= %s AND id.data_date < %s
|
||||||
AND id.available_qty = 0
|
AND {active_warehouse_condition('id')}
|
||||||
AND (p.product_type IN ('成品','清库存','现货','赠品') OR p.product_code IS NULL)
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM products p WHERE p.product_code=id.product_code
|
||||||
|
AND p.product_type IN ('成品','清库存','现货','赠品') AND p.deleted_at IS NULL)
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM products p0 WHERE p0.product_code=id.product_code AND p0.deleted_at IS NULL)
|
||||||
|
)
|
||||||
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
AND id.product_code NOT LIKE %s AND id.product_name NOT LIKE %s
|
||||||
GROUP BY id.product_code, id.data_date ORDER BY id.product_code, id.data_date
|
GROUP BY id.product_code, id.data_date
|
||||||
|
HAVING SUM(id.available_qty) <= 0
|
||||||
|
ORDER BY id.product_code, id.data_date
|
||||||
""", (month_start, next_month, '%-NL', '%内料%'))
|
""", (month_start, next_month, '%-NL', '%内料%'))
|
||||||
_stockout_codes = set()
|
_stockout_codes = set()
|
||||||
_stockout_by_code = {}
|
_stockout_by_code = {}
|
||||||
@ -1088,6 +1153,8 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
_stockout_codes.add(_prev_code)
|
_stockout_codes.add(_prev_code)
|
||||||
_run = 0
|
_run = 0
|
||||||
_prev_code, _prev_date = code, dt
|
_prev_code, _prev_date = code, dt
|
||||||
|
# 缺货监控只针对统计窗口内存在实际出库需求的 SKU,排除长期零库存且无销售的停销档案。
|
||||||
|
_stockout_codes.intersection_update(_sales_30.keys())
|
||||||
_prod_platform = {r['code']: (r['platform'] or '') for r in query("""
|
_prod_platform = {r['code']: (r['platform'] or '') for r in query("""
|
||||||
SELECT product_code AS code, platform FROM products
|
SELECT product_code AS code, platform FROM products
|
||||||
WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL
|
WHERE product_type IN ('成品','清库存','现货','赠品') AND deleted_at IS NULL
|
||||||
@ -1146,7 +1213,7 @@ def _inventory_board_system(month, latest_date, force_refresh=False):
|
|||||||
'active_sku_100': active_sku_100, # 动销SKU数量(>100天)
|
'active_sku_100': active_sku_100, # 动销SKU数量(>100天)
|
||||||
'active_sku': active_sku_30, # 兼容旧字段(外部录入覆盖)
|
'active_sku': active_sku_30, # 兼容旧字段(外部录入覆盖)
|
||||||
'total_sku': total_sku,
|
'total_sku': total_sku,
|
||||||
'total_sku_200': 0,
|
'total_sku_200': total_sku_200,
|
||||||
'active_rate': active_rate,
|
'active_rate': active_rate,
|
||||||
'active_rate_100': 0,
|
'active_rate_100': 0,
|
||||||
# 板块3 呆滞治理
|
# 板块3 呆滞治理
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from services.forecast import (is_new_product, get_product_grade,
|
|||||||
from routes.auth import login_required, role_required, operation_required, _ensure_personnel_table
|
from routes.auth import login_required, role_required, operation_required, _ensure_personnel_table
|
||||||
from services.field_perms import filter_rows as _filter_field_rows
|
from services.field_perms import filter_rows as _filter_field_rows
|
||||||
from services.planning_snapshot import consecutive_days_map, attach_consecutive_days
|
from services.planning_snapshot import consecutive_days_map, attach_consecutive_days
|
||||||
|
from services.order_progress import attach_unfinished_order_progress, unfinished_order_progress_map
|
||||||
import json
|
import json
|
||||||
|
|
||||||
planning_bp = Blueprint('planning', __name__)
|
planning_bp = Blueprint('planning', __name__)
|
||||||
@ -336,11 +337,13 @@ def _merge_pending_formal_qty(rows):
|
|||||||
if not codes:
|
if not codes:
|
||||||
return
|
return
|
||||||
placeholders = ','.join(['%s'] * len(codes))
|
placeholders = ','.join(['%s'] * len(codes))
|
||||||
pending = query(f"SELECT product_code, formal_order_qty FROM pending_orders WHERE status IN ('pending','draft') AND product_code IN ({placeholders})", codes)
|
pending = query(f"""SELECT product_code, formal_order_qty FROM pending_orders
|
||||||
|
WHERE status IN ('pending','draft') AND product_code IN ({placeholders})
|
||||||
|
ORDER BY CASE status WHEN 'draft' THEN 0 ELSE 1 END, updated_at, id""", codes)
|
||||||
qty_map = {r['product_code']: int(r['formal_order_qty'] or 0) for r in pending}
|
qty_map = {r['product_code']: int(r['formal_order_qty'] or 0) for r in pending}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
pc = row.get('product_code', '')
|
pc = row.get('product_code', '')
|
||||||
if pc in qty_map and qty_map[pc] > 0:
|
if pc in qty_map:
|
||||||
row['正式下单数量'] = qty_map[pc]
|
row['正式下单数量'] = qty_map[pc]
|
||||||
|
|
||||||
|
|
||||||
@ -720,6 +723,9 @@ def planning_list(filters=None):
|
|||||||
extended_sales=extended_sales)
|
extended_sales=extended_sales)
|
||||||
row['连续建议天数'] = snapshot_map.get(str(row.get('product_code') or ''), 0)
|
row['连续建议天数'] = snapshot_map.get(str(row.get('product_code') or ''), 0)
|
||||||
|
|
||||||
|
attach_unfinished_order_progress(all_rows)
|
||||||
|
|
||||||
|
for row in all_rows:
|
||||||
# 筛选
|
# 筛选
|
||||||
match = True
|
match = True
|
||||||
if match and type_filter and type_filter.lower() not in (row.get('product_type') or '').lower():
|
if match and type_filter and type_filter.lower() not in (row.get('product_type') or '').lower():
|
||||||
@ -936,6 +942,7 @@ def planning_list(filters=None):
|
|||||||
cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map,
|
cloud_inv_map=cloud_inv_map, ext_inv_map=ext_inv_map,
|
||||||
extended_sales=extended_sales)
|
extended_sales=extended_sales)
|
||||||
attach_consecutive_days(rows)
|
attach_consecutive_days(rows)
|
||||||
|
attach_unfinished_order_progress(rows)
|
||||||
|
|
||||||
# 获取所有匹配的产品编号(用于全选)
|
# 获取所有匹配的产品编号(用于全选)
|
||||||
all_codes_rows = query(f"SELECT product_code FROM products p WHERE {where_clause}", params)
|
all_codes_rows = query(f"SELECT product_code FROM products p WHERE {where_clause}", params)
|
||||||
@ -970,7 +977,7 @@ def planning_export():
|
|||||||
return jsonify({"code": 400, "msg": "请先在字段设置中选择要导出的字段"}), 400
|
return jsonify({"code": 400, "msg": "请先在字段设置中选择要导出的字段"}), 400
|
||||||
# 只允许计划预测字段,排除操作列及未知字段。
|
# 只允许计划预测字段,排除操作列及未知字段。
|
||||||
allowed_fields = {
|
allowed_fields = {
|
||||||
'product_type','产品种类','product_code','product_name','brand','批注','产品分级',
|
'product_type','产品种类','product_code','product_name','brand','批注','产品分级','在途进度',
|
||||||
'昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存',
|
'昨日销量','云仓昨日销量','外仓昨日销量','销售趋势','总库存','库存','总在途库存',
|
||||||
'云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨',
|
'云仓库存','外仓库存','加工厂库存','云仓可分配库存','外仓可分配库存','是否调拨',
|
||||||
'生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数',
|
'生产在途','采购在途','日销量预测','云仓日销量预测','外仓日销量预测','建议下单数量','连续建议天数',
|
||||||
@ -1303,16 +1310,18 @@ def planning_column_values():
|
|||||||
'库存工厂待生产可支撑天数','总支撑天数',
|
'库存工厂待生产可支撑天数','总支撑天数',
|
||||||
'最低销量偏差率','最高销量偏差率','14天波动率',
|
'最低销量偏差率','最高销量偏差率','14天波动率',
|
||||||
}
|
}
|
||||||
if field in COMPUTED_NUMERIC_FIELDS:
|
COMPUTED_TEXT_FIELDS = {'在途进度'}
|
||||||
|
COMPUTED_FIELDS = COMPUTED_NUMERIC_FIELDS | COMPUTED_TEXT_FIELDS
|
||||||
|
if field in COMPUTED_FIELDS:
|
||||||
# 计算字段:走全量查询+enrichment 后提取唯一值
|
# 计算字段:走全量查询+enrichment 后提取唯一值
|
||||||
need_full_fetch = True
|
need_full_fetch = True
|
||||||
|
|
||||||
if not db_col and field not in COMPUTED_NUMERIC_FIELDS:
|
if not db_col and field not in COMPUTED_FIELDS:
|
||||||
return jsonify({"code": 0, "data": []})
|
return jsonify({"code": 0, "data": []})
|
||||||
|
|
||||||
from services.db import query
|
from services.db import query
|
||||||
|
|
||||||
if field in COMPUTED_NUMERIC_FIELDS:
|
if field in COMPUTED_FIELDS:
|
||||||
# 计算字段:获取全量产品,走 enrichment 提取唯一值
|
# 计算字段:获取全量产品,走 enrichment 提取唯一值
|
||||||
try:
|
try:
|
||||||
all_rows = query(f"""
|
all_rows = query(f"""
|
||||||
@ -1344,6 +1353,7 @@ def planning_column_values():
|
|||||||
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
|
sp_map = {r['trend_type']: r for r in sales_predict_config_cached()}
|
||||||
gc = _ensure_global_config()
|
gc = _ensure_global_config()
|
||||||
snapshot_map = consecutive_days_map([r.get('product_code') for r in all_rows])
|
snapshot_map = consecutive_days_map([r.get('product_code') for r in all_rows])
|
||||||
|
progress_map = unfinished_order_progress_map(r.get('product_code') for r in all_rows)
|
||||||
|
|
||||||
seen = {}
|
seen = {}
|
||||||
values = []
|
values = []
|
||||||
@ -1362,6 +1372,7 @@ def planning_column_values():
|
|||||||
extended_sales=extended_sales
|
extended_sales=extended_sales
|
||||||
)
|
)
|
||||||
enriched['连续建议天数'] = snapshot_map.get(str(enriched.get('product_code') or ''), 0)
|
enriched['连续建议天数'] = snapshot_map.get(str(enriched.get('product_code') or ''), 0)
|
||||||
|
enriched['在途进度'] = progress_map.get(str(enriched.get('product_code') or ''), '')
|
||||||
# 联动筛选:有 plan_filter 时过滤其他字段的条件
|
# 联动筛选:有 plan_filter 时过滤其他字段的条件
|
||||||
if plan_filter_str:
|
if plan_filter_str:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""产品管理路由"""
|
"""产品管理路由"""
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import (Blueprint, current_app, request, jsonify, session, redirect,
|
from flask import (Blueprint, current_app, request, jsonify, session, redirect,
|
||||||
@ -19,6 +20,7 @@ from services.forecast import (is_new_product, get_product_grade,
|
|||||||
sales_predict_config_cached)
|
sales_predict_config_cached)
|
||||||
from routes.auth import login_required, role_required, operation_required
|
from routes.auth import login_required, role_required, operation_required
|
||||||
from services.field_perms import filter_rows as _filter_field_rows
|
from services.field_perms import filter_rows as _filter_field_rows
|
||||||
|
from services.order_progress import unfinished_order_progress_map
|
||||||
|
|
||||||
products_bp = Blueprint('products', __name__)
|
products_bp = Blueprint('products', __name__)
|
||||||
|
|
||||||
@ -1008,6 +1010,16 @@ def product_detail_with_bom():
|
|||||||
SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark
|
SELECT material_code, pre_pack_qty, post_pack_qty, pkg_remark
|
||||||
FROM bom_packaging WHERE product_code=%s
|
FROM bom_packaging WHERE product_code=%s
|
||||||
""", (code,))
|
""", (code,))
|
||||||
|
pending_snapshot_rows = query("""SELECT formal_order_qty, remark, bom_items, status
|
||||||
|
FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft')
|
||||||
|
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC
|
||||||
|
LIMIT 1""", (code,))
|
||||||
|
pending_snapshot = pending_snapshot_rows[0] if pending_snapshot_rows else None
|
||||||
|
if pending_snapshot and isinstance(pending_snapshot.get('bom_items'), str):
|
||||||
|
try:
|
||||||
|
pending_snapshot['bom_items'] = json.loads(pending_snapshot['bom_items'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pending_snapshot['bom_items'] = []
|
||||||
pkg_map = {}
|
pkg_map = {}
|
||||||
for pr in pkg_rows:
|
for pr in pkg_rows:
|
||||||
mc = str(pr['material_code'] or '').strip()
|
mc = str(pr['material_code'] or '').strip()
|
||||||
@ -1089,6 +1101,7 @@ def product_detail_with_bom():
|
|||||||
"total_suggested_qty": int(order_data['total_suggested_qty'] or 0)
|
"total_suggested_qty": int(order_data['total_suggested_qty'] or 0)
|
||||||
},
|
},
|
||||||
"bom_materials": bom_materials,
|
"bom_materials": bom_materials,
|
||||||
|
"pending_order": pending_snapshot,
|
||||||
"sales": sales_data,
|
"sales": sales_data,
|
||||||
"orders_in_transit": orders_in_transit
|
"orders_in_transit": orders_in_transit
|
||||||
}
|
}
|
||||||
@ -1152,6 +1165,7 @@ def product_planning_detail():
|
|||||||
ext_inv_map=ext_inv_map,
|
ext_inv_map=ext_inv_map,
|
||||||
extended_sales=extended_sales,
|
extended_sales=extended_sales,
|
||||||
)
|
)
|
||||||
|
row['在途进度'] = unfinished_order_progress_map([pc]).get(pc, '')
|
||||||
inv15_val = float(row.get('加工厂库存', 0) or 0)
|
inv15_val = float(row.get('加工厂库存', 0) or 0)
|
||||||
|
|
||||||
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
|
# 云仓页签沿用默认数据的字段顺序,数值改用云仓库存和云仓销量口径。
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from services.product_type import get_product_type_cached
|
|||||||
from routes.planning import (_enrich_row_with_planning_data, _ensure_global_config,
|
from routes.planning import (_enrich_row_with_planning_data, _ensure_global_config,
|
||||||
_personnel_group_members, _personnel_group_for_value)
|
_personnel_group_members, _personnel_group_for_value)
|
||||||
from services.planning_order_flag import normalize_new_old
|
from services.planning_order_flag import normalize_new_old
|
||||||
|
from services.order_progress import unfinished_order_progress_map
|
||||||
|
|
||||||
purchase_bp = Blueprint('purchase', __name__)
|
purchase_bp = Blueprint('purchase', __name__)
|
||||||
|
|
||||||
@ -1064,9 +1065,12 @@ def purchase_add_pending():
|
|||||||
update_fields = {'formal_order_qty', 'audit_price', 'actual_order_qty', 'suggested_qty', 'remark', 'bom_items'}
|
update_fields = {'formal_order_qty', 'audit_price', 'actual_order_qty', 'suggested_qty', 'remark', 'bom_items'}
|
||||||
is_update = bool(keys & update_fields)
|
is_update = bool(keys & update_fields)
|
||||||
|
|
||||||
# 待下单当前行按 status='pending' 定位(同一商品可保留多条 ordered/rolled_back 历史)。
|
# 先复用当前待下单行,否则复用计划预测保存的草稿;ordered/rolled_back 历史不参与。
|
||||||
existing_pending = query("SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at FROM pending_orders WHERE product_code=%s AND status='pending'", (pc,))
|
existing_pending = query("""SELECT id, bom_items, new_old_product, sales_snapshot, sales_snapshot_at, status
|
||||||
if existing_pending and not is_update:
|
FROM pending_orders WHERE product_code=%s AND status IN ('pending','draft')
|
||||||
|
ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC
|
||||||
|
LIMIT 1""", (pc,))
|
||||||
|
if existing_pending and existing_pending[0].get('status') == 'pending' and not is_update:
|
||||||
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
|
return jsonify({"code": 400, "msg": f"[{pc}] 已在待下单列表中,请勿重复添加"})
|
||||||
|
|
||||||
# 查询该产品的BOM物料清单
|
# 查询该产品的BOM物料清单
|
||||||
@ -1191,20 +1195,44 @@ def purchase_add_pending():
|
|||||||
@purchase_bp.route('/api/purchase/update_pending', methods=['POST'])
|
@purchase_bp.route('/api/purchase/update_pending', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def purchase_update_pending():
|
def purchase_update_pending():
|
||||||
"""更新待下单记录的备注等字段"""
|
"""保存计划预测下单信息或更新待下单记录;未下单的内容保存在隐藏草稿中。"""
|
||||||
denied = _require_purchase_operation('purchase', 'edit')
|
if not (operation_required('purchase', 'edit') or operation_required('planning', 'edit')):
|
||||||
if denied:
|
return jsonify({"code": 403, "msg": "权限不足,当前账号没有编辑下单信息权限"})
|
||||||
return denied
|
data = request.get_json(silent=True) or {}
|
||||||
data = request.json
|
|
||||||
product_code = data.get('product_code', '')
|
product_code = data.get('product_code', '')
|
||||||
if not product_code:
|
if not product_code:
|
||||||
return jsonify({"code": 400, "msg": "参数错误"})
|
return jsonify({"code": 400, "msg": "参数错误"})
|
||||||
|
|
||||||
current_rows = query(
|
current_rows = query(
|
||||||
"SELECT remark, bom_items FROM pending_orders WHERE product_code=%s AND status='pending' LIMIT 1",
|
"SELECT id, remark, bom_items, formal_order_qty, status FROM pending_orders "
|
||||||
|
"WHERE product_code=%s AND status IN ('pending','draft') "
|
||||||
|
"ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, updated_at DESC, id DESC LIMIT 1",
|
||||||
(product_code,)
|
(product_code,)
|
||||||
)
|
)
|
||||||
current = current_rows[0] if current_rows else {}
|
current = current_rows[0] if current_rows else {}
|
||||||
|
if not current:
|
||||||
|
product_rows = query("SELECT product_name, product_type FROM products WHERE product_code=%s AND deleted_at IS NULL LIMIT 1",
|
||||||
|
(product_code,))
|
||||||
|
if not product_rows:
|
||||||
|
return jsonify({"code": 404, "msg": "未找到该产品,无法保存下单草稿"})
|
||||||
|
requested_bom = data.get('bom_items')
|
||||||
|
normalized_for_insert = _normalize_pending_bom_items(requested_bom) if requested_bom is not None else []
|
||||||
|
product_actual_qty = _extract_product_actual_qty(normalized_for_insert, product_code)
|
||||||
|
if product_actual_qty is None:
|
||||||
|
try:
|
||||||
|
product_actual_qty = int(data.get('formal_order_qty') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
product_actual_qty = 0
|
||||||
|
bom_json = json.dumps(normalized_for_insert, ensure_ascii=False) if requested_bom is not None else None
|
||||||
|
draft_id = query("""INSERT INTO pending_orders
|
||||||
|
(product_code, product_name, product_type, formal_order_qty, status, created_by, bom_items, remark)
|
||||||
|
VALUES (%s, %s, %s, %s, 'draft', %s, %s, %s)""",
|
||||||
|
(product_code, product_rows[0].get('product_name') or '',
|
||||||
|
product_rows[0].get('product_type') or '', product_actual_qty,
|
||||||
|
session.get('real_name', '') or session.get('username', ''), bom_json,
|
||||||
|
data.get('remark') or ''))
|
||||||
|
current = {'id': draft_id, 'remark': '', 'bom_items': None,
|
||||||
|
'formal_order_qty': 0, 'status': 'draft'}
|
||||||
changes = []
|
changes = []
|
||||||
|
|
||||||
# 支持更新的字段
|
# 支持更新的字段
|
||||||
@ -1213,11 +1241,10 @@ def purchase_update_pending():
|
|||||||
old_remark = current.get('remark') or ''
|
old_remark = current.get('remark') or ''
|
||||||
if old_remark != remark:
|
if old_remark != remark:
|
||||||
changes.append(f'备注:{old_remark or "(空)"} → {remark or "(空)"}')
|
changes.append(f'备注:{old_remark or "(空)"} → {remark or "(空)"}')
|
||||||
query("UPDATE pending_orders SET remark=%s WHERE product_code=%s AND status='pending'", (remark, product_code))
|
query("UPDATE pending_orders SET remark=%s, updated_at=NOW() WHERE id=%s", (remark, current['id']))
|
||||||
|
|
||||||
bom_items = data.get('bom_items')
|
bom_items = data.get('bom_items')
|
||||||
if bom_items is not None:
|
if bom_items is not None:
|
||||||
import json
|
|
||||||
normalized_bom_items = _normalize_pending_bom_items(bom_items)
|
normalized_bom_items = _normalize_pending_bom_items(bom_items)
|
||||||
product_actual_qty = _extract_product_actual_qty(normalized_bom_items, product_code)
|
product_actual_qty = _extract_product_actual_qty(normalized_bom_items, product_code)
|
||||||
bom_str = json.dumps(normalized_bom_items, ensure_ascii=False)
|
bom_str = json.dumps(normalized_bom_items, ensure_ascii=False)
|
||||||
@ -1286,12 +1313,13 @@ def purchase_update_pending():
|
|||||||
f'{display_code} {label}:{old_display} → {new_display}'
|
f'{display_code} {label}:{old_display} → {new_display}'
|
||||||
)
|
)
|
||||||
if product_actual_qty is None:
|
if product_actual_qty is None:
|
||||||
query("UPDATE pending_orders SET bom_items=%s WHERE product_code=%s AND status='pending'", (bom_str, product_code))
|
query("UPDATE pending_orders SET bom_items=%s, updated_at=NOW() WHERE id=%s", (bom_str, current['id']))
|
||||||
else:
|
else:
|
||||||
old_formal_qty = int((current.get('formal_order_qty') or 0))
|
old_formal_qty = int((current.get('formal_order_qty') or 0))
|
||||||
if old_formal_qty != product_actual_qty:
|
if old_formal_qty != product_actual_qty:
|
||||||
changes.append(f'成品实际下单数量:{old_formal_qty} → {product_actual_qty}')
|
changes.append(f'成品实际下单数量:{old_formal_qty} → {product_actual_qty}')
|
||||||
query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s WHERE product_code=%s AND status='pending'", (product_actual_qty, bom_str, product_code))
|
query("UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s, updated_at=NOW() WHERE id=%s",
|
||||||
|
(product_actual_qty, bom_str, current['id']))
|
||||||
# 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。
|
# 待下单详情中的“下单前包材”与计划预测共用 bom_packaging 真源,保存时同步更新。
|
||||||
_sync_pending_pre_packaging(product_code, normalized_bom_items)
|
_sync_pending_pre_packaging(product_code, normalized_bom_items)
|
||||||
|
|
||||||
@ -1776,6 +1804,9 @@ def purchase_batch_add_pending():
|
|||||||
if existing:
|
if existing:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
saved_draft = query("""SELECT id, new_old_product, sales_snapshot, sales_snapshot_at
|
||||||
|
FROM pending_orders WHERE product_code=%s AND status='draft'
|
||||||
|
ORDER BY updated_at DESC, id DESC LIMIT 1""", (pc,))
|
||||||
p = prod_map.get(pc, {})
|
p = prod_map.get(pc, {})
|
||||||
# 查询BOM物料清单
|
# 查询BOM物料清单
|
||||||
bom_rows = query("SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s", (pc,))
|
bom_rows = query("SELECT material_code, material_name, quantity FROM bom WHERE product_code=%s", (pc,))
|
||||||
@ -1785,6 +1816,10 @@ def purchase_batch_add_pending():
|
|||||||
'material_name': r['material_name'],
|
'material_name': r['material_name'],
|
||||||
'quantity': float(r['quantity'])
|
'quantity': float(r['quantity'])
|
||||||
} for r in bom_rows], ensure_ascii=False) if bom_rows else None
|
} for r in bom_rows], ensure_ascii=False) if bom_rows else None
|
||||||
|
if saved_draft and saved_draft[0].get('sales_snapshot'):
|
||||||
|
sales_snapshot_json = saved_draft[0]['sales_snapshot']
|
||||||
|
sales_snapshot_at = saved_draft[0].get('sales_snapshot_at') or datetime.now()
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
sales_snapshot = _build_planning_sales_snapshot(
|
sales_snapshot = _build_planning_sales_snapshot(
|
||||||
pc, p.get('product_name', ''), p.get('product_type', ''))
|
pc, p.get('product_name', ''), p.get('product_type', ''))
|
||||||
@ -1792,6 +1827,19 @@ def purchase_batch_add_pending():
|
|||||||
return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"})
|
return jsonify({"code": 500, "msg": f"[{pc}] 销量信息快照保存失败:{exc}"})
|
||||||
sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str)
|
sales_snapshot_json = json.dumps(sales_snapshot, ensure_ascii=False, default=str)
|
||||||
sales_snapshot_at = datetime.now()
|
sales_snapshot_at = datetime.now()
|
||||||
|
if saved_draft:
|
||||||
|
new_old_product = normalize_new_old(saved_draft[0].get('new_old_product') or p.get('planning_new_old'))
|
||||||
|
query("""UPDATE pending_orders SET product_name=%s, product_type=%s, supplier_name=%s,
|
||||||
|
lead_time=%s, logistics_time=%s, procurement_follower=%s, procurement_planner=%s,
|
||||||
|
platform=%s, operator=%s, new_old_product=%s, sales_snapshot=%s,
|
||||||
|
sales_snapshot_at=%s, status='pending', updated_at=NOW() WHERE id=%s""",
|
||||||
|
(p.get('product_name', ''), p.get('product_type', ''), p.get('supplier_name', ''),
|
||||||
|
int(p.get('lead_time', 0) or 0), int(p.get('logistics_time', 0) or 0),
|
||||||
|
p.get('procurement_follower', '') or '', p.get('procurement_planner', '') or '',
|
||||||
|
p.get('platform', '') or '', p.get('operator', '') or '', new_old_product,
|
||||||
|
sales_snapshot_json, sales_snapshot_at, saved_draft[0]['id']))
|
||||||
|
added += 1
|
||||||
|
continue
|
||||||
query("""
|
query("""
|
||||||
INSERT INTO pending_orders
|
INSERT INTO pending_orders
|
||||||
(product_code, product_name, product_type, product_grade, supplier_name,
|
(product_code, product_name, product_type, product_grade, supplier_name,
|
||||||
@ -2470,7 +2518,7 @@ def _arrival_operator_name():
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_arrival_batches_table():
|
def _ensure_arrival_batches_table():
|
||||||
"""Ensure arrival batch storage includes the defective quantity field."""
|
"""Ensure arrival batch storage records which immutable fields were actually filled."""
|
||||||
global _ARRIVAL_BATCHES_SCHEMA_READY
|
global _ARRIVAL_BATCHES_SCHEMA_READY
|
||||||
if _ARRIVAL_BATCHES_SCHEMA_READY:
|
if _ARRIVAL_BATCHES_SCHEMA_READY:
|
||||||
return
|
return
|
||||||
@ -2483,6 +2531,9 @@ def _ensure_arrival_batches_table():
|
|||||||
defective_qty INT NOT NULL DEFAULT 0,
|
defective_qty INT NOT NULL DEFAULT 0,
|
||||||
operator_name VARCHAR(100) NOT NULL DEFAULT '',
|
operator_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
operated_at DATETIME NULL,
|
operated_at DATETIME NULL,
|
||||||
|
arrival_date_filled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
arrival_qty_filled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
defective_qty_filled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
remark TEXT,
|
remark TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)""")
|
)""")
|
||||||
@ -2500,21 +2551,46 @@ def _ensure_arrival_batches_table():
|
|||||||
query("""ALTER TABLE arrival_batches
|
query("""ALTER TABLE arrival_batches
|
||||||
ADD COLUMN operated_at DATETIME NULL AFTER operator_name""")
|
ADD COLUMN operated_at DATETIME NULL AFTER operator_name""")
|
||||||
query("UPDATE arrival_batches SET operated_at=created_at WHERE operated_at IS NULL")
|
query("UPDATE arrival_batches SET operated_at=created_at WHERE operated_at IS NULL")
|
||||||
|
# 已保存字段一旦填写即锁定;空字段允许后续补填一次。历史 0 按“未填写”迁移,
|
||||||
|
# 因为旧前端会把空输入强制保存为 0,无法再可靠区分显式 0 与空值。
|
||||||
|
if 'arrival_date_filled' not in column_names:
|
||||||
|
query("""ALTER TABLE arrival_batches
|
||||||
|
ADD COLUMN arrival_date_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER operated_at""")
|
||||||
|
query("UPDATE arrival_batches SET arrival_date_filled=IF(arrival_date IS NULL,0,1)")
|
||||||
|
if 'arrival_qty_filled' not in column_names:
|
||||||
|
query("""ALTER TABLE arrival_batches
|
||||||
|
ADD COLUMN arrival_qty_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER arrival_date_filled""")
|
||||||
|
query("UPDATE arrival_batches SET arrival_qty_filled=IF(arrival_qty IS NULL OR arrival_qty=0,0,1)")
|
||||||
|
if 'defective_qty_filled' not in column_names:
|
||||||
|
query("""ALTER TABLE arrival_batches
|
||||||
|
ADD COLUMN defective_qty_filled TINYINT(1) NOT NULL DEFAULT 0 AFTER arrival_qty_filled""")
|
||||||
|
query("UPDATE arrival_batches SET defective_qty_filled=IF(defective_qty IS NULL OR defective_qty=0,0,1)")
|
||||||
_ARRIVAL_BATCHES_SCHEMA_READY = True
|
_ARRIVAL_BATCHES_SCHEMA_READY = True
|
||||||
|
|
||||||
|
|
||||||
def _arrival_batch_quantities(data):
|
def _optional_int(data, key, label):
|
||||||
"""Parse and validate gross/defective arrival quantities."""
|
"""Return (value, filled), preserving the distinction between blank and zero."""
|
||||||
|
raw = data.get(key)
|
||||||
|
if raw in (None, ''):
|
||||||
|
return 0, False
|
||||||
try:
|
try:
|
||||||
arrival_qty = int(data.get('arrival_qty') or 0)
|
return int(raw), True
|
||||||
defective_qty = int(data.get('defective_qty') or 0)
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise ValueError('到货数量和不良品数量必须为整数')
|
raise ValueError(f'{label}必须为整数')
|
||||||
if arrival_qty < 0 or defective_qty < 0:
|
|
||||||
raise ValueError('到货数量和不良品数量不能为负数')
|
|
||||||
|
def _arrival_batch_quantities(data, order_type='domestic'):
|
||||||
|
"""Parse quantities; packaging defective quantity may be any integer."""
|
||||||
|
arrival_qty, arrival_qty_filled = _optional_int(data, 'arrival_qty', '到货数量')
|
||||||
|
defective_qty, defective_qty_filled = _optional_int(data, 'defective_qty', '不良品数量')
|
||||||
|
if arrival_qty_filled and arrival_qty < 0:
|
||||||
|
raise ValueError('到货数量不能为负数')
|
||||||
|
if order_type != 'packaging' and defective_qty_filled:
|
||||||
|
if defective_qty < 0:
|
||||||
|
raise ValueError('不良品数量不能为负数')
|
||||||
if defective_qty > arrival_qty:
|
if defective_qty > arrival_qty:
|
||||||
raise ValueError('不良品数量不能大于到货数量')
|
raise ValueError('不良品数量不能大于到货数量')
|
||||||
return arrival_qty, defective_qty
|
return arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled
|
||||||
|
|
||||||
|
|
||||||
def _domestic_order_pre_packaging(order_id):
|
def _domestic_order_pre_packaging(order_id):
|
||||||
@ -2617,7 +2693,8 @@ def domestic_orders_detail_extras():
|
|||||||
_ensure_arrival_batches_table()
|
_ensure_arrival_batches_table()
|
||||||
|
|
||||||
arrival_batches = list(query("""
|
arrival_batches = list(query("""
|
||||||
SELECT id, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark
|
SELECT id, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark,
|
||||||
|
arrival_date_filled, arrival_qty_filled, defective_qty_filled
|
||||||
FROM arrival_batches
|
FROM arrival_batches
|
||||||
WHERE order_id=%s AND order_type=%s
|
WHERE order_id=%s AND order_type=%s
|
||||||
ORDER BY arrival_date DESC, id DESC
|
ORDER BY arrival_date DESC, id DESC
|
||||||
@ -2658,22 +2735,28 @@ def domestic_orders_add_batch():
|
|||||||
return jsonify({"code": 400, "msg": "订单类型错误"})
|
return jsonify({"code": 400, "msg": "订单类型错误"})
|
||||||
_ensure_arrival_batches_table()
|
_ensure_arrival_batches_table()
|
||||||
try:
|
try:
|
||||||
arrival_qty, defective_qty = _arrival_batch_quantities(data)
|
arrival_qty, defective_qty, arrival_qty_filled, defective_qty_filled = _arrival_batch_quantities(data, order_type)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"code": 400, "msg": str(exc)})
|
return jsonify({"code": 400, "msg": str(exc)})
|
||||||
operator_name = _arrival_operator_name()
|
operator_name = _arrival_operator_name()
|
||||||
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
row_id = query("""INSERT INTO arrival_batches
|
row_id = query("""INSERT INTO arrival_batches
|
||||||
(order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at, remark)
|
(order_id, order_type, arrival_date, arrival_qty, defective_qty, operator_name, operated_at,
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""", (
|
arrival_date_filled, arrival_qty_filled, defective_qty_filled, remark)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (
|
||||||
order_id, order_type, data.get('arrival_date') or None,
|
order_id, order_type, data.get('arrival_date') or None,
|
||||||
arrival_qty, defective_qty, operator_name, operated_at, data.get('remark') or ''
|
arrival_qty, defective_qty, operator_name, operated_at,
|
||||||
|
1 if data.get('arrival_date') not in (None, '') else 0,
|
||||||
|
int(arrival_qty_filled), int(defective_qty_filled), data.get('remark') or ''
|
||||||
))
|
))
|
||||||
sync_order_arrival_totals(order_id, order_type)
|
sync_order_arrival_totals(order_id, order_type)
|
||||||
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
|
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
|
||||||
add_operation_log('到货批次及数量', f"新增到货批次:到货日期={data.get('arrival_date') or '空'},数量={arrival_qty},不良品数量={defective_qty},备注={data.get('remark') or '空'}(订单ID={order_id})", page=page_name, field='到货批次及数量')
|
add_operation_log('到货批次及数量', f"新增到货批次:到货日期={data.get('arrival_date') or '空'},数量={arrival_qty},不良品数量={defective_qty},备注={data.get('remark') or '空'}(订单ID={order_id})", page=page_name, field='到货批次及数量')
|
||||||
return jsonify({"code": 0, "msg": "添加到货批次成功", "data": {
|
return jsonify({"code": 0, "msg": "添加到货批次成功", "data": {
|
||||||
"id": row_id, "operator_name": operator_name, "operated_at": operated_at
|
"id": row_id, "operator_name": operator_name, "operated_at": operated_at,
|
||||||
|
"arrival_date_filled": 1 if data.get('arrival_date') not in (None, '') else 0,
|
||||||
|
"arrival_qty_filled": int(arrival_qty_filled),
|
||||||
|
"defective_qty_filled": int(defective_qty_filled),
|
||||||
}})
|
}})
|
||||||
|
|
||||||
|
|
||||||
@ -2691,26 +2774,64 @@ def domestic_orders_update_batch():
|
|||||||
return jsonify({"code": 400, "msg": "缺少批次ID"})
|
return jsonify({"code": 400, "msg": "缺少批次ID"})
|
||||||
_ensure_arrival_batches_table()
|
_ensure_arrival_batches_table()
|
||||||
batch_rows = query(
|
batch_rows = query(
|
||||||
"SELECT order_id, arrival_date, arrival_qty, defective_qty, remark, operator_name, operated_at "
|
"SELECT order_id, arrival_date, arrival_qty, defective_qty, remark, operator_name, operated_at, "
|
||||||
|
"arrival_date_filled, arrival_qty_filled, defective_qty_filled "
|
||||||
"FROM arrival_batches WHERE id=%s AND order_type=%s",
|
"FROM arrival_batches WHERE id=%s AND order_type=%s",
|
||||||
(batch_id, order_type),
|
(batch_id, order_type),
|
||||||
)
|
)
|
||||||
if not batch_rows:
|
if not batch_rows:
|
||||||
return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"})
|
return jsonify({"code": 404, "msg": "批次不存在或不属于当前订单"})
|
||||||
current = batch_rows[0]
|
current = batch_rows[0]
|
||||||
# 已保存批次的核心字段不可修改,只允许在备注末尾追加留痕。
|
# 已填写字段不可修改;首次保存时留空的字段允许补填一次,补填后立即锁定。
|
||||||
|
update_fields = []
|
||||||
|
update_params = []
|
||||||
|
supplemented = []
|
||||||
if data.get('arrival_date') not in (None, ''):
|
if data.get('arrival_date') not in (None, ''):
|
||||||
current_date = current.get('arrival_date')
|
current_date = current.get('arrival_date')
|
||||||
if hasattr(current_date, 'isoformat'):
|
if hasattr(current_date, 'isoformat'):
|
||||||
current_date = current_date.isoformat()
|
current_date = current_date.isoformat()
|
||||||
if str(current_date or '')[:10] != str(data.get('arrival_date') or '')[:10]:
|
requested_date = str(data.get('arrival_date') or '')[:10]
|
||||||
|
date_locked = order_type != 'packaging' or current.get('arrival_date_filled')
|
||||||
|
if date_locked and str(current_date or '')[:10] != requested_date:
|
||||||
return jsonify({"code": 400, "msg": "到货日期保存后不允许修改"}), 400
|
return jsonify({"code": 400, "msg": "到货日期保存后不允许修改"}), 400
|
||||||
|
if order_type == 'packaging' and not current.get('arrival_date_filled'):
|
||||||
|
update_fields.extend(['arrival_date=%s', 'arrival_date_filled=1'])
|
||||||
|
update_params.append(requested_date)
|
||||||
|
supplemented.append(f'到货日期={requested_date}')
|
||||||
if data.get('arrival_qty') not in (None, ''):
|
if data.get('arrival_qty') not in (None, ''):
|
||||||
if int(data.get('arrival_qty') or 0) != int(current.get('arrival_qty') or 0):
|
try:
|
||||||
|
requested_qty = int(data.get('arrival_qty'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"code": 400, "msg": "到货数量必须为整数"}), 400
|
||||||
|
if requested_qty < 0:
|
||||||
|
return jsonify({"code": 400, "msg": "到货数量不能为负数"}), 400
|
||||||
|
qty_locked = order_type != 'packaging' or current.get('arrival_qty_filled')
|
||||||
|
if qty_locked and requested_qty != int(current.get('arrival_qty') or 0):
|
||||||
return jsonify({"code": 400, "msg": "到货数量保存后不允许修改"}), 400
|
return jsonify({"code": 400, "msg": "到货数量保存后不允许修改"}), 400
|
||||||
|
if order_type == 'packaging' and not current.get('arrival_qty_filled'):
|
||||||
|
update_fields.extend(['arrival_qty=%s', 'arrival_qty_filled=1'])
|
||||||
|
update_params.append(requested_qty)
|
||||||
|
supplemented.append(f'到货数量={requested_qty}')
|
||||||
if data.get('defective_qty') not in (None, ''):
|
if data.get('defective_qty') not in (None, ''):
|
||||||
if int(data.get('defective_qty') or 0) != int(current.get('defective_qty') or 0):
|
try:
|
||||||
|
requested_defective = int(data.get('defective_qty'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"code": 400, "msg": "不良品数量必须为整数"}), 400
|
||||||
|
if order_type != 'packaging' and requested_defective < 0:
|
||||||
|
return jsonify({"code": 400, "msg": "不良品数量不能为负数"}), 400
|
||||||
|
effective_arrival_qty = (
|
||||||
|
requested_qty if 'requested_qty' in locals() and data.get('arrival_qty') not in (None, '')
|
||||||
|
else int(current.get('arrival_qty') or 0)
|
||||||
|
)
|
||||||
|
if order_type != 'packaging' and requested_defective > effective_arrival_qty:
|
||||||
|
return jsonify({"code": 400, "msg": "不良品数量不能大于到货数量"}), 400
|
||||||
|
defective_locked = order_type != 'packaging' or current.get('defective_qty_filled')
|
||||||
|
if defective_locked and requested_defective != int(current.get('defective_qty') or 0):
|
||||||
return jsonify({"code": 400, "msg": "不良品数量保存后不允许修改"}), 400
|
return jsonify({"code": 400, "msg": "不良品数量保存后不允许修改"}), 400
|
||||||
|
if order_type == 'packaging' and not current.get('defective_qty_filled'):
|
||||||
|
update_fields.extend(['defective_qty=%s', 'defective_qty_filled=1'])
|
||||||
|
update_params.append(requested_defective)
|
||||||
|
supplemented.append(f'不良品数量={requested_defective}')
|
||||||
operator_name = _arrival_operator_name()
|
operator_name = _arrival_operator_name()
|
||||||
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
operated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
old_remark = str(current.get('remark') or '')
|
old_remark = str(current.get('remark') or '')
|
||||||
@ -2723,10 +2844,14 @@ def domestic_orders_update_batch():
|
|||||||
if added:
|
if added:
|
||||||
trace = f"[{operated_at} {operator_name or '未知账号'}] {added}"
|
trace = f"[{operated_at} {operator_name or '未知账号'}] {added}"
|
||||||
final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace
|
final_remark = (old_remark.rstrip() + "\n" if old_remark.strip() else '') + trace
|
||||||
|
update_fields.append('remark=%s')
|
||||||
|
update_params.append(final_remark)
|
||||||
|
if update_fields:
|
||||||
query(
|
query(
|
||||||
"UPDATE arrival_batches SET remark=%s WHERE id=%s AND order_type=%s",
|
"UPDATE arrival_batches SET " + ', '.join(update_fields) + " WHERE id=%s AND order_type=%s",
|
||||||
(final_remark, batch_id, order_type),
|
tuple(update_params) + (batch_id, order_type),
|
||||||
)
|
)
|
||||||
|
sync_order_arrival_totals(current['order_id'], order_type)
|
||||||
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
|
page_name = {'domestic': '国内订单管理', 'overseas': '海外订单管理', 'packaging': '包材订单管理'}.get(order_type, order_type)
|
||||||
if final_remark != old_remark:
|
if final_remark != old_remark:
|
||||||
add_operation_log(
|
add_operation_log(
|
||||||
@ -2735,11 +2860,21 @@ def domestic_orders_update_batch():
|
|||||||
page=page_name,
|
page=page_name,
|
||||||
field='到货批次及数量',
|
field='到货批次及数量',
|
||||||
)
|
)
|
||||||
|
if supplemented:
|
||||||
|
add_operation_log(
|
||||||
|
'到货批次及数量',
|
||||||
|
f"补填到货批次字段:{';'.join(supplemented)}(批次ID={batch_id},订单ID={current['order_id']})",
|
||||||
|
page=page_name,
|
||||||
|
field='到货批次及数量',
|
||||||
|
)
|
||||||
return jsonify({"code": 0, "msg": "更新成功", "data": {
|
return jsonify({"code": 0, "msg": "更新成功", "data": {
|
||||||
"remark": final_remark,
|
"remark": final_remark,
|
||||||
"operator_name": current.get('operator_name') or '',
|
"operator_name": current.get('operator_name') or '',
|
||||||
"operated_at": current.get('operated_at').strftime('%Y-%m-%d %H:%M:%S')
|
"operated_at": current.get('operated_at').strftime('%Y-%m-%d %H:%M:%S')
|
||||||
if hasattr(current.get('operated_at'), 'strftime') else str(current.get('operated_at') or '')[:19]
|
if hasattr(current.get('operated_at'), 'strftime') else str(current.get('operated_at') or '')[:19],
|
||||||
|
"arrival_date_filled": 1 if current.get('arrival_date_filled') or data.get('arrival_date') not in (None, '') else 0,
|
||||||
|
"arrival_qty_filled": 1 if current.get('arrival_qty_filled') or data.get('arrival_qty') not in (None, '') else 0,
|
||||||
|
"defective_qty_filled": 1 if current.get('defective_qty_filled') or data.get('defective_qty') not in (None, '') else 0,
|
||||||
}})
|
}})
|
||||||
|
|
||||||
|
|
||||||
@ -3503,44 +3638,7 @@ def packaging_orders_delete():
|
|||||||
# ─── 成品提货表 API ───
|
# ─── 成品提货表 API ───
|
||||||
def _finished_order_progress_map():
|
def _finished_order_progress_map():
|
||||||
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
|
"""读取国内/海外未完结订单的进度,按最早下单时间为每个成品取统一进度。"""
|
||||||
rows = []
|
return unfinished_order_progress_map()
|
||||||
for source, sql in (
|
|
||||||
('domestic', """
|
|
||||||
SELECT `货品编号` AS product_code, `进度`,
|
|
||||||
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
|
|
||||||
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
|
|
||||||
id
|
|
||||||
FROM domestic_orders
|
|
||||||
WHERE `状态`='未完结'
|
|
||||||
"""),
|
|
||||||
('overseas', """
|
|
||||||
SELECT `产品编码` AS product_code, `进度`,
|
|
||||||
CASE WHEN CAST(`订单下达供应商时间` AS CHAR) <> '' THEN CAST(`订单下达供应商时间` AS CHAR)
|
|
||||||
ELSE NULLIF(CAST(`日期` AS CHAR), '') END AS order_date,
|
|
||||||
id
|
|
||||||
FROM overseas_orders
|
|
||||||
WHERE `状态`='未完结'
|
|
||||||
"""),
|
|
||||||
):
|
|
||||||
for row in query(sql):
|
|
||||||
row['_source_order'] = 0 if source == 'domestic' else 1
|
|
||||||
rows.append(row)
|
|
||||||
|
|
||||||
def sort_key(row):
|
|
||||||
order_date = row.get('order_date')
|
|
||||||
if order_date and hasattr(order_date, 'strftime'):
|
|
||||||
date_text = order_date.strftime('%Y-%m-%d %H:%M:%S')
|
|
||||||
else:
|
|
||||||
date_text = str(order_date or '').strip()
|
|
||||||
return (0 if date_text else 1, date_text, row.get('_source_order', 0), row.get('id') or 0)
|
|
||||||
|
|
||||||
rows.sort(key=sort_key)
|
|
||||||
progress_map = {}
|
|
||||||
for row in rows:
|
|
||||||
product_code = str(row.get('product_code') or '').strip()
|
|
||||||
if product_code and product_code not in progress_map:
|
|
||||||
progress_map[product_code] = row.get('进度') or ''
|
|
||||||
return progress_map
|
|
||||||
|
|
||||||
|
|
||||||
def _finished_inventory_maps():
|
def _finished_inventory_maps():
|
||||||
|
|||||||
@ -769,6 +769,256 @@ def _h_supplier_delivery(params):
|
|||||||
rows = _apply_filters(rows, params)
|
rows = _apply_filters(rows, params)
|
||||||
return {'columns': fields, 'rows': rows}
|
return {'columns': fields, 'rows': rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _h_operation_history(params):
|
||||||
|
"""按板块及业务对象检索结构化审计日志,旧记录允许文本兼容匹配。"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
code = str(params.get('product_code') or '').strip()
|
||||||
|
product_name = str(params.get('product_name') or '').strip()
|
||||||
|
identity = code or product_name
|
||||||
|
module = str(params.get('module') or '').strip()
|
||||||
|
field = str(params.get('field') or '').strip()
|
||||||
|
operator = str(params.get('operator') or '').strip()
|
||||||
|
start_date = str(params.get('start_date') or '').strip()
|
||||||
|
end_date = str(params.get('end_date') or '').strip()
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(params.get('limit') or 100), 200))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
where = ["l.action <> '登录'",
|
||||||
|
"COALESCE(l.event_type,'') NOT IN ('import','export','ai_analysis','ai_report','ai_simulation','login')",
|
||||||
|
"COALESCE(l.action,'') NOT LIKE '%%导入%%'"]
|
||||||
|
values = []
|
||||||
|
allowed_pages = params.get('_allowed_pages')
|
||||||
|
if allowed_pages is not None:
|
||||||
|
allowed_pages = [str(page) for page in allowed_pages if str(page).strip()]
|
||||||
|
if not allowed_pages:
|
||||||
|
return {'columns': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
|
||||||
|
'rows': [], 'summary': {'row_count': 0, 'returned_rows': 0,
|
||||||
|
'exact_object_matches': 0, 'text_fallback_matches': 0, 'limit': limit}}
|
||||||
|
where.append("COALESCE(NULLIF(l.page,''),l.action) IN (" + ','.join(['%s'] * len(allowed_pages)) + ")")
|
||||||
|
values.extend(allowed_pages)
|
||||||
|
if code:
|
||||||
|
boundary_pattern = r'(^|[^A-Za-z0-9-])' + re.escape(code) + r'($|[^A-Za-z0-9-])'
|
||||||
|
where.append("(l.object_id=%s OR l.new_values REGEXP %s OR l.old_values REGEXP %s OR l.detail REGEXP %s)")
|
||||||
|
values.extend((code, boundary_pattern, boundary_pattern, boundary_pattern))
|
||||||
|
elif product_name:
|
||||||
|
where.append("(l.new_values LIKE %s OR l.old_values LIKE %s OR l.detail LIKE %s)")
|
||||||
|
values.extend(['%' + product_name + '%'] * 3)
|
||||||
|
if module:
|
||||||
|
where.append("COALESCE(NULLIF(l.page,''),l.action) LIKE %s")
|
||||||
|
values.append('%' + module + '%')
|
||||||
|
if field:
|
||||||
|
where.append("(COALESCE(l.field,'') LIKE %s OR l.new_values LIKE %s OR l.old_values LIKE %s)")
|
||||||
|
values.extend(['%' + field + '%'] * 3)
|
||||||
|
if operator:
|
||||||
|
where.append("(u.real_name LIKE %s OR u.username LIKE %s)")
|
||||||
|
values.extend(['%' + operator + '%'] * 2)
|
||||||
|
if start_date:
|
||||||
|
where.append('l.created_at >= %s')
|
||||||
|
values.append(start_date + ' 00:00:00')
|
||||||
|
if end_date:
|
||||||
|
where.append('l.created_at < DATE_ADD(%s, INTERVAL 1 DAY)')
|
||||||
|
values.append(end_date)
|
||||||
|
|
||||||
|
where_sql = ' AND '.join(where)
|
||||||
|
total_rows = query('SELECT COUNT(*) AS total FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id WHERE ' + where_sql,
|
||||||
|
values)[0].get('total', 0)
|
||||||
|
rows = query("""SELECT l.id,l.created_at,l.user_id,l.page,l.action,l.field,l.object_type,l.object_id,
|
||||||
|
l.old_values,l.new_values,l.detail,l.result_status,l.request_id,
|
||||||
|
u.real_name,u.username
|
||||||
|
FROM dashboard_logs l LEFT JOIN dashboard_users u ON u.id=l.user_id
|
||||||
|
WHERE """ + where_sql + ' ORDER BY l.created_at DESC,l.id DESC LIMIT %s',
|
||||||
|
values + [limit])
|
||||||
|
|
||||||
|
def decode(value):
|
||||||
|
if not value:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value) if isinstance(value, str) else value
|
||||||
|
return scrub(parsed) if isinstance(parsed, (dict, list)) else {'内容': parsed}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {'历史原文': str(value)[:1000]}
|
||||||
|
|
||||||
|
def scrub(value):
|
||||||
|
sensitive = ('password', 'token', 'secret', 'authorization', 'cookie', 'api_key',
|
||||||
|
'密码', '令牌', '密钥', '授权信息')
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: ('[已隐藏]' if any(mark in str(key).lower() for mark in sensitive)
|
||||||
|
else scrub(item)) for key, item in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [scrub(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
field_labels = {
|
||||||
|
'product_code': '货品编号', 'product_name': '货品名称', 'product_type': '货品类型',
|
||||||
|
'brand': '品牌', 'supplier': '供应商', 'supplier_code': '供应商编码', 'supplier_name': '供应商名称',
|
||||||
|
'planner': '采购计划', 'purchase_planner': '采购计划', 'follower': '采购跟单',
|
||||||
|
'purchase_follower': '采购跟单', 'operator': '运营', 'developer': '开发专员',
|
||||||
|
'barcode': '条形码', 'category': '类目', 'platform': '平台', 'department': '部门',
|
||||||
|
'group_name': '组别', 'status': '状态', 'progress': '进度', 'remark': '备注',
|
||||||
|
'order_remark': '下单备注', 'purchase_plan': '采购计划', 'lead_time': '生产货期',
|
||||||
|
'logistics_time': '物流时间', 'moq': '最低起订量', 'cost_price': '成本价',
|
||||||
|
'price': '单价', 'quantity': '数量', 'order_quantity': '下单数量',
|
||||||
|
'inventory': '库存', 'stock': '库存', 'arrival_date': '到货日期',
|
||||||
|
'completion_date': '完结日期', 'new_old': '新旧品', 'is_new': '新旧品',
|
||||||
|
'id': '记录编号', 'product_codes': '货品编号列表', 'materials': '物料列表',
|
||||||
|
'bom_data': 'BOM数据', 'material_code': '物料编号', 'material_name': '物料名称',
|
||||||
|
'use_quantity': '用量', 'unit_quantity': '用量', 'old_values': '修改前内容',
|
||||||
|
'new_values': '修改后内容', 'value': '字段值', 'field': '字段', 'data': '提交数据',
|
||||||
|
'warehouse': '仓库', 'warehouse_code': '仓库编号', 'warehouse_name': '仓库名称',
|
||||||
|
'supplier_contract': '供应商合同', 'contract_code': '合同编号', 'purchase_order': '采购单',
|
||||||
|
'purchase_plan_name': '采购计划', 'purchase_group': '跟单小组',
|
||||||
|
'view': '视图', 'columns': '字段列', 'sort_by': '排序字段', 'sort_order': '排序顺序',
|
||||||
|
'width': '列宽', 'visible': '是否显示', 'default_show': '默认显示', 'order': '顺序',
|
||||||
|
'settings': '设置', 'config': '配置', 'filters': '筛选条件', 'page': '页码',
|
||||||
|
'size': '每页条数', 'limit': '数量上限', 'enabled': '是否启用', 'deleted': '是否删除',
|
||||||
|
}
|
||||||
|
page_labels = {
|
||||||
|
'planning': '计划预测', 'products': '产品信息管理', 'product': '产品信息管理',
|
||||||
|
'order_domestic': '国内订单管理', 'order_overseas': '海外订单管理',
|
||||||
|
'order_packaging': '包材订单管理', 'purchase_pending': '待下单管理',
|
||||||
|
'purchase_backup': '下单备份管理', 'bom': '物料清单管理', 'suppliers': '供应商管理',
|
||||||
|
'spot_application': '现货采样申请', 'spot_confirmation': '现货采样确认',
|
||||||
|
'spot_sales': '现货销量管理', 'success': '成功', 'failed': '失败', 'denied': '已拒绝',
|
||||||
|
}
|
||||||
|
|
||||||
|
def label_for(key):
|
||||||
|
key = str(key)
|
||||||
|
if key in field_labels:
|
||||||
|
return field_labels[key]
|
||||||
|
if key in ('货品编号', '货品名称', '产品编码', '产品名称'):
|
||||||
|
return key
|
||||||
|
# 常见 camelCase key 先拆分,再保留语义清晰的中文前缀。
|
||||||
|
readable = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', key).replace('-', '_')
|
||||||
|
parts = readable.split('_')
|
||||||
|
if len(parts) > 1 and parts[0] in ('product', 'material', 'supplier', 'order'):
|
||||||
|
roots = {'product': '产品', 'material': '物料', 'supplier': '供应商', 'order': '订单'}
|
||||||
|
tails = {'code': '编号', 'name': '名称', 'type': '类型', 'id': '编号', 'quantity': '数量',
|
||||||
|
'status': '状态', 'remark': '备注', 'price': '单价', 'date': '日期'}
|
||||||
|
return roots[parts[0]] + ''.join(tails.get(part, part) for part in parts[1:])
|
||||||
|
return key
|
||||||
|
|
||||||
|
def format_value(value, field_key=''):
|
||||||
|
if field_key in ('field', 'sort_by') and isinstance(value, str):
|
||||||
|
return label_for(value)
|
||||||
|
if field_key == 'sort_order' and value in ('asc', 'desc'):
|
||||||
|
return '升序' if value == 'asc' else '降序'
|
||||||
|
if field_key == 'view' and value == 'default':
|
||||||
|
return '默认视图'
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(translate_data(value), ensure_ascii=False, default=str)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def translate_data(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
translated = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
if key in ('field', 'sort_by') and isinstance(item, str):
|
||||||
|
translated[label_for(key)] = label_for(item)
|
||||||
|
else:
|
||||||
|
translated[label_for(key)] = translate_data(item)
|
||||||
|
return translated
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [translate_data(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
def diff_values(old_value, new_value, path):
|
||||||
|
"""Return leaf-level changes so large submitted objects do not expose unchanged siblings."""
|
||||||
|
if isinstance(old_value, dict) and isinstance(new_value, dict):
|
||||||
|
changes = []
|
||||||
|
for key in dict.fromkeys(list(old_value) + list(new_value)):
|
||||||
|
child_path = path + [str(key)]
|
||||||
|
if key not in old_value:
|
||||||
|
changes.append((child_path, None, new_value[key]))
|
||||||
|
elif key not in new_value:
|
||||||
|
changes.append((child_path, old_value[key], None))
|
||||||
|
else:
|
||||||
|
changes.extend(diff_values(old_value[key], new_value[key], child_path))
|
||||||
|
return changes
|
||||||
|
if isinstance(old_value, list) and isinstance(new_value, list):
|
||||||
|
identity_keys = ('product_code', 'material_code', 'product_id', 'id', 'code',
|
||||||
|
'货品编号', '物料编号', '产品编码')
|
||||||
|
if all(isinstance(item, dict) for item in old_value + new_value):
|
||||||
|
def item_key(item, index):
|
||||||
|
for identity_key in identity_keys:
|
||||||
|
if item.get(identity_key) not in (None, ''):
|
||||||
|
return str(item[identity_key])
|
||||||
|
return '#%s' % index
|
||||||
|
old_items = {item_key(item, index): item for index, item in enumerate(old_value)}
|
||||||
|
new_items = {item_key(item, index): item for index, item in enumerate(new_value)}
|
||||||
|
changes = []
|
||||||
|
for item_id in dict.fromkeys(list(old_items) + list(new_items)):
|
||||||
|
item_path = path + [item_id]
|
||||||
|
if item_id not in old_items:
|
||||||
|
changes.append((item_path, None, new_items[item_id]))
|
||||||
|
elif item_id not in new_items:
|
||||||
|
changes.append((item_path, old_items[item_id], None))
|
||||||
|
else:
|
||||||
|
changes.extend(diff_values(old_items[item_id], new_items[item_id], item_path))
|
||||||
|
return changes
|
||||||
|
if old_value == new_value:
|
||||||
|
return []
|
||||||
|
return [(path, old_value, new_value)]
|
||||||
|
return [] if old_value == new_value else [(path, old_value, new_value)]
|
||||||
|
|
||||||
|
def display_value(value, field_key=''):
|
||||||
|
if value is None:
|
||||||
|
return '(空)'
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(translate_data(value), ensure_ascii=False, default=str)
|
||||||
|
return format_value(value, field_key)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
exact_count = 0
|
||||||
|
text_fallback_count = 0
|
||||||
|
for row in rows:
|
||||||
|
exact = bool(code and str(row.get('object_id') or '').casefold() == code.casefold())
|
||||||
|
if identity and not exact:
|
||||||
|
pattern = (re.compile(r'(?<![A-Za-z0-9-])' + re.escape(code) + r'(?![A-Za-z0-9-])', re.I)
|
||||||
|
if code else re.compile(re.escape(product_name), re.I))
|
||||||
|
if not any(pattern.search(str(row.get(key) or '')) for key in ('new_values', 'old_values', 'detail')):
|
||||||
|
continue
|
||||||
|
exact_count += int(exact)
|
||||||
|
text_fallback_count += int(bool(identity and not exact))
|
||||||
|
old_values = decode(row.get('old_values'))
|
||||||
|
new_values = decode(row.get('new_values'))
|
||||||
|
changed = []
|
||||||
|
changed_fields = []
|
||||||
|
if isinstance(new_values, dict):
|
||||||
|
old_map = old_values if isinstance(old_values, dict) else {}
|
||||||
|
for key, new_value in new_values.items():
|
||||||
|
# A new-only request payload is not evidence that every submitted field changed.
|
||||||
|
if key not in old_map:
|
||||||
|
continue
|
||||||
|
for path, old_value, current_value in diff_values(old_map[key], new_value, [str(key)]):
|
||||||
|
label = ' · '.join(label_for(part) for part in path)
|
||||||
|
changed_fields.append(label)
|
||||||
|
changed.append('%s:%s → %s' % (
|
||||||
|
label, display_value(old_value, path[-1]), display_value(current_value, path[-1])))
|
||||||
|
created = row.get('created_at')
|
||||||
|
if hasattr(created, 'strftime'):
|
||||||
|
created = created.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
result.append({
|
||||||
|
'操作时间': str(created or ''),
|
||||||
|
'操作人': row.get('real_name') or row.get('username') or ('用户#%s' % row.get('user_id')),
|
||||||
|
'板块': page_labels.get(str(row.get('page') or ''), row.get('page') or ''),
|
||||||
|
'操作': page_labels.get(str(row.get('action') or ''), row.get('action') or ''),
|
||||||
|
'字段': '、'.join(dict.fromkeys(changed_fields)) or label_for(row.get('field') or '请求字段(旧日志未结构化)'),
|
||||||
|
'修改内容': ';'.join(changed) if changed else '该记录未保存可比较的字段前后值',
|
||||||
|
'匹配方式': '精确对象匹配' if exact else ('历史文本匹配' if identity else '条件匹配'),
|
||||||
|
'结果': page_labels.get(str(row.get('result_status') or ''), row.get('result_status') or ''),
|
||||||
|
'请求编号': row.get('request_id') or '',
|
||||||
|
'日志ID': row.get('id'),
|
||||||
|
})
|
||||||
|
return {'columns': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
|
||||||
|
'rows': result,
|
||||||
|
'summary': {'row_count': int(total_rows or 0), 'returned_rows': len(result),
|
||||||
|
'exact_object_matches': exact_count,
|
||||||
|
'text_fallback_matches': text_fallback_count, 'limit': limit}}
|
||||||
|
|
||||||
def _latest_outbound_date():
|
def _latest_outbound_date():
|
||||||
"""最新出库日期(销量/出库类数据源的核对日期)"""
|
"""最新出库日期(销量/出库类数据源的核对日期)"""
|
||||||
rows = query("SELECT MAX(日期) AS d FROM daily_outbound")
|
rows = query("SELECT MAX(日期) AS d FROM daily_outbound")
|
||||||
@ -935,6 +1185,15 @@ AI_DATASOURCES = {
|
|||||||
'date_kind': '',
|
'date_kind': '',
|
||||||
'keywords': ['供应商表现', '供应商交付', '供应商排名', '交付率', '不良品率', '供应商风险'],
|
'keywords': ['供应商表现', '供应商交付', '供应商排名', '交付率', '不良品率', '供应商风险'],
|
||||||
},
|
},
|
||||||
|
'operation_history': {
|
||||||
|
'name': '手动修改记录',
|
||||||
|
'desc': '只读检索结构化操作日志,按板块、货品编号、字段、操作人和日期查询;精确对象匹配与旧日志文本匹配会分别标注。需要操作日志菜单查看权限。可回答某商品谁在何时修改了哪些内容。',
|
||||||
|
'fields': ['操作时间','操作人','板块','操作','字段','修改内容','匹配方式','结果','请求编号','日志ID'],
|
||||||
|
'handler': _h_operation_history,
|
||||||
|
'permission_pages': ['logs'],
|
||||||
|
'date_kind': '',
|
||||||
|
'keywords': ['操作日志','修改记录','编辑记录','手动修改','修改历史','变更历史','谁修改','修改过','修改前','修改后'],
|
||||||
|
},
|
||||||
'brand_overview': {
|
'brand_overview': {
|
||||||
'name': '品牌汇总',
|
'name': '品牌汇总',
|
||||||
'desc': '按品牌汇总的 SKU数、库存数量、库存金额、昨日销量、30天平均销量与近30/100天动销SKU数(默认按库存金额倒序)。可回答:哪个品牌库存金额最高、某品牌有多少个动销SKU、各品牌销量对比。',
|
'desc': '按品牌汇总的 SKU数、库存数量、库存金额、昨日销量、30天平均销量与近30/100天动销SKU数(默认按库存金额倒序)。可回答:哪个品牌库存金额最高、某品牌有多少个动销SKU、各品牌销量对比。',
|
||||||
|
|||||||
@ -59,9 +59,36 @@ def classify_risk(action='',field='',path=''):
|
|||||||
return 'normal'
|
return 'normal'
|
||||||
|
|
||||||
def infer_object(data,path=''):
|
def infer_object(data,path=''):
|
||||||
|
if isinstance(data,list):
|
||||||
|
for item in data:
|
||||||
|
nested_type,nested_id=infer_object(item,path)
|
||||||
|
if nested_id:return nested_type,nested_id
|
||||||
|
return ('','')
|
||||||
if not isinstance(data,dict):return ('','')
|
if not isinstance(data,dict):return ('','')
|
||||||
for key in ('product_code','material_code','supplier_code','record_id','order_id','subject_id','target_id','id','user_id'):
|
# 中文字段名用于兼容前端表单及嵌套业务对象的常见编码字段。
|
||||||
|
for key in ('product_code','货品编号','产品编码','货品编码','material_code','物料编号','supplier_code','供应商编码',
|
||||||
|
'order_id','订单编号','record_id','subject_id','target_id'):
|
||||||
|
value=data.get(key)
|
||||||
|
if isinstance(value,dict):
|
||||||
|
nested_type,nested_id=infer_object(value,path)
|
||||||
|
if nested_id:return nested_type,nested_id
|
||||||
|
if isinstance(value,list):
|
||||||
|
for item in value:
|
||||||
|
nested_type,nested_id=infer_object(item,path)
|
||||||
|
if nested_id:return nested_type,nested_id
|
||||||
|
if value not in (None,''):
|
||||||
|
object_type=key.replace('_code','').replace('_id','')
|
||||||
|
if key in ('货品编号','产品编码','货品编码'):object_type='product'
|
||||||
|
elif key=='物料编号':object_type='material'
|
||||||
|
elif key=='供应商编码':object_type='supplier'
|
||||||
|
elif key=='订单编号':object_type='order'
|
||||||
|
return (object_type,str(value))
|
||||||
|
for value in data.values():
|
||||||
|
if isinstance(value,(dict,list)):
|
||||||
|
nested_type,nested_id=infer_object(value,path)
|
||||||
|
if nested_id:return nested_type,nested_id
|
||||||
|
for key in ('id','user_id'):
|
||||||
value=data.get(key)
|
value=data.get(key)
|
||||||
if value not in (None,''):
|
if value not in (None,''):
|
||||||
return (key.replace('_code','').replace('_id',''),str(value))
|
return (key.replace('_id',''),str(value))
|
||||||
return ('','')
|
return ('','')
|
||||||
|
|||||||
@ -28,6 +28,7 @@ FIELD_REGISTRY = {
|
|||||||
{'key': '新旧品', 'sensitive': False},
|
{'key': '新旧品', 'sensitive': False},
|
||||||
{'key': '下单清单', 'sensitive': False},
|
{'key': '下单清单', 'sensitive': False},
|
||||||
{'key': '产品分级', 'sensitive': False},
|
{'key': '产品分级', 'sensitive': False},
|
||||||
|
{'key': '在途进度', 'sensitive': False},
|
||||||
{'key': '昨日销量', 'sensitive': False},
|
{'key': '昨日销量', 'sensitive': False},
|
||||||
{'key': '云仓昨日销量', 'sensitive': False},
|
{'key': '云仓昨日销量', 'sensitive': False},
|
||||||
{'key': '外仓昨日销量', 'sensitive': False},
|
{'key': '外仓昨日销量', 'sensitive': False},
|
||||||
|
|||||||
85
services/order_progress.py
Normal file
85
services/order_progress.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""国内/海外订单统一在途进度口径。"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from services.db import query
|
||||||
|
|
||||||
|
|
||||||
|
def _order_time_value(value):
|
||||||
|
"""把历史订单中的 datetime、YYYY-MM-DD 和斜杠日期转成可比较值。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
text = text.replace('/', '-').replace('T', ' ')
|
||||||
|
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d', '%Y-%m-%d %H:%M'):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text[:19], fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unfinished_order_progress_map(product_codes=None):
|
||||||
|
"""按货品编号返回国内/海外最早一笔未完结订单的详情进度。
|
||||||
|
|
||||||
|
下单时间沿用订单管理口径:订单下达供应商时间优先,其次订单日期,
|
||||||
|
最后使用计划下单时间;无日期的历史记录排在有日期记录之后。
|
||||||
|
"""
|
||||||
|
source_codes = list(product_codes) if product_codes is not None else None
|
||||||
|
requested = {
|
||||||
|
str(code or '').strip() for code in (source_codes or []) if str(code or '').strip()
|
||||||
|
}
|
||||||
|
if source_codes is not None and not requested:
|
||||||
|
return {}
|
||||||
|
rows = []
|
||||||
|
for source_order, (table, code_column) in enumerate((
|
||||||
|
('domestic_orders', '货品编号'),
|
||||||
|
('overseas_orders', '产品编码'),
|
||||||
|
)):
|
||||||
|
params = []
|
||||||
|
code_where = ''
|
||||||
|
if requested:
|
||||||
|
code_where = f" AND `{code_column}` IN ({','.join(['%s'] * len(requested))})"
|
||||||
|
params.extend(sorted(requested))
|
||||||
|
sql = f"""
|
||||||
|
SELECT `{code_column}` AS product_code, `进度`,
|
||||||
|
`订单下达供应商时间` AS supplier_ordered_at,
|
||||||
|
`日期` AS order_date, `计划下单时间` AS planned_ordered_at, id
|
||||||
|
FROM {table}
|
||||||
|
WHERE `状态`='未完结'{code_where}
|
||||||
|
"""
|
||||||
|
for row in query(sql, tuple(params)):
|
||||||
|
row['_source_order'] = source_order
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
def sort_key(row):
|
||||||
|
ordered_at = (
|
||||||
|
_order_time_value(row.get('supplier_ordered_at'))
|
||||||
|
or _order_time_value(row.get('order_date'))
|
||||||
|
or _order_time_value(row.get('planned_ordered_at'))
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
0 if ordered_at else 1,
|
||||||
|
ordered_at or datetime.max,
|
||||||
|
row.get('_source_order', 0),
|
||||||
|
row.get('id') or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.sort(key=sort_key)
|
||||||
|
result = {}
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get('product_code') or '').strip()
|
||||||
|
if code and code not in result:
|
||||||
|
result[code] = row.get('进度') or ''
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def attach_unfinished_order_progress(rows):
|
||||||
|
"""为计划预测行批量附加“在途进度”,避免逐 SKU 查询。"""
|
||||||
|
progress = unfinished_order_progress_map(row.get('product_code') for row in rows)
|
||||||
|
for row in rows:
|
||||||
|
row['在途进度'] = progress.get(str(row.get('product_code') or '').strip(), '')
|
||||||
|
return rows
|
||||||
264
static/js/app.js
264
static/js/app.js
@ -2442,19 +2442,30 @@ function aiAppendBubble(role, html) {
|
|||||||
function aiRenderTable(columns, rows, sql, totalRows, canExport) {
|
function aiRenderTable(columns, rows, sql, totalRows, canExport) {
|
||||||
if (!rows || !rows.length) return '<div style="color:#999">查询无数据</div>';
|
if (!rows || !rows.length) return '<div style="color:#999">查询无数据</div>';
|
||||||
if (canExport === undefined || canExport === null) canExport = true;
|
if (canExport === undefined || canExport === null) canExport = true;
|
||||||
var thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px">' + escHtml(c) + '</th>'; }).join('');
|
var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1);
|
||||||
var tbody = rows.slice(0, 100).map(function(r) {
|
window._aiResults = window._aiResults || {};
|
||||||
|
window._aiResults[rid] = {columns: columns, rows: rows};
|
||||||
|
var operationHistoryTable = columns.indexOf('修改内容') >= 0 && columns.indexOf('日志ID') >= 0;
|
||||||
|
var historyWidths = {'操作时间':150,'操作人':100,'板块':140,'操作':120,'字段':170,'修改内容':420,'匹配方式':130,'结果':80,'请求编号':180,'日志ID':80};
|
||||||
|
var colgroup = operationHistoryTable ? '<colgroup>' + columns.map(function(c) {
|
||||||
|
return '<col style="width:' + (historyWidths[c] || 120) + 'px">';
|
||||||
|
}).join('') + '</colgroup>' : '';
|
||||||
|
var thead = columns.map(function(c) { return '<th style="text-align:center;padding:8px 10px;white-space:nowrap;font-size:12px;overflow:hidden;text-overflow:ellipsis">' + escHtml(c) + '</th>'; }).join('');
|
||||||
|
var tbody = rows.slice(0, 100).map(function(r, rowIndex) {
|
||||||
return '<tr>' + columns.map(function(c) {
|
return '<tr>' + columns.map(function(c) {
|
||||||
var v = r[c];
|
var v = r[c];
|
||||||
if (v === null || v === undefined) v = '';
|
if (v === null || v === undefined) v = '';
|
||||||
var s = String(v);
|
var s = String(v);
|
||||||
|
if (c === '修改内容') {
|
||||||
|
var preview = s.length > 72 ? s.slice(0, 72) + '…' : s;
|
||||||
|
return '<td style="text-align:left;padding:6px 8px;font-size:12px;overflow:hidden" title="' + escHtml(s) + '">' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:6px;min-width:0;max-width:100%"><span style="display:block;flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(preview) + '</span>' +
|
||||||
|
(s.length > 72 ? '<button type="button" class="btn btn-sm" style="flex:0 0 auto;padding:0 5px;font-size:11px" onclick="aiShowResultCell(\'' + rid + '\',' + rowIndex + ',\'' + escJs(c) + '\')">查看全部</button>' : '') + '</div></td>';
|
||||||
|
}
|
||||||
if (s.length > 60) s = s.slice(0, 60) + '…';
|
if (s.length > 60) s = s.slice(0, 60) + '…';
|
||||||
return '<td style="text-align:center;padding:6px 10px;font-size:12px;white-space:nowrap">' + escHtml(s) + '</td>';
|
return '<td style="text-align:center;padding:6px 8px;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(String(v)) + '"><span style="display:block;overflow:hidden;text-overflow:ellipsis">' + escHtml(s) + '</span></td>';
|
||||||
}).join('') + '</tr>';
|
}).join('') + '</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
var rid = 'aiRes' + (window._aiResSeq = (window._aiResSeq || 0) + 1);
|
|
||||||
window._aiResults = window._aiResults || {};
|
|
||||||
window._aiResults[rid] = {columns: columns, rows: rows};
|
|
||||||
var total = totalRows || rows.length;
|
var total = totalRows || rows.length;
|
||||||
var hint = total > rows.slice(0, 100).length
|
var hint = total > rows.slice(0, 100).length
|
||||||
? '<div style="margin-top:6px;font-size:11px;color:#fa8c16">共 ' + total + ' 行,当前仅展示前 ' + rows.slice(0, 100).length + ' 行,可点「导出CSV」查看全部</div>'
|
? '<div style="margin-top:6px;font-size:11px;color:#fa8c16">共 ' + total + ' 行,当前仅展示前 ' + rows.slice(0, 100).length + ' 行,可点「导出CSV」查看全部</div>'
|
||||||
@ -2466,11 +2477,22 @@ function aiRenderTable(columns, rows, sql, totalRows, canExport) {
|
|||||||
: '<span style="font-size:11px;color:#bbb">(无导出权限)</span>') +
|
: '<span style="font-size:11px;color:#bbb">(无导出权限)</span>') +
|
||||||
'<span style="font-size:11px;color:#bbb">共 ' + total + ' 行</span>' +
|
'<span style="font-size:11px;color:#bbb">共 ' + total + ' 行</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
return toolbar + '<div style="overflow-x:auto;max-height:320px;overflow-y:auto"><table class="data-table" style="font-size:12px;min-width:100%">' +
|
var tableStyle = operationHistoryTable
|
||||||
'<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint +
|
? 'font-size:12px;table-layout:fixed;width:1570px;min-width:1570px;border-collapse:collapse'
|
||||||
|
: 'font-size:12px;min-width:100%';
|
||||||
|
return toolbar + '<div style="width:100%;overflow:auto;max-height:320px"><table class="data-table" style="' + tableStyle + '">' +
|
||||||
|
colgroup + '<thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table></div>' + hint +
|
||||||
(sql ? '<div style="margin-top:6px;font-size:11px;color:#bbb;word-break:break-all">SQL:' + escHtml(sql) + '</div>' : '');
|
(sql ? '<div style="margin-top:6px;font-size:11px;color:#bbb;word-break:break-all">SQL:' + escHtml(sql) + '</div>' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.aiShowResultCell = function(rid, rowIndex, column) {
|
||||||
|
var result = (window._aiResults || {})[rid];
|
||||||
|
var row = result && result.rows && result.rows[rowIndex];
|
||||||
|
if (!row) return;
|
||||||
|
var id = 'aiResultCell-' + Date.now();
|
||||||
|
document.body.insertAdjacentHTML('beforeend', '<div class="modal-overlay" id="' + id + '" style="display:flex;z-index:100100"><div class="modal" style="width:min(900px,92vw);max-height:86vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + escHtml(column || '完整内容') + '</h3><button class="modal-close" onclick="document.getElementById(\'' + id + '\').remove()">×</button></div><div class="modal-body" style="overflow:auto"><div style="font-size:12px;color:#777;margin-bottom:8px">' + escHtml(row['操作时间'] || '') + ' · ' + escHtml(row['操作人'] || '') + ' · ' + escHtml(row['板块'] || '') + '</div><pre style="margin:0;padding:14px;background:#f7f8fa;border:1px solid #e5e7eb;border-radius:4px;white-space:pre-wrap;word-break:break-word;font:12px/1.7 inherit">' + escHtml(row[column] || '') + '</pre></div><div class="modal-footer"><button class="btn btn-primary" onclick="document.getElementById(\'' + id + '\').remove()">关闭</button></div></div></div>');
|
||||||
|
};
|
||||||
|
|
||||||
// 导出 CSV(带 BOM,Excel 直接打开不乱码)
|
// 导出 CSV(带 BOM,Excel 直接打开不乱码)
|
||||||
window.aiExportCsv = function(rid) {
|
window.aiExportCsv = function(rid) {
|
||||||
var res = (window._aiResults || {})[rid];
|
var res = (window._aiResults || {})[rid];
|
||||||
@ -7627,12 +7649,7 @@ function loadInventoryBoardPage(container) {
|
|||||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
<h1 style="margin:0">库存数据看板</h1>
|
<h1 style="margin:0">库存数据看板</h1>
|
||||||
<label style="font-size:13px;color:#666;margin-left:auto">月份
|
<label style="font-size:13px;color:#666;margin-left:auto">月份
|
||||||
<select id="invBoardMonth" autocomplete="off" aria-label="选择月份" style="width:150px;height:36px;box-sizing:border-box;padding:6px 10px;border:1px solid var(--border,#d9d9d9);border-radius:6px;font-size:15px;font-weight:600;color:#333;background:#fff">
|
<select id="invBoardMonth" autocomplete="off" aria-label="选择月份" style="width:150px;height:36px;box-sizing:border-box;padding:6px 10px;border:1px solid var(--border,#d9d9d9);border-radius:6px;font-size:15px;font-weight:600;color:#333;background:#fff"></select>
|
||||||
<option value="2026-08">2026-08</option>
|
|
||||||
<option value="2026-07">2026-07</option>
|
|
||||||
<option value="2026-06">2026-06</option>
|
|
||||||
<option value="2026-05">2026-05</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-sm" onclick="reloadInventoryBoard(true)">重新计算</button>
|
<button class="btn btn-sm" onclick="reloadInventoryBoard(true)">重新计算</button>
|
||||||
<button class="btn btn-sm btn-primary" onclick="openInventoryManualModal()">人工新建</button>
|
<button class="btn btn-sm btn-primary" onclick="openInventoryManualModal()">人工新建</button>
|
||||||
@ -7647,8 +7664,13 @@ function loadInventoryBoardPage(container) {
|
|||||||
|
|
||||||
var monthInput = document.getElementById('invBoardMonth');
|
var monthInput = document.getElementById('invBoardMonth');
|
||||||
if (monthInput) {
|
if (monthInput) {
|
||||||
monthInput.value = '2026-08';
|
var initialMonth = window._inventoryBoardSelectedMonth || inventoryBoardCurrentMonth();
|
||||||
monthInput.onchange = function() { reloadInventoryBoard(false); };
|
monthInput.innerHTML = inventoryBoardMonthOptions(initialMonth, false);
|
||||||
|
monthInput.value = initialMonth;
|
||||||
|
monthInput.onchange = function() {
|
||||||
|
window._inventoryBoardSelectedMonth = this.value;
|
||||||
|
reloadInventoryBoard(false);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
fetch('/api/dashboard/inventory-board?month=' + encodeURIComponent(monthInput ? monthInput.value : ''), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
fetch('/api/dashboard/inventory-board?month=' + encodeURIComponent(monthInput ? monthInput.value : ''), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@ -7664,9 +7686,36 @@ function loadInventoryBoardPage(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inventoryBoardCurrentMonth() {
|
||||||
|
var parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit'
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
var year = '', month = '';
|
||||||
|
parts.forEach(function(part) {
|
||||||
|
if (part.type === 'year') year = part.value;
|
||||||
|
if (part.type === 'month') month = part.value;
|
||||||
|
});
|
||||||
|
return year + '-' + month;
|
||||||
|
}
|
||||||
|
function inventoryBoardMonthOptions(selected, includePlaceholder) {
|
||||||
|
var current = inventoryBoardCurrentMonth();
|
||||||
|
var cursor = new Date(Number(current.slice(0, 4)), Number(current.slice(5, 7)) - 1, 1);
|
||||||
|
var earliest = new Date(2025, 0, 1);
|
||||||
|
var options = includePlaceholder ? '<option value="">请选择月份</option>' : '';
|
||||||
|
var values = [];
|
||||||
|
while (cursor >= earliest) {
|
||||||
|
values.push(cursor.getFullYear() + '-' + String(cursor.getMonth() + 1).padStart(2, '0'));
|
||||||
|
cursor.setMonth(cursor.getMonth() - 1);
|
||||||
|
}
|
||||||
|
if (selected && values.indexOf(selected) < 0) values.unshift(selected);
|
||||||
|
values.forEach(function(value) {
|
||||||
|
options += '<option value="' + value + '"' + (value === selected ? ' selected' : '') + '>' + value + '</option>';
|
||||||
|
});
|
||||||
|
return options;
|
||||||
|
}
|
||||||
function inventoryBoardMonth() {
|
function inventoryBoardMonth() {
|
||||||
var el = document.getElementById('invBoardMonth');
|
var el = document.getElementById('invBoardMonth');
|
||||||
return (el && el.value) || new Date().toISOString().slice(0, 7);
|
return (el && el.value) || inventoryBoardCurrentMonth();
|
||||||
}
|
}
|
||||||
function reloadInventoryBoard(force) {
|
function reloadInventoryBoard(force) {
|
||||||
var month = inventoryBoardMonth();
|
var month = inventoryBoardMonth();
|
||||||
@ -7707,21 +7756,12 @@ function scheduleInventoryBoardFreshPoll(d) {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
function inventoryManualMonthOptions() {
|
function inventoryManualMonthOptions() {
|
||||||
var options = '<option value="">请选择月份</option>';
|
return inventoryBoardMonthOptions(inventoryBoardMonth(), true);
|
||||||
for (var year = 2025; year <= 2026; year++) {
|
|
||||||
var startMonth = year === 2025 ? 1 : 1;
|
|
||||||
var endMonth = year === 2026 ? 5 : 12;
|
|
||||||
for (var month = startMonth; month <= endMonth; month++) {
|
|
||||||
var value = year + '-' + String(month).padStart(2, '0');
|
|
||||||
options += '<option value="' + value + '">' + value + '</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
function openInventoryManualModal() {
|
function openInventoryManualModal() {
|
||||||
var groups = [
|
var groups = [
|
||||||
['库存金额',[['all_amount','全部仓库成品'],['wh01_amount','蝶雅仓成品'],['wh15_amount','加工厂仓成品'],['cloud_amount','云仓成品'],['overseas_amount','海外仓成品'],['domestic_self_amount','国内平台自营仓成品']]],
|
['库存金额',[['all_amount','全部仓库成品'],['wh01_amount','蝶雅仓成品'],['wh15_amount','加工厂仓成品'],['cloud_amount','云仓成品'],['overseas_amount','海外仓成品'],['domestic_self_amount','国内平台自营仓成品']]],
|
||||||
['经营效率',[['inventory_total_amount','库存总金额'],['monthly_ship_cost','月销货成本'],['monthly_return_cost','月退货成本'],['monthly_refund_amount','月退款金额'],['return_rate','退货率'],['monthly_purchase_inbound','月采购入库'],['turnover_rate','月库存周转率'],['turnover_days','库存周转天数'],['active_sku','动销SKU数量(大于30)'],['active_sku_100','动销SKU数量(>100天)'],['total_sku','总SKU数量(库存>=1)'],['total_sku_200','总SKU数量(库存>=200)'],['active_rate','动销率(>30)'],['active_rate_100','动销率(>100)']]],
|
['经营效率',[['inventory_total_amount','库存总金额'],['monthly_ship_cost','月销货成本'],['monthly_return_cost','月退货成本'],['monthly_refund_amount','月退款金额'],['return_rate','退货率'],['monthly_purchase_inbound','月采购入库'],['turnover_rate','月库存周转率'],['turnover_days','库存周转天数'],['active_sku','动销SKU数量(≥30件)'],['active_sku_100','动销SKU数量(≥100件)'],['total_sku','总SKU数量(库存≥1)'],['total_sku_200','总SKU数量(库存≥200)'],['active_rate','动销率(≥30件)'],['active_rate_100','动销率(≥100件)']]],
|
||||||
['呆滞治理',[['slow_amount','呆滞库存金额'],['slow_ratio','呆滞库存比例']]],
|
['呆滞治理',[['slow_amount','呆滞库存金额'],['slow_ratio','呆滞库存比例']]],
|
||||||
['缺货监控',[['stockout_sku_domestic','缺货SKU次数(国内)'],['stockout_sku_snowwolf','缺货SKU次数(国际-雪狼)'],['stockout_sku_lingdong','缺货SKU次数(国际-灵动)'],['stockout_sku_chunyue','缺货SKU次数(国际-春跃)'],['stockout_rate','缺货率'],['top100_stockout_rate','销量TOP100缺货率'],['new_stockout_rate','新品缺货率'],['old_stockout_rate','老品缺货率']]]
|
['缺货监控',[['stockout_sku_domestic','缺货SKU次数(国内)'],['stockout_sku_snowwolf','缺货SKU次数(国际-雪狼)'],['stockout_sku_lingdong','缺货SKU次数(国际-灵动)'],['stockout_sku_chunyue','缺货SKU次数(国际-春跃)'],['stockout_rate','缺货率'],['top100_stockout_rate','销量TOP100缺货率'],['new_stockout_rate','新品缺货率'],['old_stockout_rate','老品缺货率']]]
|
||||||
];
|
];
|
||||||
@ -7800,10 +7840,10 @@ function renderInventoryBoard(d) {
|
|||||||
'monthly_return_cost': {label:'月退货成本', unit:'万元', icon:'↩️', color:'#fa8c16'},
|
'monthly_return_cost': {label:'月退货成本', unit:'万元', icon:'↩️', color:'#fa8c16'},
|
||||||
'monthly_refund_amount': {label:'月退款金额', unit:'万元', icon:'💸', color:'#eb2f96'},
|
'monthly_refund_amount': {label:'月退款金额', unit:'万元', icon:'💸', color:'#eb2f96'},
|
||||||
'monthly_purchase_inbound': {label:'月采购入库', unit:'件', icon:'🚚', color:'#13c2c2'},
|
'monthly_purchase_inbound': {label:'月采购入库', unit:'件', icon:'🚚', color:'#13c2c2'},
|
||||||
'active_sku': {label:'动销SKU数量(大于30)', unit:'个', icon:'📊', color:'#52c41a'},
|
'active_sku': {label:'动销SKU数量(≥30件)', unit:'个', icon:'📊', color:'#52c41a'},
|
||||||
'active_sku_100': {label:'动销SKU数量(>100天)', unit:'个', icon:'📈', color:'#a0d911'},
|
'active_sku_100': {label:'动销SKU数量(≥100件)', unit:'个', icon:'📈', color:'#a0d911'},
|
||||||
'total_sku': {label:'总SKU数量(库存>=1)', unit:'个', icon:'🗃️', color:'#1890ff'},
|
'total_sku': {label:'总SKU数量(库存≥1)', unit:'个', icon:'🗃️', color:'#1890ff'},
|
||||||
'total_sku_200': {label:'总SKU数量(库存>=200)', unit:'个', icon:'📚', color:'#2f54eb'},
|
'total_sku_200': {label:'总SKU数量(库存≥200)', unit:'个', icon:'📚', color:'#2f54eb'},
|
||||||
'slow_amount': {label:'呆滞库存金额', unit:'万元', icon:'🕸️', color:'#faad14'},
|
'slow_amount': {label:'呆滞库存金额', unit:'万元', icon:'🕸️', color:'#faad14'},
|
||||||
'stockout_sku_domestic': {label:'缺货SKU次数(国内)', unit:'次', icon:'🇨🇳', color:'#f5222d'},
|
'stockout_sku_domestic': {label:'缺货SKU次数(国内)', unit:'次', icon:'🇨🇳', color:'#f5222d'},
|
||||||
'stockout_sku_snowwolf': {label:'缺货SKU次数(国际-雪狼)', unit:'次', icon:'🐺', color:'#722ed1'},
|
'stockout_sku_snowwolf': {label:'缺货SKU次数(国际-雪狼)', unit:'次', icon:'🐺', color:'#722ed1'},
|
||||||
@ -7911,12 +7951,12 @@ function renderInventoryBoard(d) {
|
|||||||
{key:'monthly_purchase_inbound', label:'月采购入库', value:num(d.monthly_purchase_inbound), unit:'件', icon:'🚚', color:'#13c2c2'},
|
{key:'monthly_purchase_inbound', label:'月采购入库', value:num(d.monthly_purchase_inbound), unit:'件', icon:'🚚', color:'#13c2c2'},
|
||||||
{key:'turnover_rate', label:'月库存周转率', value:has(d.turnover_rate) ? pct(d.turnover_rate * 100) : '—', unit:'', icon:'🔄', color:'#722ed1'},
|
{key:'turnover_rate', label:'月库存周转率', value:has(d.turnover_rate) ? pct(d.turnover_rate * 100) : '—', unit:'', icon:'🔄', color:'#722ed1'},
|
||||||
{key:'turnover_days', label:'库存周转天数', value:has(d.turnover_days) ? d.turnover_days.toFixed(1) : '—', unit:'天', icon:'⏱️', color:'#2f54eb'},
|
{key:'turnover_days', label:'库存周转天数', value:has(d.turnover_days) ? d.turnover_days.toFixed(1) : '—', unit:'天', icon:'⏱️', color:'#2f54eb'},
|
||||||
{key:'active_sku', label:'动销SKU数量(大于30)', value:num(d.active_sku_30), unit:'个', icon:'📊', color:'#52c41a'},
|
{key:'active_sku', label:'动销SKU数量(≥30件)', value:num(d.active_sku_30), unit:'个', icon:'📊', color:'#52c41a'},
|
||||||
{key:'active_sku_100', label:'动销SKU数量(>100天)', value:num(d.active_sku_100), unit:'个', icon:'📈', color:'#a0d911'},
|
{key:'active_sku_100', label:'动销SKU数量(≥100件)', value:num(d.active_sku_100), unit:'个', icon:'📈', color:'#a0d911'},
|
||||||
{key:'total_sku', label:'总SKU数量(库存>=1)', value:num(d.total_sku), unit:'个', icon:'🗃️', color:'#1890ff'},
|
{key:'total_sku', label:'总SKU数量(库存≥1)', value:num(d.total_sku), unit:'个', icon:'🗃️', color:'#1890ff'},
|
||||||
{key:'total_sku_200', label:'总SKU数量(库存>=200)', value:num(d.total_sku_200), unit:'个', icon:'📚', color:'#2f54eb'},
|
{key:'total_sku_200', label:'总SKU数量(库存≥200)', value:num(d.total_sku_200), unit:'个', icon:'📚', color:'#2f54eb'},
|
||||||
{key:'active_rate', label:'动销率(>30)', value:pct(d.active_rate), unit:'', icon:'📈', color:'#a0d911'},
|
{key:'active_rate', label:'动销率(≥30件)', value:pct(d.active_rate), unit:'', icon:'📈', color:'#a0d911'},
|
||||||
{key:'active_rate_100', label:'动销率(>100)', value:pct(d.active_rate_100), unit:'', icon:'📊', color:'#13c2c2'}
|
{key:'active_rate_100', label:'动销率(≥100件)', value:pct(d.active_rate_100), unit:'', icon:'📊', color:'#13c2c2'}
|
||||||
];
|
];
|
||||||
var sec2Html = '';
|
var sec2Html = '';
|
||||||
cards2.forEach(function(s) {
|
cards2.forEach(function(s) {
|
||||||
@ -7974,8 +8014,8 @@ function saveInventoryExternal(key) {
|
|||||||
var val = (input.value || '').trim();
|
var val = (input.value || '').trim();
|
||||||
var label = {'monthly_ship_cost':'月销货成本','monthly_return_cost':'月退货成本',
|
var label = {'monthly_ship_cost':'月销货成本','monthly_return_cost':'月退货成本',
|
||||||
'monthly_refund_amount':'月退款金额','monthly_purchase_inbound':'月采购入库',
|
'monthly_refund_amount':'月退款金额','monthly_purchase_inbound':'月采购入库',
|
||||||
'active_sku':'动销SKU数量(大于30)','active_sku_100':'动销SKU数量(>100天)',
|
'active_sku':'动销SKU数量(≥30件)','active_sku_100':'动销SKU数量(≥100件)',
|
||||||
'total_sku':'总SKU数量(库存>=1)','total_sku_200':'总SKU数量(库存>=200)',
|
'total_sku':'总SKU数量(库存≥1)','total_sku_200':'总SKU数量(库存≥200)',
|
||||||
'slow_amount':'呆滞库存金额'}[key] || key;
|
'slow_amount':'呆滞库存金额'}[key] || key;
|
||||||
// 空值 = 清空外部值回退内部计算
|
// 空值 = 清空外部值回退内部计算
|
||||||
if (val !== '' && isNaN(parseFloat(val))) {
|
if (val !== '' && isNaN(parseFloat(val))) {
|
||||||
@ -8016,14 +8056,13 @@ function saveInventoryExternal(key) {
|
|||||||
var WH_INFO = {
|
var WH_INFO = {
|
||||||
'all': {
|
'all': {
|
||||||
title: '全部仓库成品',
|
title: '全部仓库成品',
|
||||||
desc: '代表全部序号仓库数据之和',
|
desc: '仓库信息管理中全部启用仓库的成品库存之和(排除20号原材料仓)'
|
||||||
whs: '1、2、3、4、5、6、7、8、9、10、11、12、13、14、15、16、20、24、25、26、27、29号仓'
|
|
||||||
},
|
},
|
||||||
'wh01': {title: '蝶雅仓成品', desc: '代表 1 号仓数据', whs: '1号仓(蝶雅广州新仓)'},
|
'wh01': {title: '蝶雅仓成品', desc: '代表 1 号仓数据', whs: '1号仓(蝶雅广州新仓)'},
|
||||||
'wh15': {title: '加工厂仓成品', desc: '代表 15 号仓数据', whs: '15号仓(加工厂储存仓)'},
|
'wh15': {title: '加工厂仓成品', desc: '代表 15 号仓数据', whs: '15号仓(加工厂储存仓)'},
|
||||||
'cloud': {title: '云仓成品', desc: '代表以下仓库数据之和', whs: '1、2、3、4、5、6、7、8、16、24、27、29号仓'},
|
'cloud': {title: '云仓成品', desc: '仓库信息管理中已启用且勾选“云仓”属性的仓库之和(排除1号仓)'},
|
||||||
'overseas': {title: '海外仓成品', desc: '代表以下仓库数据之和', whs: '9、10、11、12、25、26号仓'},
|
'overseas': {title: '海外仓成品', desc: '仓库信息管理中已启用且勾选“海外仓”属性的仓库之和'},
|
||||||
'domestic_self': {title: '国内平台自营仓成品', desc: '代表以下仓库数据之和', whs: '13、14号仓'}
|
'domestic_self': {title: '国内平台自营仓成品', desc: '已启用的国内平台自营仓之和(13、14号仓及名称含“自营”的非海外仓,排除1号仓)'}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示)
|
// 经营效率板块:卡片 → 计算逻辑说明(眼睛图标悬浮提示)
|
||||||
@ -8046,12 +8085,12 @@ var CALC_INFO = {
|
|||||||
'monthly_purchase_inbound': {title: '月采购入库', desc: '取自财务数据报表'},
|
'monthly_purchase_inbound': {title: '月采购入库', desc: '取自财务数据报表'},
|
||||||
'turnover_rate': {title: '月库存周转率', desc: '月销货成本 ÷ 库存总金额'},
|
'turnover_rate': {title: '月库存周转率', desc: '月销货成本 ÷ 库存总金额'},
|
||||||
'turnover_days': {title: '库存周转天数', desc: '当月天数 ÷ 月库存周转率'},
|
'turnover_days': {title: '库存周转天数', desc: '当月天数 ÷ 月库存周转率'},
|
||||||
'active_sku': {title: '动销SKU数量(大于30)', desc: '当月至少销售发货 30 件的 SKU 数量'},
|
'active_sku': {title: '动销SKU数量(≥30件)', desc: '所选自然月累计销售发货达到 30 件的 SKU 数量'},
|
||||||
'active_sku_100': {title: '动销SKU数量(>100天)', desc: '当月至少销售发货 100 件的 SKU 数量'},
|
'active_sku_100': {title: '动销SKU数量(≥100件)', desc: '所选自然月累计销售发货达到 100 件的 SKU 数量'},
|
||||||
'total_sku': {title: '总SKU数量(库存>=1)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 1)'},
|
'total_sku': {title: '总SKU数量(库存≥1)', desc: '月末启用仓库中成品库存合计达到 1 件的去重 SKU 数'},
|
||||||
'total_sku_200': {title: '总SKU数量(库存>=200)', desc: '月末所有在仓 SKU 库存数量(库存 ≥ 200)'},
|
'total_sku_200': {title: '总SKU数量(库存≥200)', desc: '月末启用仓库中成品库存合计达到 200 件的去重 SKU 数'},
|
||||||
'active_rate': {title: '动销率(>30)', desc: '动销SKU数量(>30)÷ 总SKU数量(库存>=1)× 100%'},
|
'active_rate': {title: '动销率(≥30件)', desc: '动销SKU数量(≥30件)÷ 总SKU数量(库存≥1)× 100%'},
|
||||||
'active_rate_100': {title: '动销率(>100)', desc: '动销SKU数量(>100)÷ 总SKU数量(库存>=1)× 100%'}
|
'active_rate_100': {title: '动销率(≥100件)', desc: '动销SKU数量(≥100件)÷ 总SKU数量(库存≥1)× 100%'}
|
||||||
};
|
};
|
||||||
|
|
||||||
function showWhTip(key, el) {
|
function showWhTip(key, el) {
|
||||||
@ -14329,6 +14368,7 @@ const PLAN_COLUMNS = [
|
|||||||
{field: '新旧品', label: '新旧品', defaultShow: true},
|
{field: '新旧品', label: '新旧品', defaultShow: true},
|
||||||
{field: 'bom', label: '下单清单', defaultShow: true, filterable: false},
|
{field: 'bom', label: '下单清单', defaultShow: true, filterable: false},
|
||||||
{field: '产品分级', label: '产品分级', defaultShow: true},
|
{field: '产品分级', label: '产品分级', defaultShow: true},
|
||||||
|
{field: '在途进度', label: '在途进度', defaultShow: true},
|
||||||
{field: '昨日销量', label: '昨日销量', defaultShow: true},
|
{field: '昨日销量', label: '昨日销量', defaultShow: true},
|
||||||
{field: '云仓昨日销量', label: '云仓昨日销量', defaultShow: false},
|
{field: '云仓昨日销量', label: '云仓昨日销量', defaultShow: false},
|
||||||
{field: '外仓昨日销量', label: '外仓昨日销量', defaultShow: false},
|
{field: '外仓昨日销量', label: '外仓昨日销量', defaultShow: false},
|
||||||
@ -14425,7 +14465,7 @@ const PLAN_FILTER_OPTIONS = {
|
|||||||
|
|
||||||
// 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询)
|
// 动态筛选字段中排除的计算字段(这些值来自后台计算,不可直接查询)
|
||||||
const PLAN_FILTER_EXCLUDE_FIELDS = {
|
const PLAN_FILTER_EXCLUDE_FIELDS = {
|
||||||
'产品分级':1, '昨日销量':1, '云仓昨日销量':1, '外仓昨日销量':1, '销售趋势':1, '库存':1, '云仓库存':1, '外仓库存':1, '加工厂库存':1,
|
'产品分级':1, '在途进度':1, '昨日销量':1, '云仓昨日销量':1, '外仓昨日销量':1, '销售趋势':1, '库存':1, '云仓库存':1, '外仓库存':1, '加工厂库存':1,
|
||||||
'云仓可分配库存':1, '外仓可分配库存':1, '是否调拨':1,
|
'云仓可分配库存':1, '外仓可分配库存':1, '是否调拨':1,
|
||||||
'生产在途':1, '采购在途':1, '日销量预测':1, '云仓日销量预测':1, '外仓日销量预测':1, '库存健康指数':1, '风险把控':1,
|
'生产在途':1, '采购在途':1, '日销量预测':1, '云仓日销量预测':1, '外仓日销量预测':1, '库存健康指数':1, '风险把控':1,
|
||||||
'库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1,
|
'库存可支撑天数':1, '库存工厂可支撑天数':1, '加工厂库存支撑天数':1,
|
||||||
@ -15185,7 +15225,7 @@ function renderPlanTable(rows) {
|
|||||||
var pcode = (r.product_code || '');
|
var pcode = (r.product_code || '');
|
||||||
var pname = r.product_name || '';
|
var pname = r.product_name || '';
|
||||||
var suggQty = r['建议下单数量'] || 0;
|
var suggQty = r['建议下单数量'] || 0;
|
||||||
var actQty = r['实际下单数量'] || 0;
|
var actQty = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : (r['实际下单数量'] || 0);
|
||||||
var remark = r['下单备注'] || '';
|
var remark = r['下单备注'] || '';
|
||||||
return '<button class="btn btn-sm btn-outline" onclick="planToggleBom(' + inlineJsString(pcode) + ',' + inlineJsString(pname) + ',' + suggQty + ',' + actQty + ',' + inlineJsString(remark) + ')" title="查看下单信息" style="font-size:12px;padding:2px 10px">📋 下单信息</button>';
|
return '<button class="btn btn-sm btn-outline" onclick="planToggleBom(' + inlineJsString(pcode) + ',' + inlineJsString(pname) + ',' + suggQty + ',' + actQty + ',' + inlineJsString(remark) + ')" title="查看下单信息" style="font-size:12px;padding:2px 10px">📋 下单信息</button>';
|
||||||
}
|
}
|
||||||
@ -15314,7 +15354,8 @@ function renderPlanTable(rows) {
|
|||||||
return '<span style="font-weight:600;color:#d4380d">' + val + '</span>';
|
return '<span style="font-weight:600;color:#d4380d">' + val + '</span>';
|
||||||
}
|
}
|
||||||
if (c.field === '正式下单数量') {
|
if (c.field === '正式下单数量') {
|
||||||
var currentVal = parseInt(r['实际下单数量']) || 0;
|
var savedFormalQty = r['正式下单数量'];
|
||||||
|
var currentVal = parseInt(savedFormalQty !== undefined && savedFormalQty !== null ? savedFormalQty : r['实际下单数量']) || 0;
|
||||||
var code = r.product_code || '';
|
var code = r.product_code || '';
|
||||||
var codeHtml = escHtml(code);
|
var codeHtml = escHtml(code);
|
||||||
var canEditFormal = window._canEditField && window._canEditField('planning', '正式下单数量');
|
var canEditFormal = window._canEditField && window._canEditField('planning', '正式下单数量');
|
||||||
@ -15361,7 +15402,8 @@ function renderPlanTable(rows) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
var code = r.product_code || '';
|
var code = r.product_code || '';
|
||||||
// 序列化下单所需数据
|
// 序列化下单所需数据
|
||||||
var formalQty = parseInt(r['实际下单数量']) || 0;
|
var formalQtyValue = r['正式下单数量'] !== undefined && r['正式下单数量'] !== null ? r['正式下单数量'] : r['实际下单数量'];
|
||||||
|
var formalQty = parseInt(formalQtyValue) || 0;
|
||||||
var suggestedQty = parseInt(r['建议下单数量']) || 0;
|
var suggestedQty = parseInt(r['建议下单数量']) || 0;
|
||||||
var orderData = JSON.stringify({
|
var orderData = JSON.stringify({
|
||||||
product_code: r.product_code || '',
|
product_code: r.product_code || '',
|
||||||
@ -18953,7 +18995,7 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
|
|||||||
if (m.material_code === '成品_' + productCode) {
|
if (m.material_code === '成品_' + productCode) {
|
||||||
// 修复:允许恢复为0(用户手动设0表示不下单)
|
// 修复:允许恢复为0(用户手动设0表示不下单)
|
||||||
if (m.actual_quantity >= 0) actualQty = m.actual_quantity;
|
if (m.actual_quantity >= 0) actualQty = m.actual_quantity;
|
||||||
if (m.remark) orderRemark = m.remark;
|
if (m.remark !== undefined && m.remark !== null) orderRemark = m.remark;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -18965,6 +19007,23 @@ function planToggleBom(productCode, productName, suggestedQty, actualQty, orderR
|
|||||||
alert('❌ 获取物料清单失败');
|
alert('❌ 获取物料清单失败');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var savedOrder = res.data.pending_order;
|
||||||
|
if (savedOrder && (!window._pendingBomData || !window._pendingBomData[productCode])) {
|
||||||
|
var savedItems = savedOrder.bom_items;
|
||||||
|
if (typeof savedItems === 'string') {
|
||||||
|
try { savedItems = JSON.parse(savedItems); } catch (e) { savedItems = []; }
|
||||||
|
}
|
||||||
|
if (Array.isArray(savedItems)) {
|
||||||
|
if (!window._pendingBomData) window._pendingBomData = {};
|
||||||
|
window._pendingBomData[productCode] = savedItems;
|
||||||
|
}
|
||||||
|
if (savedOrder.formal_order_qty !== undefined && savedOrder.formal_order_qty !== null) {
|
||||||
|
actualQty = parseInt(savedOrder.formal_order_qty, 10) || 0;
|
||||||
|
}
|
||||||
|
if (savedOrder.remark !== undefined && savedOrder.remark !== null) {
|
||||||
|
orderRemark = savedOrder.remark;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 没有绑定包材时仍显示成品下单信息,包材区域由下方统一展示暂无数据。
|
// 没有绑定包材时仍显示成品下单信息,包材区域由下方统一展示暂无数据。
|
||||||
var bom = res.data.bom_materials || [];
|
var bom = res.data.bom_materials || [];
|
||||||
// 获取正式下单数量(从计划预测页面元素获取)
|
// 获取正式下单数量(从计划预测页面元素获取)
|
||||||
@ -21819,6 +21878,10 @@ function showOrderDetail(encodedStr, title) {
|
|||||||
data.arrival_batches.forEach(function(b, idx) {
|
data.arrival_batches.forEach(function(b, idx) {
|
||||||
var qty = parseInt(b.arrival_qty) || 0;
|
var qty = parseInt(b.arrival_qty) || 0;
|
||||||
var defectiveQty = parseInt(b.defective_qty) || 0;
|
var defectiveQty = parseInt(b.defective_qty) || 0;
|
||||||
|
var dateFilled = Number(b.arrival_date_filled) === 1;
|
||||||
|
var qtyFilled = Number(b.arrival_qty_filled) === 1;
|
||||||
|
var defectiveFilled = Number(b.defective_qty_filled) === 1;
|
||||||
|
var allowBlankFill = orderType === 'packaging';
|
||||||
totalQty += qty - defectiveQty;
|
totalQty += qty - defectiveQty;
|
||||||
totalDefectiveQty += defectiveQty;
|
totalDefectiveQty += defectiveQty;
|
||||||
var dateVal = (b.arrival_date || '').substring(0, 10);
|
var dateVal = (b.arrival_date || '').substring(0, 10);
|
||||||
@ -21827,9 +21890,9 @@ function showOrderDetail(encodedStr, title) {
|
|||||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + (idx + 1) + '</td>';
|
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + (idx + 1) + '</td>';
|
||||||
abHtml += '<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operator_name || '-') + '</td>';
|
abHtml += '<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operator_name || '-') + '</td>';
|
||||||
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operated_at || '-') + '</td>';
|
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(b.operated_at || '-') + '</td>';
|
||||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-date" value="' + dateVal + '" data-orig="' + dateVal + '"><span>' + escHtml(dateVal || '-') + '</span></td>' +
|
abHtml += renderArrivalBatchStoredCell('date', 'batch-date', dateVal, dateFilled, allowBlankFill, '到货日期') +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' +
|
renderArrivalBatchStoredCell('number', 'batch-qty', qtyFilled ? qty : '', qtyFilled, allowBlankFill, '到货数量', true) +
|
||||||
(showDefectiveQty ? '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-defective-qty" value="' + defectiveQty + '" data-orig="' + defectiveQty + '"><span>' + defectiveQty + '</span></td>' : '') +
|
(showDefectiveQty ? renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveFilled ? defectiveQty : '', defectiveFilled, true, '不良品数量', false) : '') +
|
||||||
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
|
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
|
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
|
||||||
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(b.remark || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(b.remark || '') + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
|
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(b.remark || '') + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(b.remark || '') + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
|
||||||
@ -21846,7 +21909,7 @@ function showOrderDetail(encodedStr, title) {
|
|||||||
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>';
|
abHtml += '<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>';
|
||||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;color:#999;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;color:#999;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" placeholder="数量" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" placeholder="数量" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||||
if (showDefectiveQty) abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-defective-qty" placeholder="不良品" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
if (showDefectiveQty) abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" class="batch-defective-qty" placeholder="不良品" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>';
|
||||||
abHtml += '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;color:#ccc">-</td>';
|
abHtml += '<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;color:#ccc">-</td>';
|
||||||
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" placeholder="备注" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveNewArrivalBatch(this,' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button></div></td>';
|
abHtml += '<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" placeholder="备注" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveNewArrivalBatch(this,' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button></div></td>';
|
||||||
abHtml += '</tr>';
|
abHtml += '</tr>';
|
||||||
@ -21890,6 +21953,16 @@ function showOrderDetail(encodedStr, title) {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderArrivalBatchStoredCell(type, className, value, filled, allowBlankFill, placeholder, nonNegative) {
|
||||||
|
var style = 'border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap';
|
||||||
|
var safeValue = value === null || value === undefined ? '' : String(value);
|
||||||
|
if (allowBlankFill && !filled) {
|
||||||
|
return '<td style="' + style + '"><input type="' + type + '" class="' + className + '" value="" data-orig="" placeholder="' + placeholder + '"' +
|
||||||
|
(nonNegative ? ' min="0"' : '') + ' oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:80%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #1890ff;border-radius:3px;padding:4px 6px;background:#fffbe6"></td>';
|
||||||
|
}
|
||||||
|
return '<td style="' + style + '"><input type="hidden" class="' + className + '" value="' + escHtml(safeValue) + '" data-orig="' + escHtml(safeValue) + '"><span>' + escHtml(safeValue || '-') + '</span></td>';
|
||||||
|
}
|
||||||
|
|
||||||
function appendArrivalBatchEditingRow(btn) {
|
function appendArrivalBatchEditingRow(btn) {
|
||||||
var overlay = btn.closest('.modal-overlay');
|
var overlay = btn.closest('.modal-overlay');
|
||||||
if (!overlay) {
|
if (!overlay) {
|
||||||
@ -21923,7 +21996,7 @@ function appendArrivalBatchEditingRow(btn) {
|
|||||||
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>' +
|
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;color:#ccc">-</td>' +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>' +
|
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="date" class="batch-date" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px"></td>' +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="数量"></td>' +
|
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="数量"></td>' +
|
||||||
(orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" min="0" class="batch-defective-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="不良品"></td>' : '') +
|
(orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:4px 6px;text-align:center"><input type="number" class="batch-defective-qty" oninput="recalcArrivalUnfinished()" onkeydown="batchQtyKeydown(event)" style="width:66%;box-sizing:border-box;font-size:12px;text-align:center;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px" placeholder="不良品"></td>' : '') +
|
||||||
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:4px 6px"></td>' +
|
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:4px 6px"></td>' +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
|
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center">' +
|
||||||
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical" placeholder="备注"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
|
'<span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical" placeholder="备注"></textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span>' +
|
||||||
@ -22021,11 +22094,15 @@ function saveNewArrivalBatch(btn, orderId, orderType) {
|
|||||||
var tr = btn.closest('tr');
|
var tr = btn.closest('tr');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
var date = tr.querySelector('.batch-date')?.value || '';
|
var date = tr.querySelector('.batch-date')?.value || '';
|
||||||
var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
|
var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
|
||||||
var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
|
var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
|
||||||
|
var qty = qtyRaw === '' ? '' : parseInt(qtyRaw, 10);
|
||||||
|
var defectiveQty = defectiveRaw === '' ? '' : parseInt(defectiveRaw, 10);
|
||||||
var remark = tr.querySelector('.batch-remark')?.value || '';
|
var remark = tr.querySelector('.batch-remark')?.value || '';
|
||||||
if (!date && !qty) { alert('请输入到货日期或数量'); return; }
|
if (!date && qtyRaw === '') { alert('请输入到货日期或数量'); return; }
|
||||||
if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; }
|
if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; }
|
||||||
|
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
|
||||||
|
if (orderType !== 'packaging' && defectiveRaw !== '' && (defectiveQty < 0 || defectiveQty > (qty || 0))) { alert('不良品数量不能为负数或大于到货数量'); return; }
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '⋯';
|
btn.textContent = '⋯';
|
||||||
fetch('/api/domestic/orders/add_batch', {
|
fetch('/api/domestic/orders/add_batch', {
|
||||||
@ -22044,9 +22121,9 @@ function saveNewArrivalBatch(btn, orderId, orderType) {
|
|||||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + batchNum + '</td>' +
|
'<td style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center;font-weight:700">' + batchNum + '</td>' +
|
||||||
'<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operator_name || '-') + '</td>' +
|
'<td class="batch-operator" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operator_name || '-') + '</td>' +
|
||||||
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operated_at || '-') + '</td>' +
|
'<td class="batch-operated-at" style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap">' + escHtml(res.data && res.data.operated_at || '-') + '</td>' +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-date" value="' + date + '" data-orig="' + date + '"><span>' + escHtml(date || '-') + '</span></td>' +
|
renderArrivalBatchStoredCell('date', 'batch-date', date, !!(res.data && res.data.arrival_date_filled), orderType === 'packaging', '到货日期') +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-qty" value="' + qty + '" data-orig="' + qty + '"><span>' + qty + '</span></td>' +
|
renderArrivalBatchStoredCell('number', 'batch-qty', qty, !!(res.data && res.data.arrival_qty_filled), orderType === 'packaging', '到货数量', true) +
|
||||||
(orderType === 'packaging' ? '<td style="border:1.5px solid #b0b0b0;padding:6px 8px;text-align:center;white-space:nowrap"><input type="hidden" class="batch-defective-qty" value="' + defectiveQty + '" data-orig="' + defectiveQty + '"><span>' + defectiveQty + '</span></td>' : '') +
|
(orderType === 'packaging' ? renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, !!(res.data && res.data.defective_qty_filled), true, '不良品数量', false) : '') +
|
||||||
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
|
'<td class="batch-unfinished" style="border:1.5px solid #b0b0b0;padding:6px 10px;text-align:center">0</td>' +
|
||||||
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(remark) + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(remark) + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveArrivalBatch(this,' + batchId + ',' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button><button onclick="deleteArrivalBatch(this,' + batchId + ',\'' + orderType + '\')" title="删除" style="padding:2px 6px;font-size:11px;border:1px solid #ff4d4f;background:#fff1f0;color:#ff4d4f;border-radius:3px;cursor:pointer;margin-left:2px;white-space:nowrap">✕</button></div></td>';
|
'<td style="border:1.5px solid #b0b0b0;padding:4px 6px"><div style="display:flex;gap:2px;align-items:center;justify-content:center"><span class="arrival-remark-field" style="position:relative;display:block;flex:1;min-width:120px;max-width:220px"><textarea rows="2" class="batch-remark" data-orig="' + escHtml(remark) + '" style="width:100%;box-sizing:border-box;font-size:12px;text-align:left;border:1px solid #d9d9d9;border-radius:3px;padding:4px 27px 4px 6px;resize:vertical">' + escHtml(remark) + '</textarea><button type="button" class="arrival-remark-expand" title="展开编辑备注" aria-label="展开编辑备注" onclick="openArrivalRemarkViewer(this)" style="position:absolute;right:3px;bottom:3px;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:#e6f7ff;color:#1677ff;line-height:20px;font-size:13px;cursor:pointer">⛶</button></span><button onclick="saveArrivalBatch(this,' + batchId + ',' + orderId + ',\'' + orderType + '\')" title="保存" style="padding:2px 6px;font-size:11px;border:1px solid #1890ff;background:#e6f7ff;color:#1890ff;border-radius:3px;cursor:pointer;white-space:nowrap">✅</button><button onclick="deleteArrivalBatch(this,' + batchId + ',\'' + orderType + '\')" title="删除" style="padding:2px 6px;font-size:11px;border:1px solid #ff4d4f;background:#fff1f0;color:#ff4d4f;border-radius:3px;cursor:pointer;margin-left:2px;white-space:nowrap">✕</button></div></td>';
|
||||||
recalcArrivalUnfinished();
|
recalcArrivalUnfinished();
|
||||||
@ -22069,10 +22146,14 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) {
|
|||||||
var tr = btn.closest('tr');
|
var tr = btn.closest('tr');
|
||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
var date = tr.querySelector('.batch-date')?.value || '';
|
var date = tr.querySelector('.batch-date')?.value || '';
|
||||||
var qty = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
|
var qtyRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
|
||||||
var defectiveQty = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
|
var defectiveRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
|
||||||
|
var qty = qtyRaw === '' ? '' : parseInt(qtyRaw, 10);
|
||||||
|
var defectiveQty = defectiveRaw === '' ? '' : parseInt(defectiveRaw, 10);
|
||||||
var remark = tr.querySelector('.batch-remark')?.value || '';
|
var remark = tr.querySelector('.batch-remark')?.value || '';
|
||||||
if (defectiveQty < 0 || defectiveQty > qty) { alert('不良品数量不能为负数或大于到货数量'); return; }
|
if (qtyRaw !== '' && (!Number.isInteger(qty) || qty < 0)) { alert('到货数量必须为非负整数'); return; }
|
||||||
|
if (defectiveRaw !== '' && !Number.isInteger(defectiveQty)) { alert('不良品数量必须为整数'); return; }
|
||||||
|
if (orderType !== 'packaging' && defectiveRaw !== '' && (defectiveQty < 0 || defectiveQty > (qty || 0))) { alert('不良品数量不能为负数或大于到货数量'); return; }
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '⋯';
|
btn.textContent = '⋯';
|
||||||
fetch('/api/domestic/orders/update_batch', {
|
fetch('/api/domestic/orders/update_batch', {
|
||||||
@ -22098,6 +22179,14 @@ function saveArrivalBatch(btn, batchId, orderId, orderType) {
|
|||||||
var opt = tr.querySelector('.batch-operated-at');
|
var opt = tr.querySelector('.batch-operated-at');
|
||||||
if (op) op.textContent = (res.data && res.data.operator_name) || '-';
|
if (op) op.textContent = (res.data && res.data.operator_name) || '-';
|
||||||
if (opt) opt.textContent = (res.data && res.data.operated_at) || '-';
|
if (opt) opt.textContent = (res.data && res.data.operated_at) || '-';
|
||||||
|
if (orderType === 'packaging') {
|
||||||
|
var dateCell = dd && dd.closest('td');
|
||||||
|
var qtyCell = dq && dq.closest('td');
|
||||||
|
var defectiveCell = df && df.closest('td');
|
||||||
|
if (dateCell && res.data && res.data.arrival_date_filled) dateCell.outerHTML = renderArrivalBatchStoredCell('date', 'batch-date', date, true, false, '到货日期');
|
||||||
|
if (qtyCell && res.data && res.data.arrival_qty_filled) qtyCell.outerHTML = renderArrivalBatchStoredCell('number', 'batch-qty', qty, true, false, '到货数量', true);
|
||||||
|
if (defectiveCell && res.data && res.data.defective_qty_filled) defectiveCell.outerHTML = renderArrivalBatchStoredCell('number', 'batch-defective-qty', defectiveQty, true, false, '不良品数量', false);
|
||||||
|
}
|
||||||
recalcArrivalUnfinished();
|
recalcArrivalUnfinished();
|
||||||
refreshOrderListFromArrivalModal(btn);
|
refreshOrderListFromArrivalModal(btn);
|
||||||
setTimeout(function() { btn.textContent = '✅'; }, 500);
|
setTimeout(function() { btn.textContent = '✅'; }, 500);
|
||||||
@ -22253,10 +22342,12 @@ function saveDetailChanges(btn) {
|
|||||||
if (tr.classList.contains('batch-placeholder') || tr.classList.contains('batch-editing')) {
|
if (tr.classList.contains('batch-placeholder') || tr.classList.contains('batch-editing')) {
|
||||||
// 新增行:有日期或数量才保存
|
// 新增行:有日期或数量才保存
|
||||||
var d = tr.querySelector('.batch-date')?.value || '';
|
var d = tr.querySelector('.batch-date')?.value || '';
|
||||||
var q = parseInt(tr.querySelector('.batch-qty')?.value) || 0;
|
var qRaw = (tr.querySelector('.batch-qty')?.value || '').trim();
|
||||||
var f = parseInt(tr.querySelector('.batch-defective-qty')?.value) || 0;
|
var fRaw = (tr.querySelector('.batch-defective-qty')?.value || '').trim();
|
||||||
|
var q = qRaw === '' ? '' : parseInt(qRaw, 10);
|
||||||
|
var f = fRaw === '' ? '' : parseInt(fRaw, 10);
|
||||||
var r = tr.querySelector('.batch-remark')?.value || '';
|
var r = tr.querySelector('.batch-remark')?.value || '';
|
||||||
if (d || q || f) {
|
if (d || qRaw !== '' || fRaw !== '') {
|
||||||
batchAdds.push({arrival_date: d, arrival_qty: q, defective_qty: f, remark: r});
|
batchAdds.push({arrival_date: d, arrival_qty: q, defective_qty: f, remark: r});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -22271,16 +22362,19 @@ function saveDetailChanges(btn) {
|
|||||||
var of = tr.querySelector('.batch-defective-qty')?.getAttribute('data-orig') || '';
|
var of = tr.querySelector('.batch-defective-qty')?.getAttribute('data-orig') || '';
|
||||||
var or_ = tr.querySelector('.batch-remark')?.getAttribute('data-orig') || '';
|
var or_ = tr.querySelector('.batch-remark')?.getAttribute('data-orig') || '';
|
||||||
if (String(d) !== String(od) || String(q) !== String(oq) || String(f) !== String(of) || String(r) !== String(or_)) {
|
if (String(d) !== String(od) || String(q) !== String(oq) || String(f) !== String(of) || String(r) !== String(or_)) {
|
||||||
batchUpdates.push({id: bid, arrival_date: d, arrival_qty: parseInt(q) || 0, defective_qty: parseInt(f) || 0, remark: r});
|
batchUpdates.push({id: bid, arrival_date: d, arrival_qty: q === '' ? '' : parseInt(q, 10), defective_qty: f === '' ? '' : parseInt(f, 10), remark: r});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) {
|
var invalidBatch = batchAdds.concat(batchUpdates).some(function(b) {
|
||||||
return b.defective_qty < 0 || b.defective_qty > b.arrival_qty;
|
if (b.arrival_qty !== '' && (!Number.isInteger(b.arrival_qty) || b.arrival_qty < 0)) return true;
|
||||||
|
if (b.defective_qty !== '' && !Number.isInteger(b.defective_qty)) return true;
|
||||||
|
return orderType !== 'packaging' && b.defective_qty !== '' &&
|
||||||
|
(b.defective_qty < 0 || b.defective_qty > (b.arrival_qty || 0));
|
||||||
});
|
});
|
||||||
if (invalidBatch) {
|
if (invalidBatch) {
|
||||||
showCenteredAlert('提示', '不良品数量不能为负数或大于到货数量');
|
showCenteredAlert('提示', orderType === 'packaging' ? '到货数量必须为非负整数,不良品数量必须为整数' : '数量格式错误,且不良品数量不能为负数或大于到货数量');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28712,7 +28806,7 @@ window.exportBom = function() {
|
|||||||
|
|
||||||
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
|
// ─── 统一产品详情弹窗(固定字段,参考弹窗展示内容1方案)───
|
||||||
var POPUP_VISIBLE_FIELDS = [
|
var POPUP_VISIBLE_FIELDS = [
|
||||||
'product_type', '产品种类', 'product_code', 'product_name', '新旧品', '产品分级',
|
'product_type', '产品种类', 'product_code', 'product_name', '新旧品', '产品分级', '在途进度',
|
||||||
'昨日销量', '销售趋势',
|
'昨日销量', '销售趋势',
|
||||||
'库存', '加工厂库存', '生产在途', '采购在途',
|
'库存', '加工厂库存', '生产在途', '采购在途',
|
||||||
'日销量预测',
|
'日销量预测',
|
||||||
@ -28733,6 +28827,7 @@ var POPUP_FIELD_LABELS = {
|
|||||||
'product_code': '货品编号',
|
'product_code': '货品编号',
|
||||||
'product_name': '货品名称',
|
'product_name': '货品名称',
|
||||||
'产品分级': '产品分级',
|
'产品分级': '产品分级',
|
||||||
|
'在途进度': '在途进度',
|
||||||
'brand': '品牌',
|
'brand': '品牌',
|
||||||
'specification': '规格',
|
'specification': '规格',
|
||||||
'cost_price': '成本价',
|
'cost_price': '成本价',
|
||||||
@ -29207,7 +29302,7 @@ function renderPlanConfirmDialog(modal, data, bomMaterials) {
|
|||||||
window._pendingBomData[data.product_code].forEach(function(m) {
|
window._pendingBomData[data.product_code].forEach(function(m) {
|
||||||
if (m.material_code === '成品_' + data.product_code) {
|
if (m.material_code === '成品_' + data.product_code) {
|
||||||
if (m.actual_quantity >= 0) savedProductQty = m.actual_quantity;
|
if (m.actual_quantity >= 0) savedProductQty = m.actual_quantity;
|
||||||
if (m.remark) savedRemark = m.remark;
|
if (m.remark !== undefined && m.remark !== null) savedRemark = m.remark;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -29408,10 +29503,11 @@ window.showProductDetail = function(productCode) {
|
|||||||
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
|
var basicFields = ['product_type', '\u4ea7\u54c1\u79cd\u7c7b', 'product_code', 'product_name',
|
||||||
'新旧品',
|
'新旧品',
|
||||||
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8'];
|
'\u4f9b\u5e94\u5546\u540d\u79f0', '\u751f\u4ea7\u8d27\u671f', '\u7269\u6d41\u65f6\u95f4', '\u521b\u5efa\u65f6\u95f4', '\u6279\u6ce8'];
|
||||||
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
|
var salesFields = ['\u4ea7\u54c1\u5206\u7ea7', '在途进度', '\u6628\u65e5\u9500\u91cf', '\u9500\u552e\u8d8b\u52bf', '\u5e93\u5b58', '\u52a0\u5de5\u5382\u5e93\u5b58', '\u751f\u4ea7\u5728\u9014', '\u91c7\u8d2d\u5728\u9014',
|
||||||
'\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',
|
'\u65e5\u9500\u91cf\u9884\u6d4b', '3\u5929\u5e73\u5747\u9500\u91cf', '7\u5929\u5e73\u5747\u9500\u91cf', '14\u5929\u5e73\u5747\u9500\u91cf',
|
||||||
'\u5e93\u5b58\u53ef\u652f\u6491\u5929\u6570', '\u52a0\u5de5\u5382\u5e93\u5b58\u652f\u6491\u5929\u6570', '\u603b\u652f\u6491\u5929\u6570',
|
'\u5e93\u5b58\u53ef\u652f\u6491\u5929\u6570', '\u52a0\u5de5\u5382\u5e93\u5b58\u652f\u6491\u5929\u6570', '\u603b\u652f\u6491\u5929\u6570',
|
||||||
'30\u5929\u5e73\u5747\u9500\u91cf', '\u6781\u503c\u5224\u65ad'];
|
'30\u5929\u5e73\u5747\u9500\u91cf', '\u6781\u503c\u5224\u65ad'];
|
||||||
|
var scopedSalesFields = salesFields.filter(function(field) { return field !== '在途进度'; });
|
||||||
|
|
||||||
// \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09
|
// \u751f\u6210\u57fa\u7840\u4fe1\u606f\u5b57\u6bb5HTML\uff082\u5217\u7b49\u8ddd\uff09
|
||||||
var basicHtml = '';
|
var basicHtml = '';
|
||||||
@ -29460,8 +29556,8 @@ window.showProductDetail = function(productCode) {
|
|||||||
};
|
};
|
||||||
return labels[field] || POPUP_FIELD_LABELS[field] || field;
|
return labels[field] || POPUP_FIELD_LABELS[field] || field;
|
||||||
};
|
};
|
||||||
for (var i = 0; i < salesFields.length; i++) {
|
for (var i = 0; i < scopedSalesFields.length; i++) {
|
||||||
var cloudField = salesFields[i];
|
var cloudField = scopedSalesFields[i];
|
||||||
var cloudLabel = scopedFieldLabel(cloudField, '云仓');
|
var cloudLabel = scopedFieldLabel(cloudField, '云仓');
|
||||||
var cloudVal = cloudSales[cloudField] !== undefined ? cloudSales[cloudField] : '-';
|
var cloudVal = cloudSales[cloudField] !== undefined ? cloudSales[cloudField] : '-';
|
||||||
var cloudValHtml = formatPopupFieldValue(cloudField, cloudVal, cloudSales);
|
var cloudValHtml = formatPopupFieldValue(cloudField, cloudVal, cloudSales);
|
||||||
@ -29474,8 +29570,8 @@ window.showProductDetail = function(productCode) {
|
|||||||
// 外仓数据与默认数据使用相同字段顺序和四列布局。
|
// 外仓数据与默认数据使用相同字段顺序和四列布局。
|
||||||
var externalSalesHtml = '';
|
var externalSalesHtml = '';
|
||||||
var externalSales = p['外仓数据'] || {};
|
var externalSales = p['外仓数据'] || {};
|
||||||
for (var i = 0; i < salesFields.length; i++) {
|
for (var i = 0; i < scopedSalesFields.length; i++) {
|
||||||
var externalField = salesFields[i];
|
var externalField = scopedSalesFields[i];
|
||||||
var externalLabel = scopedFieldLabel(externalField, '外仓');
|
var externalLabel = scopedFieldLabel(externalField, '外仓');
|
||||||
var externalVal = externalSales[externalField] !== undefined ? externalSales[externalField] : '-';
|
var externalVal = externalSales[externalField] !== undefined ? externalSales[externalField] : '-';
|
||||||
var externalValHtml = formatPopupFieldValue(externalField, externalVal, externalSales);
|
var externalValHtml = formatPopupFieldValue(externalField, externalVal, externalSales);
|
||||||
|
|||||||
120
tests/test_ai_operation_history.py
Normal file
120
tests/test_ai_operation_history.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from routes import ai
|
||||||
|
from services import ai_datasources, audit_schema
|
||||||
|
|
||||||
|
|
||||||
|
class AiOperationHistoryTest(unittest.TestCase):
|
||||||
|
def test_history_query_returns_exact_and_legacy_matches_distinctly(self):
|
||||||
|
fake_logs = [
|
||||||
|
{'id': 2, 'created_at': '2026-10-28 10:00:00', 'user_id': 3, 'page': '计划预测',
|
||||||
|
'action': '数据变更', 'field': None, 'object_type': 'product', 'object_id': 'SKU-1',
|
||||||
|
'old_values': '{"采购计划":"甲"}', 'new_values': '{"采购计划":"乙"}',
|
||||||
|
'detail': '更新计划预测', 'result_status': 'success', 'request_id': 'r2',
|
||||||
|
'real_name': '张三', 'username': 'zhang'},
|
||||||
|
{'id': 1, 'created_at': '2026-10-27 10:00:00', 'user_id': 4, 'page': '计划预测',
|
||||||
|
'action': '数据变更', 'field': None, 'object_type': None, 'object_id': None,
|
||||||
|
'old_values': None, 'new_values': None, 'detail': '货品编号:SKU-1,采购计划调整',
|
||||||
|
'result_status': 'success', 'request_id': 'r1', 'real_name': '李四', 'username': 'li'},
|
||||||
|
]
|
||||||
|
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 2}], fake_logs]) as query:
|
||||||
|
result = ai_datasources._h_operation_history({'product_code': 'SKU-1', 'module': '计划预测'})
|
||||||
|
self.assertEqual(2, result['summary']['row_count'])
|
||||||
|
self.assertEqual('精确对象匹配', result['rows'][0]['匹配方式'])
|
||||||
|
self.assertIn('采购计划:甲 → 乙', result['rows'][0]['修改内容'])
|
||||||
|
self.assertEqual('历史文本匹配', result['rows'][1]['匹配方式'])
|
||||||
|
self.assertIn('object_id=%s', query.call_args_list[0].args[0])
|
||||||
|
self.assertIn('%计划预测%', query.call_args_list[0].args[1])
|
||||||
|
|
||||||
|
def test_chinese_and_nested_product_identifiers_are_inferred(self):
|
||||||
|
self.assertEqual(('product', 'SKU-2'), audit_schema.infer_object({'货品编号': 'SKU-2'}))
|
||||||
|
self.assertEqual(('product', 'SKU-3'), audit_schema.infer_object({'items': [{'产品编码': 'SKU-3'}]}))
|
||||||
|
self.assertEqual(('product', 'SKU-4'), audit_schema.infer_object({'id': 22, 'product': {'product_code': 'SKU-4'}}))
|
||||||
|
|
||||||
|
def test_history_requests_are_isolated_from_other_ai_sources(self):
|
||||||
|
tasks = ai._normalize_tasks([
|
||||||
|
{'datasource': 'product_profile', 'params': {'product_code': 'SKU-1'}},
|
||||||
|
{'datasource': 'operation_history', 'params': {'product_code': 'SKU-1'}},
|
||||||
|
{'datasource': 'planning_metrics', 'params': {'product_code': 'SKU-1'}},
|
||||||
|
])
|
||||||
|
self.assertEqual(['operation_history'], [task['datasource'] for task in tasks])
|
||||||
|
|
||||||
|
def test_history_can_search_legacy_text_by_product_name(self):
|
||||||
|
fake_logs = [{'id': 3, 'created_at': None, 'user_id': 1, 'page': '计划预测',
|
||||||
|
'action': '编辑', 'object_type': None, 'object_id': None,
|
||||||
|
'old_values': None, 'new_values': None, 'detail': '修改 Aromat 精华液',
|
||||||
|
'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
|
||||||
|
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]) as query:
|
||||||
|
result = ai_datasources._h_operation_history({'product_name': 'Aromat 精华液'})
|
||||||
|
self.assertEqual('历史文本匹配', result['rows'][0]['匹配方式'])
|
||||||
|
self.assertIn('%Aromat 精华液%', query.call_args_list[0].args[1])
|
||||||
|
|
||||||
|
def test_history_query_is_scoped_to_permitted_pages(self):
|
||||||
|
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 0}], []]) as query:
|
||||||
|
ai_datasources._h_operation_history({'_allowed_pages': ['planning', '计划预测']})
|
||||||
|
self.assertIn('COALESCE(NULLIF(l.page', query.call_args_list[0].args[0])
|
||||||
|
self.assertIn('planning', query.call_args_list[0].args[1])
|
||||||
|
|
||||||
|
def test_history_field_values_are_hidden_without_field_permission(self):
|
||||||
|
rows = [{'板块': '计划预测', '修改内容': '采购计划: (未记录) → 王某', '字段': '请求字段(旧日志未结构化)'}]
|
||||||
|
with mock.patch('routes.auth._has_effective_page_permission', return_value=True), \
|
||||||
|
mock.patch('services.field_perms.field_perms_for_page', return_value={'采购计划': {'view': False}}):
|
||||||
|
result = ai._filter_operation_history_permissions(1, '采购跟单', rows)
|
||||||
|
self.assertEqual('修改字段内容因字段权限不可见', result[0]['修改内容'])
|
||||||
|
|
||||||
|
def test_history_content_localizes_common_fields_and_keeps_full_values(self):
|
||||||
|
fake_logs = [{'id': 4, 'created_at': None, 'user_id': 1, 'page': '产品信息管理',
|
||||||
|
'action': '编辑产品', 'object_type': 'product', 'object_id': 'SKU-5',
|
||||||
|
'old_values': '{"purchase_plan":"甲"}',
|
||||||
|
'new_values': '{"purchase_plan":"乙","product_name":"Aromat 精华液"}',
|
||||||
|
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
|
||||||
|
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]):
|
||||||
|
result = ai_datasources._h_operation_history({'product_code': 'SKU-5'})
|
||||||
|
row = result['rows'][0]
|
||||||
|
self.assertEqual('采购计划', row['字段'])
|
||||||
|
self.assertIn('采购计划:甲 → 乙', row['修改内容'])
|
||||||
|
self.assertNotIn('货品名称', row['修改内容'])
|
||||||
|
|
||||||
|
def test_nested_json_field_keys_are_localized(self):
|
||||||
|
fake_logs = [{'id': 5, 'created_at': None, 'user_id': 1, 'page': 'planning',
|
||||||
|
'action': '数据变更', 'object_type': None, 'object_id': None,
|
||||||
|
'old_values': '{"columns":[{"field":"product_code","label":"货品编号"}],"sort_by":"product_name"}',
|
||||||
|
'new_values': '{"columns":[{"field":"product_code","label":"货品编号"}],"sort_by":"product_code"}',
|
||||||
|
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'}]
|
||||||
|
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 1}], fake_logs]):
|
||||||
|
row = ai_datasources._h_operation_history({'module': '计划预测'})['rows'][0]
|
||||||
|
self.assertEqual('排序字段', row['字段'])
|
||||||
|
self.assertIn('排序字段:货品名称 → 货品编号', row['修改内容'])
|
||||||
|
self.assertNotIn('字段列', row['修改内容'])
|
||||||
|
self.assertEqual('计划预测', row['板块'])
|
||||||
|
|
||||||
|
def test_history_omits_unverifiable_bulk_payload_and_shows_only_nested_changes(self):
|
||||||
|
fake_logs = [
|
||||||
|
{'id': 6, 'created_at': None, 'user_id': 1, 'page': '物料清单管理',
|
||||||
|
'action': '数据变更', 'object_type': 'product', 'object_id': 'SKU-6',
|
||||||
|
'old_values': None,
|
||||||
|
'new_values': '{"product_codes":["SKU-6"],"bom_data_map":{"SKU-6":[{"material_code":"MAT-1","quantity":2}]}}',
|
||||||
|
'detail': 'POST /api/bom/save;提交内容', 'result_status': 'success',
|
||||||
|
'real_name': '用户', 'username': 'u'},
|
||||||
|
{'id': 7, 'created_at': None, 'user_id': 1, 'page': '物料清单管理',
|
||||||
|
'action': '数据变更', 'object_type': 'product', 'object_id': 'SKU-7',
|
||||||
|
'old_values': '{"bom_data_map":{"SKU-7":[{"material_code":"MAT-1","quantity":2,"remark":"保留"}]}}',
|
||||||
|
'new_values': '{"bom_data_map":{"SKU-7":[{"material_code":"MAT-1","quantity":3,"remark":"保留"}]}}',
|
||||||
|
'detail': '', 'result_status': 'success', 'real_name': '用户', 'username': 'u'},
|
||||||
|
]
|
||||||
|
with mock.patch.object(ai_datasources, 'query', side_effect=[[{'total': 2}], fake_logs]):
|
||||||
|
rows = ai_datasources._h_operation_history({'module': '物料清单管理'})['rows']
|
||||||
|
self.assertEqual('该记录未保存可比较的字段前后值', rows[0]['修改内容'])
|
||||||
|
self.assertNotIn('SKU-6', rows[0]['修改内容'])
|
||||||
|
self.assertIn('数量:2 → 3', rows[1]['修改内容'])
|
||||||
|
self.assertNotIn('保留', rows[1]['修改内容'])
|
||||||
|
|
||||||
|
def test_history_source_requires_operation_log_page_permission(self):
|
||||||
|
with mock.patch('routes.auth._has_effective_page_permission', return_value=False):
|
||||||
|
self.assertFalse(ai._can_view_datasource(8, '采购跟单', 'operation_history'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
43
tests/test_inventory_board_logic.py
Normal file
43
tests/test_inventory_board_logic.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""库存数据看板月份与统计口径回归测试。"""
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryBoardLogicTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.frontend = ROOT.joinpath('static', 'js', 'app.js').read_text(encoding='utf-8')
|
||||||
|
cls.backend = ROOT.joinpath('routes', 'dashboard.py').read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
def test_month_is_dynamic_and_uses_shanghai_timezone(self):
|
||||||
|
self.assertIn("timeZone: 'Asia/Shanghai'", self.frontend)
|
||||||
|
self.assertIn('inventoryBoardCurrentMonth()', self.frontend)
|
||||||
|
self.assertNotIn("monthInput.value = '2026-08'", self.frontend)
|
||||||
|
|
||||||
|
def test_snapshot_is_limited_to_selected_month(self):
|
||||||
|
self.assertIn(
|
||||||
|
'WHERE data_date >= %s AND data_date < %s',
|
||||||
|
self.backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disabled_warehouses_and_all_warehouse_stockout_are_enforced(self):
|
||||||
|
self.assertIn("active_warehouse_condition('id')", self.backend)
|
||||||
|
self.assertIn('HAVING SUM(id.available_qty) <= 0', self.backend)
|
||||||
|
self.assertIn('_stockout_codes.intersection_update(_sales_30.keys())', self.backend)
|
||||||
|
self.assertNotIn('AND id.available_qty = 0', self.backend)
|
||||||
|
|
||||||
|
def test_external_value_is_optional_override(self):
|
||||||
|
self.assertIn('if key not in external_map', self.backend)
|
||||||
|
self.assertNotIn("if value in ('', None):\n result[key] = 0", self.backend)
|
||||||
|
|
||||||
|
def test_sku_thresholds_follow_visible_labels(self):
|
||||||
|
self.assertIn("float(row['qty'] or 0) >= 30", self.backend)
|
||||||
|
self.assertIn("float(row['qty'] or 0) >= 100", self.backend)
|
||||||
|
self.assertIn("float(row['qty'] or 0) >= 200", self.backend)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@ -22,6 +22,8 @@ class InventoryBoardStaleCacheTest(unittest.TestCase):
|
|||||||
normalized = ' '.join(str(sql).split()).lower()
|
normalized = ' '.join(str(sql).split()).lower()
|
||||||
if normalized.startswith('select count(*) as row_count'):
|
if normalized.startswith('select count(*) as row_count'):
|
||||||
return [{'row_count': 10, 'qty_sum': 20, 'updated_signature': 'sig'}]
|
return [{'row_count': 10, 'qty_sum': 20, 'updated_signature': 'sig'}]
|
||||||
|
if normalized.startswith('select warehouse_code, warehouse_property, disabled from warehouses'):
|
||||||
|
return [{'warehouse_code': '01', 'warehouse_property': '云仓', 'disabled': '否'}]
|
||||||
if normalized.startswith('create table if not exists inventory_board_system_cache'):
|
if normalized.startswith('create table if not exists inventory_board_system_cache'):
|
||||||
return 0
|
return 0
|
||||||
if normalized.startswith('select payload, updated_at from inventory_board_system_cache'):
|
if normalized.startswith('select payload, updated_at from inventory_board_system_cache'):
|
||||||
|
|||||||
38
tests/test_packaging_arrival_defective.py
Normal file
38
tests/test_packaging_arrival_defective.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""包材订单到货批次不良品补填规则回归测试。"""
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class PackagingArrivalDefectiveTest(unittest.TestCase):
|
||||||
|
def test_packaging_defective_allows_negative_and_over_arrival(self):
|
||||||
|
from routes.purchase import _arrival_batch_quantities
|
||||||
|
|
||||||
|
self.assertEqual((10, -3, True, True), _arrival_batch_quantities(
|
||||||
|
{'arrival_qty': '10', 'defective_qty': '-3'}, 'packaging'))
|
||||||
|
self.assertEqual((10, 30, True, True), _arrival_batch_quantities(
|
||||||
|
{'arrival_qty': '10', 'defective_qty': '30'}, 'packaging'))
|
||||||
|
|
||||||
|
def test_blank_defective_is_preserved_as_unfilled(self):
|
||||||
|
from routes.purchase import _arrival_batch_quantities
|
||||||
|
|
||||||
|
self.assertEqual((10, 0, True, False), _arrival_batch_quantities(
|
||||||
|
{'arrival_qty': '10', 'defective_qty': ''}, 'packaging'))
|
||||||
|
|
||||||
|
def test_other_order_types_keep_existing_validation(self):
|
||||||
|
from routes.purchase import _arrival_batch_quantities
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, '不能为负数'):
|
||||||
|
_arrival_batch_quantities({'arrival_qty': '10', 'defective_qty': '-1'}, 'domestic')
|
||||||
|
with self.assertRaisesRegex(ValueError, '不能大于到货数量'):
|
||||||
|
_arrival_batch_quantities({'arrival_qty': '10', 'defective_qty': '11'}, 'overseas')
|
||||||
|
|
||||||
|
def test_frontend_keeps_blank_and_only_validates_non_packaging_range(self):
|
||||||
|
source = Path(__file__).resolve().parents[1].joinpath('static/js/app.js').read_text(encoding='utf-8')
|
||||||
|
self.assertIn("defectiveRaw === '' ? ''", source)
|
||||||
|
self.assertIn("orderType !== 'packaging' && defectiveRaw !== ''", source)
|
||||||
|
self.assertIn("renderArrivalBatchStoredCell('number', 'batch-defective-qty'", source)
|
||||||
|
self.assertNotIn('min="0" class="batch-defective-qty"', source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@ -49,7 +49,7 @@ class PlanningNewOldTest(unittest.TestCase):
|
|||||||
for existing, captured in [(None, '新品'), ({'id': 5, 'bom_items': None, 'new_old_product': '旧品'}, '旧品')]:
|
for existing, captured in [(None, '新品'), ({'id': 5, 'bom_items': None, 'new_old_product': '旧品'}, '旧品')]:
|
||||||
writes = []
|
writes = []
|
||||||
def sql(query, params=None):
|
def sql(query, params=None):
|
||||||
if query.lstrip().startswith('SELECT id, bom_items'):
|
if 'SELECT id, bom_items, new_old_product' in query:
|
||||||
return [existing] if existing else []
|
return [existing] if existing else []
|
||||||
if query.lstrip().startswith('SELECT'):
|
if query.lstrip().startswith('SELECT'):
|
||||||
return []
|
return []
|
||||||
@ -67,6 +67,38 @@ class PlanningNewOldTest(unittest.TestCase):
|
|||||||
params = writes[0][1]
|
params = writes[0][1]
|
||||||
self.assertEqual(params[-4] if existing else params[-3], captured)
|
self.assertEqual(params[-4] if existing else params[-3], captured)
|
||||||
|
|
||||||
|
def test_save_order_modal_creates_hidden_draft_without_pending_order(self):
|
||||||
|
writes = []
|
||||||
|
def sql(query, params=None):
|
||||||
|
if 'SELECT id, remark, bom_items, formal_order_qty, status FROM pending_orders' in query:
|
||||||
|
return []
|
||||||
|
if 'SELECT product_name, product_type FROM products' in query:
|
||||||
|
return [{'product_name': '测试成品', 'product_type': '成品'}]
|
||||||
|
writes.append((query, params))
|
||||||
|
return 42
|
||||||
|
|
||||||
|
bom_items = [
|
||||||
|
{'material_code': '成品_P1', 'actual_quantity': 12, 'remark': '成品备注'},
|
||||||
|
{'material_code': 'MAT-1', 'material_name': '测试包材', 'quantity': 1,
|
||||||
|
'actual_quantity': 8, 'remark': '包材备注', 'pre_pack_qty': 5,
|
||||||
|
'pkg_remark': '余额备注', 'change_manuscript': '是'},
|
||||||
|
]
|
||||||
|
with self.app.test_request_context(json={
|
||||||
|
'product_code': 'P1', 'remark': '成品备注', 'bom_items': bom_items
|
||||||
|
}), mock.patch.object(purchase, 'operation_required', side_effect=lambda page, op: page == 'planning'), \
|
||||||
|
mock.patch.object(purchase, 'query', side_effect=sql), \
|
||||||
|
mock.patch.object(purchase, '_sync_pending_pre_packaging') as sync_pack, \
|
||||||
|
mock.patch.object(purchase, 'add_operation_log'):
|
||||||
|
response = purchase.purchase_update_pending.__wrapped__()
|
||||||
|
|
||||||
|
self.assertEqual(response.get_json()['code'], 0)
|
||||||
|
insert = next((q, p) for q, p in writes if q.lstrip().startswith('INSERT INTO pending_orders'))
|
||||||
|
self.assertIn("'draft'", insert[0])
|
||||||
|
self.assertEqual(insert[1][0:3], ('P1', '测试成品', '成品'))
|
||||||
|
self.assertEqual(insert[1][3], 12)
|
||||||
|
self.assertTrue(any('UPDATE pending_orders SET formal_order_qty=%s, bom_items=%s' in q for q, _ in writes))
|
||||||
|
sync_pack.assert_called_once()
|
||||||
|
|
||||||
def test_batch_add_captures_each_product_choice(self):
|
def test_batch_add_captures_each_product_choice(self):
|
||||||
writes = []
|
writes = []
|
||||||
def sql(query, params=None):
|
def sql(query, params=None):
|
||||||
@ -87,6 +119,31 @@ class PlanningNewOldTest(unittest.TestCase):
|
|||||||
self.assertEqual(response.get_json()['data']['added'], 2)
|
self.assertEqual(response.get_json()['data']['added'], 2)
|
||||||
self.assertEqual([params[-3] for params in writes], ['新品', '旧品'])
|
self.assertEqual([params[-3] for params in writes], ['新品', '旧品'])
|
||||||
|
|
||||||
|
def test_batch_add_promotes_existing_order_draft(self):
|
||||||
|
writes = []
|
||||||
|
def sql(query, params=None):
|
||||||
|
if "status='pending'" in query and query.lstrip().startswith('SELECT id'):
|
||||||
|
return []
|
||||||
|
if "status='draft'" in query and query.lstrip().startswith('SELECT id'):
|
||||||
|
return [{'id': 9, 'new_old_product': '新品', 'sales_snapshot': '{"昨日销量":1}',
|
||||||
|
'sales_snapshot_at': '2026-10-28 09:00:00'}]
|
||||||
|
if 'SELECT p.product_code' in query:
|
||||||
|
return [{'product_code': 'P1', 'product_name': '商品', 'product_type': '成品',
|
||||||
|
'planning_new_old': '新品'}]
|
||||||
|
if query.lstrip().startswith('SELECT'):
|
||||||
|
return []
|
||||||
|
writes.append((query, params))
|
||||||
|
return 1
|
||||||
|
with self.app.test_request_context(json={'product_codes': ['P1']}), \
|
||||||
|
mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
|
||||||
|
mock.patch.object(purchase, 'query', side_effect=sql), \
|
||||||
|
mock.patch.object(purchase, '_build_planning_sales_snapshot') as snapshot, \
|
||||||
|
mock.patch.object(purchase, 'add_operation_log'):
|
||||||
|
response = purchase.purchase_batch_add_pending.__wrapped__()
|
||||||
|
self.assertEqual(response.get_json()['data'], {'added': 1, 'skipped': 0})
|
||||||
|
self.assertTrue(any("status='pending'" in q and 'WHERE id=%s' in q for q, _ in writes))
|
||||||
|
snapshot.assert_not_called()
|
||||||
|
|
||||||
def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self):
|
def test_confirm_overseas_uses_snapshot_instead_of_current_product_value(self):
|
||||||
for value in ('新品', '旧品'):
|
for value in ('新品', '旧品'):
|
||||||
writes = []
|
writes = []
|
||||||
|
|||||||
@ -19,3 +19,4 @@
|
|||||||
{"checked_at": "2026-10-26T09:15:23.729554+08:00", "version": "c3d7f4b283212db955f8d15b681c6cf3fa80e3b2874936002bfc90d06d840f68", "target_inventory": "2026-10-26", "target_outbound": "2026-10-25", "missing": ["outbound:2026-10-25:dymuto", "outbound:2026-10-25:dyqsimelr", "outbound:2026-10-25:jdbz", "outbound:2026-10-25:jdgj", "outbound:2026-10-25:jdjwt", "outbound:2026-10-25:jdmrt", "outbound:2026-10-25:jdsmj", "outbound:2026-10-25:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-26", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-26", "key": "02", "rows": 1760}, {"kind": "inventory", "date": "2026-10-26", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-26", "key": "05", "rows": 1455}, {"kind": "inventory", "date": "2026-10-26", "key": "06", "rows": 909}, {"kind": "inventory", "date": "2026-10-26", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-26", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-26", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-26", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-26", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-26", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-26", "key": "14", "rows": 377}, {"kind": "inventory", "date": "2026-10-26", "key": "15", "rows": 1572}, {"kind": "inventory", "date": "2026-10-26", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-26", "key": "20", "rows": 9389}, {"kind": "inventory", "date": "2026-10-26", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-26", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-26", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-26", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-26", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-26", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-25", "key": "wdt", "rows": 2195}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
{"checked_at": "2026-10-26T09:15:23.729554+08:00", "version": "c3d7f4b283212db955f8d15b681c6cf3fa80e3b2874936002bfc90d06d840f68", "target_inventory": "2026-10-26", "target_outbound": "2026-10-25", "missing": ["outbound:2026-10-25:dymuto", "outbound:2026-10-25:dyqsimelr", "outbound:2026-10-25:jdbz", "outbound:2026-10-25:jdgj", "outbound:2026-10-25:jdjwt", "outbound:2026-10-25:jdmrt", "outbound:2026-10-25:jdsmj", "outbound:2026-10-25:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-26", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-26", "key": "02", "rows": 1760}, {"kind": "inventory", "date": "2026-10-26", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-26", "key": "05", "rows": 1455}, {"kind": "inventory", "date": "2026-10-26", "key": "06", "rows": 909}, {"kind": "inventory", "date": "2026-10-26", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-26", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-26", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-26", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-26", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-26", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-26", "key": "14", "rows": 377}, {"kind": "inventory", "date": "2026-10-26", "key": "15", "rows": 1572}, {"kind": "inventory", "date": "2026-10-26", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-26", "key": "20", "rows": 9389}, {"kind": "inventory", "date": "2026-10-26", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-26", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-26", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-26", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-26", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-26", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-25", "key": "wdt", "rows": 2195}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||||
{"checked_at": "2026-10-27T09:15:28.445085+08:00", "version": "5c93a6bf338b2c936ee3f4f14fd0a47c985a093c9ea4887eaeb2331d1e0aaba4", "target_inventory": "2026-10-27", "target_outbound": "2026-10-26", "missing": ["outbound:2026-10-26:dymuto", "outbound:2026-10-26:dyqsimelr", "outbound:2026-10-26:jdbz", "outbound:2026-10-26:jdgj", "outbound:2026-10-26:jdjwt", "outbound:2026-10-26:jdmrt", "outbound:2026-10-26:jdsmj", "outbound:2026-10-26:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-27", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-27", "key": "02", "rows": 1762}, {"kind": "inventory", "date": "2026-10-27", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-27", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-27", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-27", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-27", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-27", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-27", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-27", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-27", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-27", "key": "14", "rows": 379}, {"kind": "inventory", "date": "2026-10-27", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-27", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-27", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-27", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-27", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-27", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-27", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-27", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-27", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-26", "key": "wdt", "rows": 2203}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
{"checked_at": "2026-10-27T09:15:28.445085+08:00", "version": "5c93a6bf338b2c936ee3f4f14fd0a47c985a093c9ea4887eaeb2331d1e0aaba4", "target_inventory": "2026-10-27", "target_outbound": "2026-10-26", "missing": ["outbound:2026-10-26:dymuto", "outbound:2026-10-26:dyqsimelr", "outbound:2026-10-26:jdbz", "outbound:2026-10-26:jdgj", "outbound:2026-10-26:jdjwt", "outbound:2026-10-26:jdmrt", "outbound:2026-10-26:jdsmj", "outbound:2026-10-26:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-27", "key": "01", "rows": 2378}, {"kind": "inventory", "date": "2026-10-27", "key": "02", "rows": 1762}, {"kind": "inventory", "date": "2026-10-27", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-27", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-27", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-27", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-27", "key": "09", "rows": 470}, {"kind": "inventory", "date": "2026-10-27", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-27", "key": "11", "rows": 239}, {"kind": "inventory", "date": "2026-10-27", "key": "12", "rows": 313}, {"kind": "inventory", "date": "2026-10-27", "key": "13", "rows": 741}, {"kind": "inventory", "date": "2026-10-27", "key": "14", "rows": 379}, {"kind": "inventory", "date": "2026-10-27", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-27", "key": "16", "rows": 274}, {"kind": "inventory", "date": "2026-10-27", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-27", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-27", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-27", "key": "26", "rows": 71}, {"kind": "inventory", "date": "2026-10-27", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-27", "key": "29", "rows": 698}, {"kind": "inventory", "date": "2026-10-27", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-26", "key": "wdt", "rows": 2203}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||||
{"checked_at": "2026-10-28T09:15:22.895734+08:00", "version": "1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83", "target_inventory": "2026-10-28", "target_outbound": "2026-10-27", "missing": ["outbound:2026-10-27:dymuto", "outbound:2026-10-27:dyqsimelr", "outbound:2026-10-27:jdbz", "outbound:2026-10-27:jdgj", "outbound:2026-10-27:jdjwt", "outbound:2026-10-27:jdmrt", "outbound:2026-10-27:jdsmj", "outbound:2026-10-27:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-28", "key": "01", "rows": 2381}, {"kind": "inventory", "date": "2026-10-28", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-28", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-28", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-28", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-28", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-28", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-28", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-28", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-28", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-28", "key": "13", "rows": 744}, {"kind": "inventory", "date": "2026-10-28", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-28", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-28", "key": "16", "rows": 276}, {"kind": "inventory", "date": "2026-10-28", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-28", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-28", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-28", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-28", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-28", "key": "29", "rows": 700}, {"kind": "inventory", "date": "2026-10-28", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-27", "key": "wdt", "rows": 2186}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
{"checked_at": "2026-10-28T09:15:22.895734+08:00", "version": "1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83", "target_inventory": "2026-10-28", "target_outbound": "2026-10-27", "missing": ["outbound:2026-10-27:dymuto", "outbound:2026-10-27:dyqsimelr", "outbound:2026-10-27:jdbz", "outbound:2026-10-27:jdgj", "outbound:2026-10-27:jdjwt", "outbound:2026-10-27:jdmrt", "outbound:2026-10-27:jdsmj", "outbound:2026-10-27:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-28", "key": "01", "rows": 2381}, {"kind": "inventory", "date": "2026-10-28", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-28", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-28", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-28", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-28", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-28", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-28", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-28", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-28", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-28", "key": "13", "rows": 744}, {"kind": "inventory", "date": "2026-10-28", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-28", "key": "15", "rows": 1575}, {"kind": "inventory", "date": "2026-10-28", "key": "16", "rows": 276}, {"kind": "inventory", "date": "2026-10-28", "key": "20", "rows": 9393}, {"kind": "inventory", "date": "2026-10-28", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-28", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-28", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-28", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-28", "key": "29", "rows": 700}, {"kind": "inventory", "date": "2026-10-28", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-27", "key": "wdt", "rows": 2186}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||||
|
{"checked_at": "2026-10-29T09:15:23.959650+08:00", "version": "b0cac8db89c53cafba0449fb85717cd53a9a70f0ac356bfcb1bb12582edf217d", "target_inventory": "2026-10-29", "target_outbound": "2026-10-28", "missing": ["outbound:2026-10-28:dymuto", "outbound:2026-10-28:dyqsimelr", "outbound:2026-10-28:jdbz", "outbound:2026-10-28:jdgj", "outbound:2026-10-28:jdjwt", "outbound:2026-10-28:jdmrt", "outbound:2026-10-28:jdsmj", "outbound:2026-10-28:tmgj"], "state": "success", "missing_policy": "skip", "changed": [{"kind": "inventory", "date": "2026-10-29", "key": "01", "rows": 2384}, {"kind": "inventory", "date": "2026-10-29", "key": "02", "rows": 1764}, {"kind": "inventory", "date": "2026-10-29", "key": "04", "rows": 617}, {"kind": "inventory", "date": "2026-10-29", "key": "05", "rows": 1458}, {"kind": "inventory", "date": "2026-10-29", "key": "06", "rows": 911}, {"kind": "inventory", "date": "2026-10-29", "key": "07", "rows": 348}, {"kind": "inventory", "date": "2026-10-29", "key": "09", "rows": 473}, {"kind": "inventory", "date": "2026-10-29", "key": "10", "rows": 220}, {"kind": "inventory", "date": "2026-10-29", "key": "11", "rows": 242}, {"kind": "inventory", "date": "2026-10-29", "key": "12", "rows": 314}, {"kind": "inventory", "date": "2026-10-29", "key": "13", "rows": 745}, {"kind": "inventory", "date": "2026-10-29", "key": "14", "rows": 382}, {"kind": "inventory", "date": "2026-10-29", "key": "15", "rows": 1577}, {"kind": "inventory", "date": "2026-10-29", "key": "16", "rows": 277}, {"kind": "inventory", "date": "2026-10-29", "key": "20", "rows": 9417}, {"kind": "inventory", "date": "2026-10-29", "key": "24", "rows": 182}, {"kind": "inventory", "date": "2026-10-29", "key": "25", "rows": 88}, {"kind": "inventory", "date": "2026-10-29", "key": "26", "rows": 78}, {"kind": "inventory", "date": "2026-10-29", "key": "27", "rows": 59}, {"kind": "inventory", "date": "2026-10-29", "key": "29", "rows": 702}, {"kind": "inventory", "date": "2026-10-29", "key": "35", "rows": 19}, {"kind": "outbound", "date": "2026-10-28", "key": "wdt", "rows": 2151}], "cache_refreshed": true, "files": 22, "coverage": {"inventory": 21, "outbound": 1}, "desktop_notification": "delivered"}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"inventory:2026-10-28:01":"ee9ffde70d78afd9d4d27d6f1b22e9e0022fdd9c86e5c2c67d7d83f007503993","inventory:2026-10-28:02":"b6f32ab450d9b2f724a2b9ff161fbc1531efd9b279e6d09781d4b3bcb1f1d88f","inventory:2026-10-28:04":"d6ed21cd34cd5c2233762997ab25a36749c50d3e7e21f23451a6ec4f007c8f0e","inventory:2026-10-28:05":"46fe52b48ab93cab631ab1953ddb5c8726ffba25ac041b70bae4c3c6cd0b3e47","inventory:2026-10-28:06":"f0c8c9ead0853fc85c9cf26fe5a507b7aec0d3f2239a104b9b27333d84437198","inventory:2026-10-28:07":"8ac84fc6bedf774a7508ccbeeceb1390157876e326a0a6a2c03ad06e5d050ac1","inventory:2026-10-28:09":"2f0b19ffda192f152a43518da4e45e181a39fb93c790c1d7beaf15dfae57d25f","inventory:2026-10-28:10":"1dce710dbce65d875380ff6ebbc71eaa9b08334be2a86d49bab0b0c8699089ef","inventory:2026-10-28:11":"6b4271d5400dff848b73e41ae9cae8b3e9c4ae5dadf827e55a7ffb8359646078","inventory:2026-10-28:12":"36eccf226239325947a7e174a122d0915e7539e8219b5fb6c7377a31977b8ef6","inventory:2026-10-28:13":"f81ca4914ef3d5b3dcc1a5a89db8e3dbfafec2a06ceea6e165f7b3af44faef96","inventory:2026-10-28:14":"042427a0adf4670d4081d1ca290f85c85540a2b5cdc89ec258ef37aba5616e6e","inventory:2026-10-28:15":"62bc97bbb873b36e2ddddbf28910a4dc4bc8ba4d03bbf499d8ac5ca94adb6ea1","inventory:2026-10-28:16":"921f538bcf32911b784574dae5a1ddad961896fb3f85c5bf6a60adb56b08695e","inventory:2026-10-28:20":"646999613cdddee716b26e44c8df672172b0127b221e1e7730ff6e2867a4c662","inventory:2026-10-28:24":"9447b4d1792fb51f33a1dd197e449a79681403635f1c4aceaec063318c38f1e6","inventory:2026-10-28:25":"db3a781aee1ef0b9b645fcee4841d8e1a4eec508239765175566bb3a86cd2d02","inventory:2026-10-28:26":"36a8c7b6bb21d6a7bc94c593eb51cd0a6d352bea77d770582ef6abfea62be05d","inventory:2026-10-28:27":"5e06568d122df8724d8f4494a12930dedd74a4d29a417046111e74f65848b4f0","inventory:2026-10-28:29":"205a39cf7ec53a49e69e7c62844b0843dd38d6ad743787af510821cc815edfd0","inventory:2026-10-28:35":"e31341fd65088cf85008539e52923049acdd03d00aee97ea785751f2055c95f6","outbound:2026-10-27:wdt":"45dbce5b64f9f35e48475ec85ed3e91fe764c4472450384a59358699aa316883"}
|
{"inventory:2026-10-29:01":"a0756f317b28e6f648e431cd44a894a92ba90dfce9f58508a1c774f9ad6ef0d0","inventory:2026-10-29:02":"ac4b8c3f4bc44f4d13030f253227268f43c7b94821878d1a90c7cecfe1b23cda","inventory:2026-10-29:04":"c899c38489e0f0e6cb35c6d208160bab84bb8653c0ca36a9a0e91ad70eb488f8","inventory:2026-10-29:05":"b6b6a51d9f9806012dae7cfa7701784ee739bc4476d8dde1906b8407a9c9b591","inventory:2026-10-29:06":"ed6fa1662393aa01a9666d5818deeb5cf6fe1b404f569bca83f0a6da50ca0504","inventory:2026-10-29:07":"bd0c62dc8b80a20b0b293e1b9944d45f3f30979750fa4770d3f77f2b2c0e2ace","inventory:2026-10-29:09":"f87c59ef82cc52eba6fba5dfeb519bb75b6f2e4dfaba08a2fa2e4a27a1d6a150","inventory:2026-10-29:10":"bcf8ff6f78ffc223ee6125df2d2b489008424daa1ce2a7f56314620fa4db8cbb","inventory:2026-10-29:11":"dd236bf42adf9b5ec05d5b0109ebef92d8a173fa2285551df38102c2bd5f6818","inventory:2026-10-29:12":"85b18fd3486a8bf6a10de9fd3dfbfc23ea3d3a28371442fd8c058cd954837f36","inventory:2026-10-29:13":"cda0408e658ee9b21e1b67ce189f03ea30ad8be4f9b091a91ccc69992df1cfb3","inventory:2026-10-29:14":"e8bbf760e54c1865b6c02c24259e095d07cb13e7107bb45cd0739ef606c958f7","inventory:2026-10-29:15":"a479de395b7d343952059c58d035bf9245760cf9781460ffb3c2f4212a72fe0b","inventory:2026-10-29:16":"7e2ec0676667fae020ebd26517e404c26b0ceb91f1ab56a460ea7d96809bd449","inventory:2026-10-29:20":"cb5ad1bed854158f723ade267cadcdd44d633015308b13deb97836b2252beb9d","inventory:2026-10-29:24":"7e2a4ee729327513326f2b20f7995a90a5582cf886c921b1d618b5f95fcf60ef","inventory:2026-10-29:25":"7d71ff3dca7adf3069feca3229eac7c59efc5990790219d89ec980d9eb8b4150","inventory:2026-10-29:26":"c297c48aa84a8424a3f7665e2b7eb070df3cce57462900dd797975715f09034f","inventory:2026-10-29:27":"0583cebd82db2a92fa88c3cf989460e3cff37e370ed9eef27e4d373d72325da6","inventory:2026-10-29:29":"14e00982e3e067f8b12a955a1ef8d018aa0dd2b3f56e064eb6b1af3e5b758c6f","inventory:2026-10-29:35":"3390cf1fe2b74c34f6e1f1a421c6e7af3727e0e09e17e046bbc75eab4af4b7e7","outbound:2026-10-28:wdt":"3daad2fd47d7d4c503134f42033027aa4cb6b765078fef214c0cab1bdda78b71"}
|
||||||
@ -1 +1 @@
|
|||||||
{"cache_refreshed":true,"changed":[{"date":"2026-10-28","key":"01","kind":"inventory","rows":2381},{"date":"2026-10-28","key":"02","kind":"inventory","rows":1764},{"date":"2026-10-28","key":"04","kind":"inventory","rows":617},{"date":"2026-10-28","key":"05","kind":"inventory","rows":1458},{"date":"2026-10-28","key":"06","kind":"inventory","rows":911},{"date":"2026-10-28","key":"07","kind":"inventory","rows":348},{"date":"2026-10-28","key":"09","kind":"inventory","rows":473},{"date":"2026-10-28","key":"10","kind":"inventory","rows":220},{"date":"2026-10-28","key":"11","kind":"inventory","rows":242},{"date":"2026-10-28","key":"12","kind":"inventory","rows":314},{"date":"2026-10-28","key":"13","kind":"inventory","rows":744},{"date":"2026-10-28","key":"14","kind":"inventory","rows":382},{"date":"2026-10-28","key":"15","kind":"inventory","rows":1575},{"date":"2026-10-28","key":"16","kind":"inventory","rows":276},{"date":"2026-10-28","key":"20","kind":"inventory","rows":9393},{"date":"2026-10-28","key":"24","kind":"inventory","rows":182},{"date":"2026-10-28","key":"25","kind":"inventory","rows":88},{"date":"2026-10-28","key":"26","kind":"inventory","rows":78},{"date":"2026-10-28","key":"27","kind":"inventory","rows":59},{"date":"2026-10-28","key":"29","kind":"inventory","rows":700},{"date":"2026-10-28","key":"35","kind":"inventory","rows":19},{"date":"2026-10-27","key":"wdt","kind":"outbound","rows":2186}],"checked_at":"2026-10-28T09:15:22.895734+08:00","coverage":{"inventory":21,"outbound":1},"desktop_notification":"delivered","files":22,"missing":["outbound:2026-10-27:dymuto","outbound:2026-10-27:dyqsimelr","outbound:2026-10-27:jdbz","outbound:2026-10-27:jdgj","outbound:2026-10-27:jdjwt","outbound:2026-10-27:jdmrt","outbound:2026-10-27:jdsmj","outbound:2026-10-27:tmgj"],"missing_policy":"skip","state":"success","target_inventory":"2026-10-28","target_outbound":"2026-10-27","version":"1f6f2d3dc0380882c212c94c6f27c31495c011d7f9a537dd4bb32499d6758c83"}
|
{"cache_refreshed":true,"changed":[{"date":"2026-10-29","key":"01","kind":"inventory","rows":2384},{"date":"2026-10-29","key":"02","kind":"inventory","rows":1764},{"date":"2026-10-29","key":"04","kind":"inventory","rows":617},{"date":"2026-10-29","key":"05","kind":"inventory","rows":1458},{"date":"2026-10-29","key":"06","kind":"inventory","rows":911},{"date":"2026-10-29","key":"07","kind":"inventory","rows":348},{"date":"2026-10-29","key":"09","kind":"inventory","rows":473},{"date":"2026-10-29","key":"10","kind":"inventory","rows":220},{"date":"2026-10-29","key":"11","kind":"inventory","rows":242},{"date":"2026-10-29","key":"12","kind":"inventory","rows":314},{"date":"2026-10-29","key":"13","kind":"inventory","rows":745},{"date":"2026-10-29","key":"14","kind":"inventory","rows":382},{"date":"2026-10-29","key":"15","kind":"inventory","rows":1577},{"date":"2026-10-29","key":"16","kind":"inventory","rows":277},{"date":"2026-10-29","key":"20","kind":"inventory","rows":9417},{"date":"2026-10-29","key":"24","kind":"inventory","rows":182},{"date":"2026-10-29","key":"25","kind":"inventory","rows":88},{"date":"2026-10-29","key":"26","kind":"inventory","rows":78},{"date":"2026-10-29","key":"27","kind":"inventory","rows":59},{"date":"2026-10-29","key":"29","kind":"inventory","rows":702},{"date":"2026-10-29","key":"35","kind":"inventory","rows":19},{"date":"2026-10-28","key":"wdt","kind":"outbound","rows":2151}],"checked_at":"2026-10-29T09:15:23.959650+08:00","coverage":{"inventory":21,"outbound":1},"desktop_notification":"delivered","files":22,"missing":["outbound:2026-10-28:dymuto","outbound:2026-10-28:dyqsimelr","outbound:2026-10-28:jdbz","outbound:2026-10-28:jdgj","outbound:2026-10-28:jdjwt","outbound:2026-10-28:jdmrt","outbound:2026-10-28:jdsmj","outbound:2026-10-28:tmgj"],"missing_policy":"skip","state":"success","target_inventory":"2026-10-29","target_outbound":"2026-10-28","version":"b0cac8db89c53cafba0449fb85717cd53a9a70f0ac356bfcb1bb12582edf217d"}
|
||||||
0
var/stagnant-monthly-snapshot.lock
Normal file
0
var/stagnant-monthly-snapshot.lock
Normal file
Loading…
x
Reference in New Issue
Block a user