缓冲表实现bomchecker

This commit is contained in:
2026-05-20 19:37:26 +08:00
parent 8d0970d58e
commit e6ac98463b
13 changed files with 510 additions and 71 deletions
+27 -3
View File
@@ -18,11 +18,13 @@ from collections import defaultdict
from datetime import date, datetime, timedelta
from pydantic import BaseModel as PydanticModel
import uuid
from globalobjects import logger as log_config, ProjectDefaultValues as pdv, StaticString as ce
from dataclasses import dataclass, field
from core.settings import THIS_BASE_URL, MYAPS_MAIN_DB, MYAPS_DB_SET
from apps.io_api.utils.db_operation import db_exec_sql, DbResult, MultiDbResult
from apps.io_api.utils.db_operation import db_query, db_update_by_index, db_query, db_delete, db_bupsert, call_dbprocdure
from globalobjects import logger as log_config, ProjectDefaultValues as pdv, StaticString as ce
from apps.io_api.utils.db_operation import db_exec_sql, db_query, db_update_by_index, db_query, db_delete, db_bupsert, call_dbprocdure, DbResult, MultiDbResult
from apps.io_api.models import TBatchLog
@@ -1758,6 +1760,28 @@ class ApsPayloadSponsor:
return result
@classmethod
async def add_batchlog(cls, pidno: str, strategy: str, target: Literal['RECE', 'PROC', 'SUCC', 'FAIL'], memo: str=""):
memo = {
"RECE": "Received已接收",
"PROC": "Processing处理中",
"SUCC": "Success 成功",
"FALI": f"Failed {memo}"
}.get(target)
await TBatchLog.create(
systime=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
pidno=pidno,
task="API",
strategy=strategy,
target=target,
memo=memo
)
@dataclass
class BatchSummary:
"""批处理执行结果汇总"""
-3
View File
@@ -88,9 +88,6 @@ class StagingBaseModel(TortoiseBaseModel):
# 工具函数
# ==============================================
NONE_AND_EMPTY = {None, ""}
def get_field_map(model_class: Type[TortoiseBaseModel]) -> Dict[str, str]:
"""
获取模型的字段映射Python字段名(小写) -> 数据库字段名(大驼峰)
+48 -13
View File
@@ -11,7 +11,7 @@ from tortoise import Tortoise
from tortoise.models import Model
from ._base import (
StagingStatus, ErrorType, NONE_AND_EMPTY,
StagingStatus, ErrorType,
get_field_map, extract_defaults_from_schema, extract_required_fields,
extract_enum_fields, extract_range_fields, extract_max_length_fields,
extract_business_keys_from_model, extract_display_name_from_model,
@@ -28,7 +28,7 @@ from apps.io_api.models import (
)
from apps.io_api.schemas import AcceptMaterial, AcceptWorkcenter, AcceptMatVer, AcceptMatWc, AcceptMatWcBom, AcceptMold, AcceptMatWcMold
from globalobjects import logger as log_config, globalconst as gc, ProjectDefaultValues as pdv
from .validators import validate_material_type_e_rules
from .validators import validate_material_type_e_rules, bom_structure_check_hook
logger = log_config.get_logger(__name__)
@@ -169,6 +169,7 @@ STAGING_TABLE_CONFIG = {
create_positive_rule("qty", "用量必须大于0"),
create_range_rule("scrap", 0, 100, "损耗率必须在0-100之间"),
],
"pre_batch_hook": [bom_structure_check_hook],
},
"t_mold": {
@@ -290,7 +291,7 @@ def fill_defaults(table_name: str, data: Dict[str, Any]) -> Dict[str, Any]:
current_value = result.get(field_name)
# 如果当前值是 None 或空字符串
if current_value in NONE_AND_EMPTY:
if current_value in gc.NONE_AND_EMPTY:
# 优先使用 SCHEMA_DEFAULTS 中的默认值
if field_name in defaults and defaults[field_name] is not None:
result[field_name] = defaults[field_name]
@@ -872,6 +873,9 @@ class StagingProcessor:
context.update(hook_result)
logger.debug(f"表级别前置钩子执行完成: {context}")
# 获取前置钩子的批量错误(用于后续合并)
batch_errors = context.get('batch_errors', {})
batch_count = 0
while batch_count < max_batches:
try:
@@ -915,14 +919,33 @@ class StagingProcessor:
logger.info(f"[校验] staging_id={staging_id}, 结果: is_valid={is_valid}, errors={len(errors)}")
# ========== 合并前置钩子错误 ==========
all_errors = []
has_batch_error = False
# 先添加前置钩子错误
if staging_id in batch_errors:
all_errors.extend(batch_errors[staging_id])
# 检查是否有严重的前置错误
has_batch_error = any(
e.get('error_type') == 'bom_structure_error'
for e in batch_errors[staging_id]
)
# 再添加本次校验错误
all_errors.extend(errors)
# 检查是否有填充的字段(与原始数据不同)
filled_fields = []
for key, filled_value in filled_data.items():
original_value = data.get(key)
if original_value in NONE_AND_EMPTY and filled_value not in NONE_AND_EMPTY:
if original_value in gc.NONE_AND_EMPTY and filled_value not in gc.NONE_AND_EMPTY:
filled_fields.append(key)
if is_valid:
# ========== 统一判断最终状态 ==========
final_is_valid = is_valid and not has_batch_error
if final_is_valid:
# 构建更新语句:更新状态和填充后的字段
if filled_fields:
set_clauses = ['"_status" = $1']
@@ -942,17 +965,29 @@ class StagingProcessor:
await conn.execute_query(update_query, ("relation_pass", staging_id))
stats["relation_pass"] += 1
else:
error_json = json.dumps(errors, ensure_ascii=False)
error_json = json.dumps(all_errors, ensure_ascii=False)
# 区分错误类型:检查是否包含外键关联错误
has_fk_error = any(error.get("error_type") == "fk_not_found" for error in errors)
if has_fk_error:
# 区分错误类型:优先显示合规错误,避免被外键错误覆盖
# 合规错误包括:bom_structure_error、required_field、invalid_range 等
# 联检错误主要是:fk_not_found
has_fk_error = any(error.get("error_type") == "fk_not_found" for error in all_errors)
has_compliance_error = any(
error.get("error_type") not in ["fk_not_found", "bom_structure_warning"]
for error in all_errors
)
# 状态优先级:compliance_error > relation_error
if has_compliance_error:
status = "compliance_error"
stats["compliance_error"] = (stats.get("compliance_error") or 0) + 1
elif has_fk_error:
status = "relation_error"
stats["relation_error"] += 1
else:
# 其他错误都是合规错误
status = "compliance_error"
stats["compliance_error"] = (stats.get("compliance_error") or 0) + 1
# 只有警告的情况
status = "relation_pass"
stats["relation_pass"] += 1
# 更新状态、错误信息,以及填充的字段
if filled_fields:
@@ -970,7 +1005,7 @@ class StagingProcessor:
else:
update_query = f'UPDATE "{table_name_staging}" SET "_status" = $1, "_error_msg" = $2 WHERE "_staging_id" = $3'
await conn.execute_query(update_query, (status, error_json, staging_id))
await self.cleaner.save_errors(table_name, errors)
await self.cleaner.save_errors(table_name, all_errors)
except Exception as e:
import traceback
+3 -3
View File
@@ -53,7 +53,7 @@ def create_staging_endpoint(table_key: str, config: Dict):
):
"""接收外部系统的{config['display_name']}数据,支持去重"""
try:
from apps.data_opt.utils.duplicate_checker import apply_dedup_strategy, DedupStrategy
from .utils.duplicate_checker import apply_dedup_strategy, DedupStrategy
# 应用去重策略
strategy = DedupStrategy(dedup_strategy)
@@ -955,8 +955,8 @@ async def upload_excel(
):
"""上传Excel文件并导入缓冲表,支持去重"""
try:
from apps.data_opt.utils.excel_parser import get_parser_for_table
from apps.data_opt.utils.duplicate_checker import apply_dedup_strategy, DedupStrategy
from .utils.excel_parser import get_parser_for_table
from .utils.duplicate_checker import apply_dedup_strategy, DedupStrategy
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
+191 -20
View File
@@ -1,28 +1,13 @@
import json
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple, Type
from enum import Enum
from tortoise import Tortoise
from tortoise.models import Model
from ._base import (
StagingStatus, ErrorType, NONE_AND_EMPTY,
get_field_map, extract_defaults_from_schema, extract_required_fields,
extract_enum_fields, extract_range_fields, extract_max_length_fields,
extract_business_keys_from_model, extract_display_name_from_model,
BusinessRule, create_comparison_rule, create_range_rule,
create_positive_rule, create_not_equal_rule,
)
from ._base import ErrorType
from .staging_models import (
ValidationError, TransformRule,
TMaterialStaging, TWorkcenterStaging, TMatVerStaging,
TMatWcStaging, TMatWcBomStaging, TMoldStaging, TMatWcMoldStaging,
)
from apps.io_api.models import (
TMaterial, TWorkcenter, TMatVer, TMatWc, TMatWcBom, TMold, TMatWcMold
)
from apps.io_api.schemas import AcceptMaterial, AcceptWorkcenter, AcceptMatVer, AcceptMatWc, AcceptMatWcBom, AcceptMold, AcceptMatWcMold
# from apps.io_api.models import (
# TMaterial, TWorkcenter, TMatVer, TMatWc, TMatWcBom, TMold, TMatWcMold
# )
# from apps.io_api.schemas import AcceptMaterial, AcceptWorkcenter, AcceptMatVer, AcceptMatWc, AcceptMatWcBom, AcceptMold, AcceptMatWcMold
from globalobjects import logger as log_config, globalconst as gc, ProjectDefaultValues as pdv
@@ -134,3 +119,189 @@ async def validate_material_type_e_rules(cleaner, data, staging_id):
# ))
# return errors
async def bom_structure_check_hook(processor, table_name: str, context: dict) -> dict:
"""
BOM结构完整性校验钩子
使用 bomchecker BOM 数据进行结构校验和单位一致性检查
校验内容:
- 循环引用检测
- 父子同号检查
- 孤立项目检测
- 多父项检查
- 单位一致性检查
Args:
processor: StagingProcessor实例
table_name: 表名
context: 上下文字典
Returns:
更新后的context
"""
from apps.data_opt.utils.bomchecker import BOMChecker
from tortoise import Tortoise
conn = Tortoise.get_connection(processor.db_name)
logger.info(f"[BOM校验钩子] 开始加载 {table_name} 数据")
query = '''
SELECT "_staging_id", "ProductNo", "MaterialNo", "Qty", "MatVer", "ItemNo",
"ProductUnit", "MaterialUnit"
FROM t_mat_wc_bom_staging
WHERE "_status" IN ('pending', 'relation_pass')
'''
result = await conn.execute_query(query)
records = result[1] if result[1] else []
if not records:
logger.info(f"[BOM校验钩子] 无待校验数据")
return context
logger.info(f"[BOM校验钩子] 加载 {len(records)} 条数据")
staging_id_map = {}
bom_data = []
for row in records:
row_dict = dict(row)
staging_id = row_dict['_staging_id']
business_key = (
str(row_dict['ProductNo'] or ''),
str(row_dict['MaterialNo'] or ''),
str(row_dict['MatVer'] or ''),
str(row_dict.get('ItemNo', '') or '')
)
staging_id_map[business_key] = staging_id
bom_data.append({
'productno': row_dict['ProductNo'],
'materialno': row_dict['MaterialNo'],
'qty': row_dict['Qty'] or 0,
'matver': row_dict['MatVer'],
'itemno': row_dict.get('ItemNo', ''),
'productunit': row_dict.get('ProductUnit'),
'materialunit': row_dict.get('MaterialUnit'),
})
checker = BOMChecker(
parent_col="productno",
child_col="materialno",
numerator_col="qty",
parentversion_col="matver",
parentunit_col="productunit",
childunit_col="materialunit"
)
logger.info(f"[BOM校验钩子] 开始执行BOM结构校验")
check_result = checker.start_check(bom_data)
if not check_result.get('success'):
logger.error(f"[BOM校验钩子] 校验执行失败: {check_result.get('message')}")
context['bom_check_error'] = check_result.get('message')
return context
marked_data = check_result.get('marked_data', [])
statistics = check_result.get('statistics', {})
logger.info(f"[BOM校验钩子] 校验完成 - 总计:{statistics.get('total_records', 0)}, "
f"错误:{statistics.get('error_records', 0)}, "
f"警告:{statistics.get('warning_records', 0)}")
batch_errors = {}
error_count = 0
warning_count = 0
for item in marked_data:
business_key = (
str(item.get('productno') or ''),
str(item.get('materialno') or ''),
str(item.get('matver') or ''),
str(item.get('itemno', '') or '')
)
staging_id = staging_id_map.get(business_key)
if not staging_id:
continue
errors = item.get('E', '')
warnings = item.get('W', '')
if not errors and not warnings:
continue
batch_errors[staging_id] = []
if errors:
batch_errors[staging_id].append({
'staging_id': staging_id,
'error_type': 'bom_structure_error',
'error_field': 'bom_structure',
'error_value': None,
'error_message': errors
})
error_count += 1
if warnings:
batch_errors[staging_id].append({
'staging_id': staging_id,
'error_type': 'bom_structure_warning',
'error_field': 'bom_structure',
'error_value': None,
'error_message': warnings
})
warning_count += 1
logger.info(f"[BOM校验钩子] 发现问题 - 错误:{error_count}条, 警告:{warning_count}条 (未写入数据库,将在后续校验中合并)")
# 处理单位不一致问题
unit_result = checker.unit_result
unit_stats = {}
if unit_result and unit_result.get('exec_success'):
unit_summary = unit_result.get('summary', {})
unit_stats = {
'total_materials': unit_summary.get('total_unique_materials', 0),
'unified_materials': unit_summary.get('unified_materials_count', 0),
'problematic_materials': unit_summary.get('problematic_materials_count', 0),
'pass_rate': unit_summary.get('pass_rate_percent', 0)
}
logger.info(f"[BOM校验钩子] 单位校验 - 总计:{unit_stats['total_materials']}, "
f"通过率:{unit_stats['pass_rate']}%")
# 将单位不一致问题写入 batch_errors
problematic_details = unit_result.get('problematic_details', [])
for detail in problematic_details:
material_number = detail.get('material_number', '')
unit_distribution = detail.get('unit_distribution', {})
# 找到所有涉及该物料的记录
for business_key, sid in staging_id_map.items():
productno, materialno, matver, itemno = business_key
if productno == material_number or materialno == material_number:
if sid not in batch_errors:
batch_errors[sid] = []
# 避免重复添加同一物料的单位警告
existing_msg = [e.get('error_message', '') for e in batch_errors[sid]]
unit_msg = f"物料 {material_number} 单位不一致: {dict(unit_distribution)}"
if not any('单位不一致' in msg for msg in existing_msg):
batch_errors[sid].append({
'staging_id': sid,
'error_type': 'unit_inconsistency',
'error_field': 'productunit' if productno == material_number else 'materialunit',
'error_value': str(unit_distribution),
'error_message': unit_msg
})
warning_count += 1
context['batch_errors'] = batch_errors
context['bom_check_result'] = {
'total': statistics.get('total_records', 0),
'errors': error_count,
'warnings': warning_count,
'unit_stats': unit_stats
}
return context
+8
View File
@@ -156,6 +156,14 @@ class TConfirm(pm.ProtoConfirm):
table = "t_confirm"
class TBatchLog(pm.ProtoBatchLog):
class Meta:
managed = False
abstract = False
table = "t_batchlog"
def get_table_model_mapping():
"""
+16 -1
View File
@@ -3,6 +3,21 @@ from tortoise.models import Model as TortoiseBaseModel
from tortoise import fields
class ProtoBatchLog(TortoiseBaseModel):
id = fields.IntField(source_field='ID', pk=True)
systime = fields.DatetimeField(source_field='SysTime', null=True) # Field name made lowercase.
pidno = fields.CharField(source_field='PIDNO', max_length=32, null=True, description="ProfileNo") # Field name made lowercase.
task = fields.CharField(source_field='Task', max_length=1000, null=True, description="任务") # Field name made lowercase.
strategy = fields.CharField(source_field='Strategy', max_length=32, null=True, description="策略") # Field name made lowercase.
target = fields.CharField(source_field='Target', max_length=1000, null=True, description="对象") # Field name made lowercase.
memo = fields.CharField(source_field='Memo', max_length=255, blank=True, null=True) # Field name made lowercase.
class Meta:
abstract = True
table = 't_batchlog'
class ProtoCalendar(TortoiseBaseModel):
shiftdate = fields.DateField(source_field='ShiftDate', unique=True) # Field name made lowercase.
shiftno = fields.CharField(source_field='ShiftNo', max_length=4) # Field name made lowercase.
@@ -12,7 +27,7 @@ class ProtoCalendar(TortoiseBaseModel):
class Meta:
abstract = True
table = 't_calendar'
# abstract = True # 设置为抽象模型,不直接关联特定数据库
class ProtoCapReport(TortoiseBaseModel):
+19 -3
View File
@@ -148,7 +148,7 @@ async def refresh_stock(dbs: str=MYAPS_DB_SET):
df_sap_st['itemno'] = pdv.ITEMNO
except Exception as e:
CLIENT_LOGGER.fail("SAP库存获取", "", str(e))
df_sap_st = None
raise
return df_sap_st
CLIENT_LOGGER.start("刷新库存任务")
@@ -206,7 +206,10 @@ async def push_monthpr_to_srm():
@cron_task(hour=SCHEDULER_HOUR, minute=get_scheduler_minute(), description="刷新库存数据")
async def task_refresh_stock():
await refresh_stock()
try:
await refresh_stock()
except Exception as e:
pass
@cron_task(hour=SCHEDULER_HOUR, minute=get_scheduler_minute(2), description="确认报工")
@@ -231,7 +234,6 @@ async def task_push_seasonpr_to_srm():
from .remind import ops_reminder, bus_reminder
@event_batch_handler(reminder=bus_reminder)
async def batch_handle_pl_status_a2e(event_data_list: List[Dict], _erp: EventResultPoster, description="PL 单据下达"):
"""
@@ -319,3 +321,17 @@ async def batch_handle_pl_status_a2e(event_data_list: List[Dict], _erp: EventRes
cache = await _aps.establish_production_cache(supplynos=supply_nos)
tasks = [handle_pl_status_a2e(event_data=item, _aps=_aps) for item in event_data_list]
await asyncio.gather(*tasks, return_exceptions=True)
async def batch_handle_new_batchlog(event_data_list: List[Dict]):
batchlog = event_data_list[0]
pidno = batchlog['pidno']
strategy = batchlog['strategy']
await ApsPayloadSponsor.add_batchlog(pidno=pidno, strategy=strategy, target='RECE')
try:
if strategy == '库存':
await refresh_stock()
await ApsPayloadSponsor.add_batchlog(pidno=pidno, strategy=strategy, target='SUCC')
except Exception as e:
await ApsPayloadSponsor.add_batchlog(pidno=pidno, strategy=strategy, target='FAIL', memo=str(e))
+15 -2
View File
@@ -17,11 +17,11 @@ load_dotenv(env_file)
# 导入模块
from core.settings import MYAPS_MAIN_DB, THIS_BASE_URL, MYAPS_DB_SET, MYAPS_DBSET_LIST, PROJECT_DIR
from globalobjects.globalconst import OrderStatusEnum
from apps.io_api.utils.common import dict_to_lower_keys
from globalobjects import logger as log_config
from apps.io_api.utils.common import dict_to_lower_keys
from apps.data_opt.utils.scheduler import cron_task
from apps.data_opt.components import ApsPayloadSponsor
from apps.data_opt.utils.common import get_optimized_session
from apps.data_opt.components import ApsPayloadSponsor
from apps.common.utils.redis_pool_manager import get_redis_pool_manager
@@ -66,6 +66,7 @@ class DbEventType(Enum):
PL_TYPETO_MO = "pl_to_mo" # PL类型变为 MO
PR_STATUS_A2E = "pr_status_a2e" # PR状态变为 A2E
PR_DELETED = "pr_deleted" # PR 删除
NEW_BATCHLOG = "new_batchlog" # 新的 一键通排 记录
# 模块级变量,用于跟踪事件是否已经注册
@@ -160,6 +161,7 @@ if not _events_registered:
aps_pr_status_a2e_event = ApsEvent(event_type=DbEventType.PR_STATUS_A2E, description="PR 单据 下达")
aps_pl_typeto_mo_event = ApsEvent(event_type=DbEventType.PL_TYPETO_MO, description="PL 变更为 MO")
aps_pr_deleted_event = ApsEvent(event_type=DbEventType.PR_DELETED, description="PR 单据 删除")
aps_new_batchlog_event = ApsEvent(event_type=DbEventType.NEW_BATCHLOG, description="新的 一键通排 记录", batch_size=1, quiet_window=1)
_events_registered = True
logger.success("数据库事件注册", "", "所有事件已成功注册")
@@ -198,6 +200,17 @@ def handle_update_supply(database: str, table: str, data: dict, data_diff: dict)
logger.fail("处理t_supply更新事件", "", str(e))
@binlog_listener.on_insert_for_table("t_batchlog", database=MYAPS_MAIN_DB)
def handle_insert_batchlog(database: str, table: str, data: dict):
"""处理t_batchlog表的插入事件"""
try:
new_data = dict_to_lower_keys(data['new'])
task = new_data['task']
if task == 'API':
aps_new_batchlog_event.add_event(new_data)
except Exception as e:
logger.fail("处理t_batchlog插入事件", "", str(e))
# @binlog_listener.on_insert_for_table("t_supply", database=MYAPS_MAIN_DB)
# def handle_insert_supply(database: str, table: str, data: dict):
# """处理t_supply表的插入事件"""
+78 -8
View File
@@ -592,6 +592,7 @@ span.badge.status-badge.status-badge-sync_error {
background-color: #ffcccc;
color: #cc0000;
border: 1px solid #dc3545;
border-radius: 2px;
cursor: help;
animation: enum-error-breathe 3s ease-in-out infinite;
}
@@ -622,13 +623,15 @@ span.badge.status-badge.status-badge-sync_error {
background-color: #ffcccc;
color: #cc0000;
border: 1px solid #dc3545;
border-radius: 2px;
cursor: help;
animation: enum-error-breathe 3s ease-in-out infinite;
}
/* 错误单元格高亮 */
/* 错误单元格高亮 - 统一样式 */
.error-cell {
background-color: #ffe6e6 !important;
background-color: #ffcccc !important;
color: #cc0000;
border: 1px solid #dc3545;
border-radius: 2px;
padding: 0.1rem 0.3rem;
@@ -639,12 +642,12 @@ span.badge.status-badge.status-badge-sync_error {
@keyframes error-cell-breathe {
0%, 100% {
background-color: #ffe6e6;
background-color: #ffcccc;
border-color: #dc3545;
box-shadow: 0 0 0 rgba(220, 53, 69, 0);
}
50% {
background-color: #ffcccc;
background-color: #ffe6e6;
border-color: #ff6666;
box-shadow: 0 0 6px rgba(220, 53, 69, 0.4);
}
@@ -695,8 +698,8 @@ span.badge.status-badge.status-badge-sync_error {
}
.error-tooltip-wide {
max-width: 400px;
min-width: 300px;
max-width: 520px;
min-width: 400px;
}
.error-tooltip-type {
@@ -726,8 +729,8 @@ span.badge.status-badge.status-badge-sync_error {
.error-tooltip-divider {
border: none;
border-top: 1px solid #555;
margin: 0.5rem 0;
border-top: 1px solid #444;
margin: 0.3rem 0;
}
.error-tooltip-json {
@@ -739,6 +742,73 @@ span.badge.status-badge.status-badge-sync_error {
line-height: 1.2;
}
/* 错误分组样式 - 错误类型横向排列,具体错误纵向展示 */
.error-groups-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.error-group {
flex: 1;
min-width: 200px;
max-width: 280px;
}
.error-group-header {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.25rem;
padding-bottom: 0.15rem;
border-bottom: 1px solid #444;
}
.error-group-type {
font-weight: bold;
color: #ff6b6b;
text-transform: uppercase;
font-size: 0.7rem;
}
.error-group-badge {
background-color: #ff6b6b;
color: #fff;
padding: 0.05rem 0.3rem;
border-radius: 2px;
font-size: 0.6rem;
font-weight: normal;
}
.error-group-items {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.error-group-item {
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.error-field-tag {
background-color: rgba(255, 255, 255, 0.15);
padding: 0.05rem 0.25rem;
border-radius: 2px;
font-size: 0.6rem;
color: #aaa;
white-space: nowrap;
flex-shrink: 0;
}
.error-msg-text {
color: #ddd;
font-size: 0.65rem;
line-height: 1.3;
flex: 1;
}
/* 表单渲染器样式 */
.form-field-wrapper {
margin-bottom: 0.5rem;
+105 -15
View File
@@ -805,22 +805,59 @@ class DataTable {
try {
const errors = JSON.parse(errorJson);
tooltip.innerHTML = errors.map(err => {
// 支持多字段显示
let fieldHtml = '';
if (err.error_fields && Array.isArray(err.error_fields)) {
fieldHtml = `<div class="error-tooltip-field">字段: ${err.error_fields.join(', ')}</div>`;
} else if (err.error_field) {
fieldHtml = `<div class="error-tooltip-field">字段: ${err.error_field}</div>`;
// 按错误类型分组
const groupedErrors = {};
errors.forEach(err => {
const type = err.error_type || 'unknown';
if (!groupedErrors[type]) {
groupedErrors[type] = [];
}
return `
<div class="error-tooltip-item">
<div class="error-tooltip-type">${err.error_type || 'unknown'}</div>
${fieldHtml}
<div class="error-tooltip-msg">${escapeHtml(err.error_message || '')}</div>
groupedErrors[type].push(err);
});
// 生成分组后的HTML - 错误类型横向排列
let html = '<div class="error-groups-container">';
Object.keys(groupedErrors).forEach((type, index) => {
const typeErrors = groupedErrors[type];
const count = typeErrors.length;
// 错误类型标题(带数量)
html += `
<div class="error-group">
<div class="error-group-header">
<span class="error-group-type">${type}</span>
<span class="error-group-badge">${count}</span>
</div>
<div class="error-group-items">
`;
// 该类型下的具体错误(纵向排列)
typeErrors.forEach(err => {
let fieldHtml = '';
if (err.error_fields && Array.isArray(err.error_fields)) {
fieldHtml = `<span class="error-field-tag">${err.error_fields.join(', ')}</span>`;
} else if (err.error_field) {
fieldHtml = `<span class="error-field-tag">${err.error_field}</span>`;
}
html += `
<div class="error-group-item">
${fieldHtml}
<span class="error-msg-text">${escapeHtml(err.error_message || '')}</span>
</div>
`;
});
html += `
</div>
</div>
`;
}).join('<hr class="error-tooltip-divider">');
});
html += '</div>';
tooltip.innerHTML = html;
} catch (ex) {
tooltip.innerHTML = `<pre class="error-tooltip-json">${escapeHtml(errorJson)}</pre>`;
}
@@ -828,12 +865,65 @@ class DataTable {
document.body.appendChild(tooltip);
e.target._tooltip = tooltip;
// 智能定位逻辑
const rect = e.target.getBoundingClientRect();
tooltip.style.left = Math.min(rect.left, window.innerWidth - 410) + 'px';
tooltip.style.top = rect.bottom + 5 + 'px';
const tooltipRect = tooltip.getBoundingClientRect();
const tooltipWidth = 520; // 加宽以容纳横向排列的错误类型
const tooltipHeight = tooltipRect.height || 200;
const gap = 5; // 与触发元素的间距
// 计算水平位置
let left = rect.left;
// 如果右侧空间不足,向左展开
if (left + tooltipWidth > window.innerWidth - 10) {
left = Math.max(10, window.innerWidth - tooltipWidth - 10);
}
// 计算垂直位置
let top;
const spaceBelow = window.innerHeight - rect.bottom - gap;
const spaceAbove = rect.top - gap;
// 判断应该向上还是向下展开
if (spaceBelow >= tooltipHeight || spaceBelow >= spaceAbove) {
// 向下展开:提示框上缘对齐到触发元素下缘
top = rect.bottom + gap;
} else {
// 向上展开:提示框下缘对齐到触发元素上缘
top = rect.top - tooltipHeight - 2; // 留2px间隙
}
// 最终边界检查
top = Math.max(10, Math.min(top, window.innerHeight - tooltipHeight - 10));
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
tooltip.style.display = 'block';
}
/**
* 获取错误类型的中文标签
* @param {string} type - 错误类型
* @returns {string} 中文标签
*/
getErrorTypeLabel(type) {
const labels = {
'bom_structure_error': 'BOM结构错误',
'bom_structure_warning': 'BOM结构警告',
'unit_inconsistency': '单位不一致',
'fk_not_found': '外键引用缺失',
'required_field': '必填字段缺失',
'invalid_enum': '枚举值非法',
'invalid_type': '类型错误',
'invalid_range': '数值范围错误',
'invalid_length': '字符串长度超限',
'duplicate_key': '主键重复',
'business_rule': '业务规则违反',
'process_error': '处理异常'
};
return labels[type] || type;
}
/**
* 隐藏状态错误提示框
*/