mirror of
https://github.com/rnvm9wjdtj-bot/myaps_api.git
synced 2026-06-02 05:54:40 +00:00
缓冲表实现bomchecker
This commit is contained in:
@@ -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:
|
||||
"""批处理执行结果汇总"""
|
||||
|
||||
@@ -88,9 +88,6 @@ class StagingBaseModel(TortoiseBaseModel):
|
||||
# 工具函数
|
||||
# ==============================================
|
||||
|
||||
NONE_AND_EMPTY = {None, ""}
|
||||
|
||||
|
||||
def get_field_map(model_class: Type[TortoiseBaseModel]) -> Dict[str, str]:
|
||||
"""
|
||||
获取模型的字段映射:Python字段名(小写) -> 数据库字段名(大驼峰)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
@@ -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表的插入事件"""
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏状态错误提示框
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user