初步搭建缓冲表功能

This commit is contained in:
2026-05-11 20:46:44 +08:00
parent f0ed88fdf8
commit 4229b64c6f
31 changed files with 5193 additions and 51 deletions
-41
View File
@@ -1,41 +0,0 @@
# from enum import unique
# from tortoise.models import Model as TortoiseBaseModel
# from tortoise import fields
# from core.settings import THIS_DB_NAME
# from apps.io_api import protomodels as pm
# class Storage(TortoiseBaseModel):
# id = fields.IntField(primary_key=True, description="主键")
# namespace = fields.CharField(max_length=64, description="命名空间")
# item = fields.CharField(max_length=256, description="项")
# content = fields.TextField(description="内容")
# remark = fields.TextField(null=True, description="备注")
# class Meta:
# database = THIS_DB_NAME
# table = "a_storage"
# class DataOptBaseModel(TortoiseBaseModel):
# _oid = fields.CharField(max_length=64, description="源数据主键")
# _id = fields.IntField(primary_key=True, description="主键")
# _createtime = fields.DatetimeField(auto_now_add=True, description="创建时间")
# _updatetime = fields.DatetimeField(auto_now=True, description="更新时间")
# _syncstatus = fields.IntField(default=0, description="同步状态")
# _synctime = fields.DatetimeField(null=True, description="同步时间")
# _sysprompt = fields.TextField(null=True, description="系统提示")
# class Meta:
# abstract = True
# class OptMaterial(DataOptBaseModel, pm.ProtoMaterial):
# class Meta:
# database = THIS_DB_NAME
# table = "opt_material"
# # 如果不希望ORM自动创建此表,取消下面这行的注释
# # managed = False
+672
View File
@@ -0,0 +1,672 @@
"""
数据清洗模块
包含字段校验、关联校验、数据转换等功能
"""
import json
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple, Type
from enum import Enum
from tortoise import Tortoise
from tortoise.models import Model
from apps.data_opt.staging_models import (
StagingStatus, ValidationError, TransformRule,
TMaterialStaging, TWorkcenterStaging, TMatVerStaging,
TMatWcStaging, TMatWcBomStaging, TMoldStaging, TMatWcMoldStaging,
STAGING_MODEL_MAPPING
)
from apps.io_api.models import (
TMaterial, TWorkcenter, TMatVer, TMatWc, TMatWcBom, TMold, TMatWcMold
)
from globalobjects import logger as log_config
logger = log_config.get_logger(__name__)
class ErrorType(str, Enum):
"""错误类型枚举"""
REQUIRED_FIELD = "required_field" # 必填字段缺失
INVALID_ENUM = "invalid_enum" # 枚举值非法
INVALID_TYPE = "invalid_type" # 类型错误
INVALID_RANGE = "invalid_range" # 数值范围错误
FK_NOT_FOUND = "fk_not_found" # 外键引用不存在
DUPLICATE_KEY = "duplicate_key" # 主键重复
BUSINESS_RULE = "business_rule" # 业务规则违反
BUSINESS_KEYS = {
"t_material": ["materialno"],
"t_workcenter": ["workcenter"],
"t_mat_ver": ["materialno", "matver"],
"t_mat_wc": ["materialno", "matver", "itemno"],
"t_mat_wc_bom": ["productno", "matver", "itemno", "materialno"],
"t_mold": ["moldno"],
"t_mat_wc_mold": ["materialno", "workcenter", "itemno", "moldno"],
}
class DataCleaner:
"""数据清洗器"""
MATERIAL_TYPE_ENUM = {"E", "P", "F", "M", "B"}
YES_NO_ENUM = {"Y", "N"}
LOT_SIZE_ENUM = {"EX", "FX", "D1", "D2", "D3", "D4", "D5", "D6", "W1", "W2", "W3", "W4", "M1", "M2", "VB"}
MOLD_TYPE_ENUM = {"注塑", "冲压", "压铸", "夹具"}
MOLD_STATUS_ENUM = {"空闲", "生产中", "维修中", "报废"}
def __init__(self, db_name: str):
self.db_name = db_name
self.errors: List[Dict] = []
async def check_duplicate(self, table_name: str, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""检测缓冲表中是否存在重复数据"""
pk_fields = BUSINESS_KEYS.get(table_name, [])
if not pk_fields:
return True, []
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
return True, []
conditions = {}
for pk in pk_fields:
value = data.get(pk)
if value is not None and value != '':
conditions[pk] = value
if not conditions:
return True, []
query = staging_model.filter(**conditions)
if staging_id:
query = query.exclude(_staging_id=staging_id)
try:
count = await query.count()
if count > 0:
pk_values = "/".join([str(data.get(pk, "")) for pk in pk_fields])
pk_fields_str = "/".join(pk_fields)
return False, [self._create_error(
staging_id, ErrorType.DUPLICATE_KEY,
pk_fields_str, pk_values,
f"缓冲表中已存在相同记录(主键:{pk_values}"
)]
return True, []
except Exception as e:
logger.error(f"检测重复失败: {str(e)}")
return True, []
async def validate_material(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验物料数据"""
errors = []
if not data.get("materialno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "materialno", None, "物料号不能为空"))
if not data.get("description"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "description", None, "物料描述不能为空"))
if not data.get("plant"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "plant", None, "工厂不能为空"))
if data.get("type") and data["type"] not in self.MATERIAL_TYPE_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "type", data["type"],
f"物料类型必须为: {self.MATERIAL_TYPE_ENUM}"))
if data.get("phantom") and data["phantom"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "phantom", data["phantom"],
f"虚拟件标识必须为: {self.YES_NO_ENUM}"))
if data.get("candelay") and data["candelay"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "candelay", data["candelay"],
f"可否延迟必须为: {self.YES_NO_ENUM}"))
if data.get("lotsize") and data["lotsize"] not in self.LOT_SIZE_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "lotsize", data["lotsize"],
f"批量策略必须为: {self.LOT_SIZE_ENUM}"))
leadday = data.get("leadday")
if leadday is not None and leadday < 0:
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "leadday", leadday, "提前期不能为负数"))
lotmin = data.get("lotmin")
lotmax = data.get("lotmax")
if lotmin is not None and lotmax is not None and lotmin > lotmax:
errors.append(self._create_error(staging_id, ErrorType.BUSINESS_RULE, "lotmin/lotmax",
f"{lotmin}/{lotmax}", "最小批量不能大于最大批量"))
is_unique, dup_errors = await self.check_duplicate("t_material", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
async def validate_workcenter(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验工作中心数据"""
errors = []
if not data.get("workcenter"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "workcenter", None, "工作中心编号不能为空"))
if data.get("bottleneck") and data["bottleneck"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "bottleneck", data["bottleneck"],
f"瓶颈标识必须为: {self.YES_NO_ENUM}"))
if data.get("finite") and data["finite"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "finite", data["finite"],
f"有限产能标识必须为: {self.YES_NO_ENUM}"))
is_unique, dup_errors = await self.check_duplicate("t_workcenter", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
async def validate_mat_ver(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验产线版本数据"""
errors = []
if not data.get("materialno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "materialno", None, "物料号不能为空"))
else:
exists = await TMaterial.filter(materialno=data["materialno"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "materialno",
data["materialno"], "关联的物料不存在"))
if not data.get("matver"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "matver", None, "版本号不能为空"))
if data.get("active") and data["active"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "active", data["active"],
f"激活标识必须为: {self.YES_NO_ENUM}"))
lotfrom = data.get("lotfrom")
lotto = data.get("lotto")
if lotfrom is not None and lotto is not None and lotfrom > lotto:
errors.append(self._create_error(staging_id, ErrorType.BUSINESS_RULE, "lotfrom/lotto",
f"{lotfrom}/{lotto}", "批量下限不能大于批量上限"))
is_unique, dup_errors = await self.check_duplicate("t_mat_ver", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
async def validate_mat_wc(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验工艺路线数据"""
errors = []
if not data.get("materialno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "materialno", None, "物料号不能为空"))
else:
exists = await TMaterial.filter(materialno=data["materialno"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "materialno",
data["materialno"], "关联的物料不存在"))
if not data.get("workcenter"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "workcenter", None, "工作中心不能为空"))
else:
exists = await TWorkcenter.filter(workcenter=data["workcenter"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "workcenter",
data["workcenter"], "关联的工作中心不存在"))
if data.get("materialno") and data.get("matver"):
exists = await TMatVer.filter(materialno=data["materialno"], matver=data["matver"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "matver",
f"{data['materialno']}/{data['matver']}", "关联的产线版本不存在"))
if not data.get("itemno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "itemno", None, "工序号不能为空"))
if data.get("sf") and data["sf"] not in {"S", "F"}:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "sf", data["sf"],
"串并行标识必须为 S(串行) 或 F(并行)"))
if data.get("basesec") is None:
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "basesec", None, "基础工时不能为空"))
elif data["basesec"] < 0:
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "basesec", data["basesec"], "基础工时不能为负数"))
is_unique, dup_errors = await self.check_duplicate("t_mat_wc", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
async def validate_mat_wc_bom(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验物料清单数据"""
errors = []
if not data.get("productno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "productno", None, "父件料号不能为空"))
else:
exists = await TMaterial.filter(materialno=data["productno"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "productno",
data["productno"], "关联的父件物料不存在"))
if not data.get("materialno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "materialno", None, "子件料号不能为空"))
else:
exists = await TMaterial.filter(materialno=data["materialno"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "materialno",
data["materialno"], "关联的子件物料不存在"))
if data.get("productno") == data.get("materialno"):
errors.append(self._create_error(staging_id, ErrorType.BUSINESS_RULE, "productno/materialno",
f"{data.get('productno')}/{data.get('materialno')}", "父件和子件不能为同一物料"))
if data.get("productno") and data.get("matver"):
exists = await TMatVer.filter(materialno=data["productno"], matver=data["matver"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "matver",
f"{data['productno']}/{data['matver']}", "关联的产线版本不存在"))
if data.get("productno") and data.get("matver") and data.get("itemno"):
exists = await TMatWc.filter(
materialno=data["productno"],
matver=data["matver"],
itemno=data["itemno"]
).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "itemno",
f"{data['productno']}/{data['matver']}/{data['itemno']}", "关联的工序不存在"))
if data.get("qty") is None:
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "qty", None, "用量不能为空"))
elif data["qty"] <= 0:
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "qty", data["qty"], "用量必须大于0"))
if data.get("scrap") is not None and (data["scrap"] < 0 or data["scrap"] > 100):
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "scrap", data["scrap"], "损耗率必须在0-100之间"))
if data.get("mto") and data["mto"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "mto", data["mto"],
f"MTO标识必须为: {self.YES_NO_ENUM}"))
if data.get("alt") and data["alt"] not in self.YES_NO_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "alt", data["alt"],
f"替代料标识必须为: {self.YES_NO_ENUM}"))
is_unique, dup_errors = await self.check_duplicate("t_mat_wc_bom", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
async def validate_mold(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验模具数据"""
errors = []
if not data.get("moldno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "moldno", None, "模具编号不能为空"))
if data.get("type") and data["type"] not in self.MOLD_TYPE_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "type", data["type"],
f"模具类型必须为: {self.MOLD_TYPE_ENUM}"))
if data.get("status") and data["status"] not in self.MOLD_STATUS_ENUM:
errors.append(self._create_error(staging_id, ErrorType.INVALID_ENUM, "status", data["status"],
f"模具状态必须为: {self.MOLD_STATUS_ENUM}"))
if data.get("moldnum") is not None and data["moldnum"] < 1:
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "moldnum", data["moldnum"], "模具穴数必须≥1"))
if data.get("qty") is not None and data["qty"] < 1:
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "qty", data["qty"], "模具台数必须≥1"))
is_unique, dup_errors = await self.check_duplicate("t_mold", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
async def validate_mat_wc_mold(self, data: Dict[str, Any], staging_id: int = None) -> Tuple[bool, List[Dict]]:
"""校验机台模具关联数据"""
errors = []
if not data.get("materialno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "materialno", None, "物料号不能为空"))
else:
exists = await TMaterial.filter(materialno=data["materialno"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "materialno",
data["materialno"], "关联的物料不存在"))
if not data.get("workcenter"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "workcenter", None, "工作中心不能为空"))
else:
exists = await TWorkcenter.filter(workcenter=data["workcenter"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "workcenter",
data["workcenter"], "关联的工作中心不存在"))
if not data.get("moldno"):
errors.append(self._create_error(staging_id, ErrorType.REQUIRED_FIELD, "moldno", None, "模具编号不能为空"))
else:
exists = await TMold.filter(moldno=data["moldno"]).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "moldno",
data["moldno"], "关联的模具不存在"))
if data.get("materialno") and data.get("workcenter") and data.get("itemno"):
exists = await TMatWc.filter(
materialno=data["materialno"],
workcenter=data["workcenter"],
itemno=data["itemno"]
).exists()
if not exists:
errors.append(self._create_error(staging_id, ErrorType.FK_NOT_FOUND, "itemno",
f"{data['materialno']}/{data['workcenter']}/{data['itemno']}", "关联的工序不存在"))
if data.get("basesec") is not None and data["basesec"] < 0:
errors.append(self._create_error(staging_id, ErrorType.INVALID_RANGE, "basesec", data["basesec"], "UPH不能为负数"))
is_unique, dup_errors = await self.check_duplicate("t_mat_wc_mold", data, staging_id)
errors.extend(dup_errors)
return len(errors) == 0, errors
def _create_error(self, staging_id: int, error_type: ErrorType, field: str,
value: Any, message: str) -> Dict:
"""创建错误记录"""
return {
"staging_id": staging_id,
"error_type": error_type.value,
"error_field": field,
"error_value": str(value) if value is not None else None,
"error_message": message
}
async def save_errors(self, staging_table: str, errors: List[Dict]):
"""保存错误记录"""
for err in errors:
await ValidationError.create(
staging_table=staging_table,
staging_id=err.get("staging_id"),
error_type=err["error_type"],
error_field=err["error_field"],
error_value=err.get("error_value"),
error_message=err["error_message"],
suggestion=self._get_suggestion(err["error_type"])
)
def _get_suggestion(self, error_type: ErrorType) -> str:
"""根据错误类型获取修复建议"""
suggestions = {
ErrorType.REQUIRED_FIELD: "请补充必填字段值",
ErrorType.INVALID_ENUM: "请填写合法的枚举值",
ErrorType.INVALID_TYPE: "请修正字段类型",
ErrorType.INVALID_RANGE: "请修正数值范围",
ErrorType.FK_NOT_FOUND: "请先导入关联的主数据,或检查引用值是否正确",
ErrorType.DUPLICATE_KEY: "请检查是否存在重复数据",
ErrorType.BUSINESS_RULE: "请检查业务规则约束",
}
return suggestions.get(error_type, "请检查数据正确性")
class DataTransformer:
"""数据转换器"""
def __init__(self):
self.rules_cache: Dict[str, TransformRule] = {}
async def load_rules(self, source_system: str, target_table: str) -> Optional[TransformRule]:
"""加载转换规则"""
cache_key = f"{source_system}_{target_table}"
if cache_key not in self.rules_cache:
rule = await TransformRule.filter(
source_system=source_system,
target_table=target_table,
is_active=True
).first()
self.rules_cache[cache_key] = rule
return self.rules_cache.get(cache_key)
async def transform(self, data: Dict[str, Any], source_system: str, target_table: str) -> Dict[str, Any]:
"""执行数据转换"""
rule = await self.load_rules(source_system, target_table)
if not rule:
return data
result = {}
if rule.field_mappings:
field_mappings = json.loads(rule.field_mappings)
for target_field, source_field in field_mappings.items():
if isinstance(source_field, str):
result[target_field] = data.get(source_field)
elif isinstance(source_field, dict):
result[target_field] = self._extract_nested(data, source_field)
if rule.default_values:
default_values = json.loads(rule.default_values)
for field, default_val in default_values.items():
if result.get(field) is None:
result[field] = default_val
if rule.value_mappings:
value_mappings = json.loads(rule.value_mappings)
for field, mapping in value_mappings.items():
if result.get(field) in mapping:
result[field] = mapping[result[field]]
return result
def _extract_nested(self, data: Dict, mapping: Dict) -> Any:
"""提取嵌套字段值"""
path = mapping.get("path", "")
default = mapping.get("default")
value = data
for key in path.split("."):
if isinstance(value, dict):
value = value.get(key)
else:
return default
return value if value is not None else default
class StagingProcessor:
"""缓冲表处理器"""
VALIDATORS = {
"t_material": DataCleaner.validate_material,
"t_workcenter": DataCleaner.validate_workcenter,
"t_mat_ver": DataCleaner.validate_mat_ver,
"t_mat_wc": DataCleaner.validate_mat_wc,
"t_mat_wc_bom": DataCleaner.validate_mat_wc_bom,
"t_mold": DataCleaner.validate_mold,
"t_mat_wc_mold": DataCleaner.validate_mat_wc_mold,
}
TARGET_MODELS = {
"t_material": TMaterial,
"t_workcenter": TWorkcenter,
"t_mat_ver": TMatVer,
"t_mat_wc": TMatWc,
"t_mat_wc_bom": TMatWcBom,
"t_mold": TMold,
"t_mat_wc_mold": TMatWcMold,
}
def __init__(self, db_name: str):
self.db_name = db_name
self.cleaner = DataCleaner(db_name)
self.transformer = DataTransformer()
async def process_staging(self, table_name: str, batch_size: int = 100, use_transaction: bool = True) -> Dict[str, int]:
"""处理缓冲表数据"""
from tortoise.transactions import in_transaction
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
stats = {"validated": 0, "rejected": 0, "synced": 0}
pending_records = await staging_model.filter(_status=StagingStatus.PENDING).limit(batch_size)
if not pending_records:
return stats
if use_transaction:
async with in_transaction(connection_name=self.db_name) as tx:
for record in pending_records:
data = self._record_to_dict(record)
is_valid, errors = await self._validate(table_name, record._staging_id, data)
if is_valid:
record._status = StagingStatus.VALIDATED
stats["validated"] += 1
else:
record._status = StagingStatus.REJECTED
record._error_msg = json.dumps(errors, ensure_ascii=False)
stats["rejected"] += 1
await self.cleaner.save_errors(table_name, errors)
await record.save(using_db=tx)
else:
for record in pending_records:
data = self._record_to_dict(record)
is_valid, errors = await self._validate(table_name, record._staging_id, data)
if is_valid:
record._status = StagingStatus.VALIDATED
stats["validated"] += 1
else:
record._status = StagingStatus.REJECTED
record._error_msg = json.dumps(errors, ensure_ascii=False)
stats["rejected"] += 1
await self.cleaner.save_errors(table_name, errors)
await record.save()
return stats
async def sync_to_production(self, table_name: str, batch_size: int = 100,
max_retries: int = 3, use_transaction: bool = True) -> Dict[str, int]:
"""同步到正式表(使用原生SQL"""
from tortoise import Tortoise
from tortoise.transactions import in_transaction
from core.settings import THIS_DB_NAME, MYAPS_MAIN_DB
staging_model = STAGING_MODEL_MAPPING.get(table_name)
target_model = self.TARGET_MODELS.get(table_name)
if not staging_model or not target_model:
raise ValueError(f"未知的表: {table_name}")
stats = {"synced": 0, "failed": 0, "skipped": 0}
validated_records = await staging_model.filter(
_status=StagingStatus.VALIDATED
).filter(
_retry_count__lt=max_retries
).limit(batch_size)
if not validated_records:
return stats
pg_conn = Tortoise.get_connection(THIS_DB_NAME)
mysql_conn = Tortoise.get_connection(MYAPS_MAIN_DB)
pk_fields = []
for field_name, field in target_model._meta.fields_map.items():
if field.pk:
pk_fields.append(field_name)
staging_field_map = {}
target_field_map = {}
for field in staging_model._meta.fields_map.values():
db_col = field.source_field if field.source_field else field.model_field_name
staging_field_map[field.model_field_name] = db_col
for field in target_model._meta.fields_map.values():
db_col = field.source_field if field.source_field else field.model_field_name
target_field_map[field.model_field_name] = db_col
staging_table_name = staging_model._meta.table
target_table_name = target_model._meta.table
for record in validated_records:
try:
staging_data = self._record_to_dict(record, exclude_staging_fields=True)
target_data = {}
for staging_field, value in staging_data.items():
if staging_field not in target_field_map:
continue
target_col = target_field_map.get(staging_field, staging_field)
target_data[target_col] = value
pk_conditions = []
pk_values = []
for pk_field in pk_fields:
pk_col = staging_field_map.get(pk_field, pk_field)
if pk_col in target_data:
pk_conditions.append(f"`{pk_col}` = %s")
pk_values.append(target_data[pk_col])
if not pk_conditions:
stats["skipped"] += 1
continue
check_query = f"SELECT COUNT(*) as cnt FROM `{target_table_name}` WHERE {' AND '.join(pk_conditions)}"
result = await mysql_conn.execute_query(check_query, tuple(pk_values))
exists = result[1][0]['cnt'] > 0 if result[1] else False
if exists:
set_parts = []
values = []
for col, val in target_data.items():
if col not in [staging_field_map.get(pk, pk) for pk in pk_fields]:
set_parts.append(f"`{col}` = %s")
values.append(val)
values.extend(pk_values)
sync_query = f"UPDATE `{target_table_name}` SET {', '.join(set_parts)} WHERE {' AND '.join(pk_conditions)}"
else:
columns = [f"`{col}`" for col in target_data.keys()]
placeholders = ", ".join(["%s"] * len(target_data))
sync_query = f"INSERT INTO `{target_table_name}` ({', '.join(columns)}) VALUES ({placeholders})"
values = list(target_data.values())
await mysql_conn.execute_query(sync_query, tuple(values))
update_query = f'UPDATE "{staging_table_name}" SET "_status" = $1, "_synced_time" = $2 WHERE "_staging_id" = $3'
await pg_conn.execute_query(update_query, ("synced", datetime.now(), record._staging_id))
stats["synced"] += 1
except Exception as e:
record._retry_count += 1
record._error_msg = str(e)
stats["failed"] += 1
logger.error(f"同步失败 [{table_name}] _staging_id={record._staging_id}, retry={record._retry_count}: {str(e)}")
if record._retry_count >= max_retries:
record._status = StagingStatus.REJECTED
logger.warning(f"记录达到最大重试次数,已标记为拒绝: _staging_id={record._staging_id}")
await record.save()
return stats
async def _validate(self, table_name: str, staging_id: int, data: Dict) -> Tuple[bool, List[Dict]]:
"""执行校验"""
validator = self.VALIDATORS.get(table_name)
if validator:
return await validator(self.cleaner, data, staging_id)
return True, []
def _record_to_dict(self, record: Model, exclude_staging_fields: bool = False) -> Dict[str, Any]:
"""将模型记录转换为字典"""
data = {}
for field_name in record._meta.fields_map:
if exclude_staging_fields and field_name.startswith("_"):
continue
data[field_name] = getattr(record, field_name)
return data
+133
View File
@@ -0,0 +1,133 @@
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from enum import Enum
from tortoise.models import Model as TortoiseBaseModel
from tortoise import fields
from core.settings import THIS_DB_NAME
from apps.io_api import protomodels as pm
class StagingStatus(str, Enum):
"""缓冲表数据状态"""
PENDING = "pending" # 待处理
VALIDATED = "validated" # 校验通过
APPROVED = "approved" # 已审批
REJECTED = "rejected" # 校验失败/拒绝
SYNCED = "synced" # 已同步到正式表
class StagingBaseModel(TortoiseBaseModel):
"""缓冲表基础模型"""
_staging_id = fields.IntField(primary_key=True, description="缓冲表主键")
_source_system = fields.CharField(max_length=32, description="来源系统", default="unknown")
_source_id = fields.CharField(max_length=128, null=True, description="源数据ID")
_status = fields.CharEnumField(StagingStatus, default=StagingStatus.PENDING, description="处理状态")
_error_msg = fields.TextField(null=True, description="错误信息JSON")
_transform_rules = fields.TextField(null=True, description="应用的转换规则JSON")
_retry_count = fields.IntField(default=0, description="重试次数")
_createtime = fields.DatetimeField(auto_now_add=True, description="创建时间")
_updatetime = fields.DatetimeField(auto_now=True, description="更新时间")
_synced_id = fields.CharField(max_length=128, null=True, description="同步后正式表ID")
_synced_time = fields.DatetimeField(null=True, description="同步时间")
class Meta:
abstract = True
class TMaterialStaging(StagingBaseModel, pm.ProtoMaterial):
"""物料缓冲表"""
class Meta:
table = "t_material_staging"
table_description = "物料数据缓冲表"
class TWorkcenterStaging(StagingBaseModel, pm.ProtoWorkcenter):
"""工作中心缓冲表"""
class Meta:
table = "t_workcenter_staging"
table_description = "工作中心数据缓冲表"
class TMatVerStaging(StagingBaseModel, pm.ProtoMatVer):
"""产线版本缓冲表"""
class Meta:
table = "t_mat_ver_staging"
table_description = "产线版本数据缓冲表"
class TMatWcStaging(StagingBaseModel, pm.ProtoMatWc):
"""工艺路线缓冲表"""
class Meta:
table = "t_mat_wc_staging"
table_description = "工艺路线数据缓冲表"
class TMatWcBomStaging(StagingBaseModel, pm.ProtoMatWcBom):
"""物料清单缓冲表"""
class Meta:
table = "t_mat_wc_bom_staging"
table_description = "物料清单数据缓冲表"
class TMoldStaging(StagingBaseModel, pm.ProtoMold):
"""模具缓冲表"""
class Meta:
table = "t_mold_staging"
table_description = "模具数据缓冲表"
class TMatWcMoldStaging(StagingBaseModel, pm.ProtoMatWcMold):
"""机台模具关联缓冲表"""
class Meta:
table = "t_mat_wc_mold_staging"
table_description = "机台模具关联数据缓冲表"
class ValidationError(TortoiseBaseModel):
"""校验错误记录表"""
id = fields.IntField(primary_key=True)
staging_table = fields.CharField(max_length=64, description="缓冲表名")
staging_id = fields.IntField(description="缓冲表记录ID")
error_type = fields.CharField(max_length=32, description="错误类型")
error_field = fields.CharField(max_length=64, description="错误字段")
error_value = fields.TextField(null=True, description="错误值")
error_message = fields.TextField(description="错误描述")
suggestion = fields.TextField(null=True, description="修复建议")
createtime = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "t_validation_error"
indexes = [("staging_table", "staging_id")]
class TransformRule(TortoiseBaseModel):
"""数据转换规则配置表"""
id = fields.IntField(primary_key=True)
rule_name = fields.CharField(max_length=64, unique=True, description="规则名称")
source_system = fields.CharField(max_length=32, description="来源系统")
target_table = fields.CharField(max_length=64, description="目标表")
field_mappings = fields.TextField(description="字段映射JSON")
default_values = fields.TextField(null=True, description="默认值JSON")
value_mappings = fields.TextField(null=True, description="枚举值映射JSON")
validation_rules = fields.TextField(null=True, description="校验规则JSON")
is_active = fields.BooleanField(default=True, description="是否启用")
priority = fields.IntField(default=0, description="优先级")
description = fields.TextField(null=True, description="规则描述")
createtime = fields.DatetimeField(auto_now_add=True)
updatetime = fields.DatetimeField(auto_now=True)
class Meta:
table = "t_transform_rule"
STAGING_MODEL_MAPPING = {
"t_material": TMaterialStaging,
"t_workcenter": TWorkcenterStaging,
"t_mat_ver": TMatVerStaging,
"t_mat_wc": TMatWcStaging,
"t_mat_wc_bom": TMatWcBomStaging,
"t_mold": TMoldStaging,
"t_mat_wc_mold": TMatWcMoldStaging,
}
+953
View File
@@ -0,0 +1,953 @@
"""
数据清洗API路由
提供缓冲表数据接收、校验、审批、同步等接口
"""
from typing import List, Dict, Optional, Literal
from datetime import datetime, timezone
from fastapi import APIRouter, Query, Body, HTTPException, status, Request, UploadFile, File
from apps.data_opt.staging_models import (
StagingStatus, STAGING_MODEL_MAPPING,
TMaterialStaging, TWorkcenterStaging, TMatVerStaging,
TMatWcStaging, TMatWcBomStaging, TMoldStaging, TMatWcMoldStaging,
ValidationError, TransformRule
)
from apps.data_opt.staging_cleaner import StagingProcessor, DataTransformer
from apps.io_api.utils.common import standard_response
from apps.io_api.utils.db_operation import db_bupsert
from core.settings import MYAPS_MAIN_DB, THIS_DB_NAME
from globalobjects import logger as log_config
logger = log_config.get_logger(__name__)
rt = APIRouter(prefix="/mds", tags=["数据清洗"])
def ensure_timezone_aware(dt: datetime) -> datetime:
"""确保datetime对象是时区感知的"""
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
return dt.replace(tzinfo=timezone.utc)
return dt
async def insert_to_staging_table(
model_class,
table_name: str,
data_list: List[Dict],
source_system: str,
exclude_fields: List[str] = None
) -> int:
"""
通用缓冲表SQL插入函数
Args:
model_class: Tortoise ORM 模型类
table_name: 目标表名
data_list: 数据列表
source_system: 来源系统
exclude_fields: 排除的字段列表(如 datetime 字段)
Returns:
插入记录数
"""
from tortoise import Tortoise
if exclude_fields is None:
exclude_fields = ['_createtime', '_updatetime', 'sys_date', 'sys_stamp', 'sys_date']
conn = Tortoise.get_connection(THIS_DB_NAME)
# 获取字段映射:Python字段名 -> 数据库字段名
field_map = {}
for field in model_class._meta.fields_map.values():
db_col_name = field.source_field if field.source_field else field.model_field_name
field_map[field.model_field_name] = db_col_name
count = 0
for item in data_list:
columns = ["_source_system", "_status"]
values = [source_system, "pending"]
for key, value in item.items():
if value is not None and key not in exclude_fields:
db_column = field_map.get(key, key)
columns.append(db_column)
values.append(value)
placeholders = ", ".join(["$" + str(i+1) for i in range(len(values))])
column_list = ", ".join([f'"{col}"' for col in columns])
query = f'INSERT INTO "{table_name}" ({column_list}) VALUES ({placeholders})'
await conn.execute_query(query, tuple(values))
count += 1
return count
@rt.post("/t_material", summary="接收物料数据到缓冲表")
async def staging_material(
request: Request,
data: List[Dict] = Body(..., description="物料数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的物料数据,写入缓冲表"""
try:
count = await insert_to_staging_table(
TMaterialStaging, "t_material_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条物料数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收物料数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/t_workcenter", summary="接收工作中心数据到缓冲表")
async def staging_workcenter(
request: Request,
data: List[Dict] = Body(..., description="工作中心数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的工作中心数据"""
try:
count = await insert_to_staging_table(
TWorkcenterStaging, "t_workcenter_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条工作中心数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收工作中心数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/t_mat_ver", summary="接收产线版本数据到缓冲表")
async def staging_mat_ver(
request: Request,
data: List[Dict] = Body(..., description="产线版本数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的产线版本数据"""
try:
count = await insert_to_staging_table(
TMatVerStaging, "t_mat_ver_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条产线版本数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收产线版本数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/t_mat_wc", summary="接收工艺路线数据到缓冲表")
async def staging_mat_wc(
request: Request,
data: List[Dict] = Body(..., description="工艺路线数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的工艺路线数据"""
try:
count = await insert_to_staging_table(
TMatWcStaging, "t_mat_wc_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条工艺路线数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收工艺路线数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/t_mat_wc_bom", summary="接收BOM数据到缓冲表")
async def staging_mat_wc_bom(
request: Request,
data: List[Dict] = Body(..., description="BOM数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的BOM数据"""
try:
count = await insert_to_staging_table(
TMatWcBomStaging, "t_mat_wc_bom_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条BOM数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收BOM数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/t_mold", summary="接收模具数据到缓冲表")
async def staging_mold(
request: Request,
data: List[Dict] = Body(..., description="模具数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的模具数据"""
try:
count = await insert_to_staging_table(
TMoldStaging, "t_mold_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条模具数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收模具数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/t_mat_wc_mold", summary="接收机台模具关联数据到缓冲表")
async def staging_mat_wc_mold(
request: Request,
data: List[Dict] = Body(..., description="机台模具关联数据列表"),
source_system: str = Query("unknown", description="来源系统"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""接收外部系统的机台模具关联数据"""
try:
count = await insert_to_staging_table(
TMatWcMoldStaging, "t_mat_wc_mold_staging", data, source_system
)
return standard_response(
success=1,
message=f"成功接收 {count} 条机台模具关联数据到缓冲表",
data={"count": count}
)
except Exception as e:
import traceback
logger.error(f"接收机台模具关联数据失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.post("/validate/{table_name}", summary="校验缓冲表数据")
async def validate_staging(
request: Request,
table_name: str,
batch_size: int = Query(100, description="每批处理数量"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""校验指定缓冲表中的待处理数据"""
try:
processor = StagingProcessor(db_name)
stats = await processor.process_staging(table_name, batch_size)
return standard_response(
success=1,
message=f"校验完成",
data=stats
)
except Exception as e:
logger.error(f"校验失败 [{table_name}]: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/validate_all", summary="校验所有缓冲表数据")
async def validate_all_staging(
request: Request,
batch_size: int = Query(100, description="每批处理数量"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""按依赖顺序校验所有缓冲表数据"""
table_order = [
"t_material",
"t_workcenter",
"t_mold",
"t_mat_ver",
"t_mat_wc",
"t_mat_wc_bom",
"t_mat_wc_mold",
]
try:
processor = StagingProcessor(db_name)
all_stats = {}
for table_name in table_order:
stats = await processor.process_staging(table_name, batch_size)
all_stats[table_name] = stats
return standard_response(
success=1,
message="所有缓冲表校验完成",
data=all_stats
)
except Exception as e:
logger.error(f"批量校验失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/sync/{table_name}", summary="同步缓冲表数据到正式表")
async def sync_to_production(
request: Request,
table_name: str,
batch_size: int = Query(100, description="每批同步数量"),
max_retries: int = Query(3, description="最大重试次数"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""将校验通过的缓冲表数据同步到正式表"""
try:
processor = StagingProcessor(db_name)
stats = await processor.sync_to_production(table_name, batch_size, max_retries)
return standard_response(
success=1,
message=f"同步完成",
data=stats
)
except Exception as e:
logger.error(f"同步失败 [{table_name}]: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/sync_all", summary="同步所有缓冲表数据到正式表")
async def sync_all_to_production(
request: Request,
batch_size: int = Query(100, description="每批同步数量"),
max_retries: int = Query(3, description="最大重试次数"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""按依赖顺序同步所有缓冲表数据到正式表"""
table_order = [
"t_material",
"t_workcenter",
"t_mold",
"t_mat_ver",
"t_mat_wc",
"t_mat_wc_bom",
"t_mat_wc_mold",
]
try:
processor = StagingProcessor(db_name)
all_stats = {}
for table_name in table_order:
stats = await processor.sync_to_production(table_name, batch_size, max_retries)
all_stats[table_name] = stats
return standard_response(
success=1,
message="所有缓冲表同步完成",
data=all_stats
)
except Exception as e:
logger.error(f"批量同步失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.get("/errors/{table_name}", summary="获取校验错误列表")
async def get_validation_errors(
request: Request,
table_name: str,
staging_id: Optional[int] = Query(None, description="缓冲记录ID"),
error_type: Optional[str] = Query(None, description="错误类型"),
limit: int = Query(100, description="返回数量限制")
):
"""查询缓冲表的校验错误记录"""
try:
query = ValidationError.filter(staging_table=table_name)
if staging_id:
query = query.filter(staging_id=staging_id)
if error_type:
query = query.filter(error_type=error_type)
errors = await query.order_by("-createtime").limit(limit)
data = [{
"id": e.id,
"staging_id": e.staging_id,
"error_type": e.error_type,
"error_field": e.error_field,
"error_value": e.error_value,
"error_message": e.error_message,
"suggestion": e.suggestion,
"createtime": e.createtime.isoformat()
} for e in errors]
return standard_response(
success=1,
message=f"查询到 {len(data)} 条错误记录",
data=data
)
except Exception as e:
logger.error(f"查询错误记录失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.get("/status/{table_name}", summary="获取缓冲表状态统计")
async def get_staging_status(
request: Request,
table_name: str
):
"""获取指定缓冲表的状态统计"""
try:
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
stats = {}
for status in StagingStatus:
count = await staging_model.filter(_status=status).count()
stats[status.value] = count
stats["total"] = sum(stats.values())
return standard_response(
success=1,
message="查询成功",
data=stats
)
except Exception as e:
logger.error(f"查询状态统计失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.patch("/approve/{table_name}/{staging_id}", summary="审批缓冲表数据")
async def approve_staging(
request: Request,
table_name: str,
staging_id: int,
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""手动审批通过缓冲表记录"""
try:
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
record = await staging_model.get(_staging_id=staging_id)
record._status = StagingStatus.APPROVED
await record.save()
return standard_response(success=1, message="审批通过")
except Exception as e:
logger.error(f"审批失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.patch("/reject/{table_name}/{staging_id}", summary="拒绝缓冲表数据")
async def reject_staging(
request: Request,
table_name: str,
staging_id: int,
reason: str = Query(..., description="拒绝原因"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""手动拒绝缓冲表记录"""
try:
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
record = await staging_model.get(_staging_id=staging_id)
record._status = StagingStatus.REJECTED
record._error_msg = reason
await record.save()
return standard_response(success=1, message="已拒绝")
except Exception as e:
logger.error(f"拒绝操作失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.delete("/clear/{table_name}", summary="清空缓冲表")
async def clear_staging(
request: Request,
table_name: str,
status_filter: Optional[StagingStatus] = Query(None, description="按状态过滤")
):
"""清空缓冲表数据"""
try:
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
if status_filter:
deleted = await staging_model.filter(_status=status_filter).delete()
else:
deleted = await staging_model.all().delete()
return standard_response(
success=1,
message=f"已删除 {deleted} 条记录"
)
except Exception as e:
logger.error(f"清空缓冲表失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.get("/monitor/summary", summary="获取所有缓冲表监控数据")
async def get_monitor_summary(request: Request):
"""获取所有缓冲表的数据量统计"""
try:
from tortoise import Tortoise
from core.settings import THIS_DB_NAME
conn = Tortoise.get_connection(THIS_DB_NAME)
tables = [
"t_material_staging",
"t_workcenter_staging",
"t_mat_ver_staging",
"t_mat_wc_staging",
"t_mat_wc_bom_staging",
"t_mold_staging",
"t_mat_wc_mold_staging",
]
summary = []
for table in tables:
query = f'''
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE "_status" = 'pending') as pending,
COUNT(*) FILTER (WHERE "_status" = 'validated') as validated,
COUNT(*) FILTER (WHERE "_status" = 'rejected') as rejected,
COUNT(*) FILTER (WHERE "_status" = 'synced') as synced,
MAX("_createtime") as last_created,
MAX("_synced_time") as last_synced
FROM "{table}"
'''
result = await conn.execute_query(query)
row = result[1][0] if result[1] else {}
summary.append({
"table": table,
"total": row.get("total", 0),
"pending": row.get("pending", 0),
"validated": row.get("validated", 0),
"rejected": row.get("rejected", 0),
"synced": row.get("synced", 0),
"last_created": row.get("last_created").isoformat() if row.get("last_created") else None,
"last_synced": row.get("last_synced").isoformat() if row.get("last_synced") else None,
})
return standard_response(
success=1,
message="查询成功",
data=summary
)
except Exception as e:
logger.error(f"获取监控数据失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/cleanup/old_data", summary="清理历史数据")
async def cleanup_old_data(
request: Request,
days: int = Query(30, description="保留最近N天的数据"),
status_filter: Optional[StagingStatus] = Query(StagingStatus.SYNCED, description="清理的状态类型"),
dry_run: bool = Query(True, description="仅统计不删除")
):
"""清理已同步的历史数据"""
try:
from tortoise import Tortoise
from datetime import timedelta
from core.settings import THIS_DB_NAME
conn = Tortoise.get_connection(THIS_DB_NAME)
cutoff_date = datetime.now() - timedelta(days=days)
tables = [
"t_material_staging",
"t_workcenter_staging",
"t_mat_ver_staging",
"t_mat_wc_staging",
"t_mat_wc_bom_staging",
"t_mold_staging",
"t_mat_wc_mold_staging",
]
results = []
for table in tables:
count_query = f'''
SELECT COUNT(*) as cnt FROM "{table}"
WHERE "_status" = $1 AND "_synced_time" < $2
'''
result = await conn.execute_query(count_query, (status_filter.value, cutoff_date))
count = result[1][0]['cnt'] if result[1] else 0
if not dry_run and count > 0:
delete_query = f'''
DELETE FROM "{table}"
WHERE "_status" = $1 AND "_synced_time" < $2
'''
await conn.execute_query(delete_query, (status_filter.value, cutoff_date))
results.append({
"table": table,
"would_delete": count,
"deleted": count if not dry_run else 0
})
return standard_response(
success=1,
message=f"{'统计完成(未删除)' if dry_run else '清理完成'}",
data={
"cutoff_date": cutoff_date.isoformat(),
"dry_run": dry_run,
"tables": results
}
)
except Exception as e:
logger.error(f"清理历史数据失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/retry_failed/{table_name}", summary="重试失败的记录")
async def retry_failed_records(
request: Request,
table_name: str,
max_retry: int = Query(3, description="最大重试次数"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""重试同步失败的记录"""
try:
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
records = await staging_model.filter(
_status=StagingStatus.REJECTED,
_retry_count__lt=max_retry
)
reset_count = 0
for record in records:
record._status = StagingStatus.VALIDATED
record._retry_count = 0
record._error_msg = None
await record.save()
reset_count += 1
return standard_response(
success=1,
message=f"已重置 {reset_count} 条失败记录待重试",
data={"reset_count": reset_count}
)
except Exception as e:
logger.error(f"重试失败记录失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/upload/{table_name}", summary="Excel文件上传")
async def upload_excel(
request: Request,
table_name: str,
file: UploadFile = File(..., description="Excel文件"),
source_system: str = Query("excel", description="来源系统"),
dedup_strategy: str = Query("skip", description="去重策略: overwrite/skip/reject"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""上传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
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
file_bytes = await file.read()
parser = get_parser_for_table(table_name)
data_list, parse_errors = parser.parse(file_bytes)
if parse_errors and not data_list:
return standard_response(
success=0,
message="Excel解析失败",
data={"errors": parse_errors[:10]}
)
strategy = DedupStrategy(dedup_strategy)
processed_data, handled_data = await apply_dedup_strategy(
table_name, data_list, strategy
)
table_name_staging = f"{table_name}_staging"
inserted_count = 0
if processed_data:
inserted_count = await insert_to_staging_table(
staging_model, table_name_staging, processed_data, source_system
)
return standard_response(
success=1,
message=f"Excel导入完成: 成功{inserted_count}条, 跳过{len(handled_data)}",
data={
"total": len(data_list),
"inserted": inserted_count,
"skipped": len(handled_data),
"parse_errors": len(parse_errors),
"handled_details": handled_data[:20]
}
)
except Exception as e:
import traceback
logger.error(f"Excel上传失败: {str(e)}")
logger.error(traceback.format_exc())
return standard_response(success=0, message=str(e))
@rt.get("/list/{table_name}", summary="查询缓冲表列表")
async def list_staging(
request: Request,
table_name: str,
_status: Optional[str] = Query(None, description="状态筛选"),
source_system: Optional[str] = Query(None, description="来源系统"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
page: int = Query(1, description="页码"),
page_size: int = Query(20, description="每页数量"),
sort_field: str = Query("_createtime", description="排序字段"),
sort_order: str = Query("desc", description="排序方向: asc/desc")
):
"""分页查询缓冲表数据"""
try:
from tortoise import Tortoise
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
table_name_staging = f"{table_name}_staging"
conn = Tortoise.get_connection(THIS_DB_NAME)
conditions = []
params = []
param_idx = 1
if _status:
conditions.append(f'"_status" = ${param_idx}')
params.append(_status)
param_idx += 1
if source_system:
conditions.append(f'"_source_system" = ${param_idx}')
params.append(source_system)
param_idx += 1
if keyword:
conditions.append(f'"materialno" LIKE ${param_idx}')
params.append(f"%{keyword}%")
param_idx += 1
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
count_query = f'SELECT COUNT(*) as cnt FROM "{table_name_staging}" {where_clause}'
count_result = await conn.execute_query(count_query, tuple(params) if params else None)
total = count_result[1][0]['cnt'] if count_result[1] else 0
offset = (page - 1) * page_size
sort_direction = "DESC" if sort_order == "desc" else "ASC"
data_query = f'''
SELECT * FROM "{table_name_staging}"
{where_clause}
ORDER BY "{sort_field}" {sort_direction}
LIMIT {page_size} OFFSET {offset}
'''
data_result = await conn.execute_query(data_query, tuple(params) if params else None)
records = data_result[1] if data_result[1] else []
for record in records:
for key, value in record.items():
if isinstance(value, datetime):
record[key] = value.isoformat()
return standard_response(
success=1,
message=f"查询成功,共{total}",
data={
"total": total,
"page": page,
"page_size": page_size,
"records": records
}
)
except Exception as e:
logger.error(f"查询列表失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.get("/detail/{table_name}/{staging_id}", summary="查询单条记录")
async def get_staging_detail(
request: Request,
table_name: str,
staging_id: int
):
"""查询单条缓冲表记录详情"""
try:
from tortoise import Tortoise
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
table_name_staging = f"{table_name}_staging"
conn = Tortoise.get_connection(THIS_DB_NAME)
query = f'SELECT * FROM "{table_name_staging}" WHERE "_staging_id" = $1'
result = await conn.execute_query(query, (staging_id,))
if not result[1]:
raise ValueError(f"记录不存在: staging_id={staging_id}")
record = result[1][0]
for key, value in record.items():
if isinstance(value, datetime):
record[key] = value.isoformat()
return standard_response(
success=1,
message="查询成功",
data=record
)
except Exception as e:
logger.error(f"查询详情失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.patch("/update/{table_name}/{staging_id}", summary="更新单条记录")
async def update_staging(
request: Request,
table_name: str,
staging_id: int,
data: Dict = Body(..., description="更新数据"),
db_name: str = Query(MYAPS_MAIN_DB, description="账套")
):
"""更新单条缓冲表记录"""
try:
from tortoise import Tortoise
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
table_name_staging = f"{table_name}_staging"
conn = Tortoise.get_connection(THIS_DB_NAME)
field_map = {}
for field in staging_model._meta.fields_map.values():
db_col_name = field.source_field if field.source_field else field.model_field_name
field_map[field.model_field_name] = db_col_name
set_parts = []
values = []
param_idx = 1
exclude_fields = ['_staging_id', '_createtime', '_updatetime']
for key, value in data.items():
if key not in exclude_fields:
db_col = field_map.get(key, key)
set_parts.append(f'"{db_col}" = ${param_idx}')
values.append(value)
param_idx += 1
if not set_parts:
raise ValueError("没有可更新的字段")
values.append(staging_id)
update_query = f'UPDATE "{table_name_staging}" SET {", ".join(set_parts)} WHERE "_staging_id" = ${param_idx}'
await conn.execute_query(update_query, tuple(values))
return standard_response(success=1, message="更新成功")
except Exception as e:
logger.error(f"更新记录失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.delete("/delete/{table_name}/{staging_id}", summary="删除单条记录")
async def delete_staging(
request: Request,
table_name: str,
staging_id: int
):
"""删除单条缓冲表记录"""
try:
from tortoise import Tortoise
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
table_name_staging = f"{table_name}_staging"
conn = Tortoise.get_connection(THIS_DB_NAME)
query = f'DELETE FROM "{table_name_staging}" WHERE "_staging_id" = $1'
await conn.execute_query(query, (staging_id,))
return standard_response(success=1, message="删除成功")
except Exception as e:
logger.error(f"删除记录失败: {str(e)}")
return standard_response(success=0, message=str(e))
@rt.post("/batch_delete/{table_name}", summary="批量删除记录")
async def batch_delete_staging(
request: Request,
table_name: str,
staging_ids: List[int] = Body(..., description="staging_id列表")
):
"""批量删除缓冲表记录"""
try:
from tortoise import Tortoise
staging_model = STAGING_MODEL_MAPPING.get(table_name)
if not staging_model:
raise ValueError(f"未知的缓冲表: {table_name}")
if not staging_ids:
raise ValueError("staging_ids不能为空")
table_name_staging = f"{table_name}_staging"
conn = Tortoise.get_connection(THIS_DB_NAME)
placeholders = ", ".join([f"${i+1}" for i in range(len(staging_ids))])
query = f'DELETE FROM "{table_name_staging}" WHERE "_staging_id" IN ({placeholders})'
await conn.execute_query(query, tuple(staging_ids))
return standard_response(
success=1,
message=f"成功删除 {len(staging_ids)} 条记录"
)
except Exception as e:
logger.error(f"批量删除失败: {str(e)}")
return standard_response(success=0, message=str(e))
+304
View File
@@ -0,0 +1,304 @@
"""
去重检测工具
支持缓冲表数据去重、重复标记等功能
"""
import pandas as pd
from typing import Dict, List, Any, Tuple, Optional
from collections import defaultdict
from enum import Enum
from apps.data_opt.staging_models import STAGING_MODEL_MAPPING
from globalobjects import logger as log_config
logger = log_config.get_logger(__name__)
class DedupStrategy(str, Enum):
"""去重策略"""
OVERWRITE = "overwrite" # 覆盖已有记录
SKIP = "skip" # 跳过重复记录
REJECT = "reject" # 拒绝并报错
BUSINESS_KEYS = {
"t_material": ["materialno"],
"t_workcenter": ["workcenter"],
"t_mat_ver": ["materialno", "matver"],
"t_mat_wc": ["materialno", "matver", "itemno"],
"t_mat_wc_bom": ["productno", "matver", "itemno", "materialno"],
"t_mold": ["moldno"],
"t_mat_wc_mold": ["materialno", "workcenter", "itemno", "moldno"],
}
class DuplicateChecker:
"""去重检测器"""
def __init__(self, table_name: str):
"""
初始化去重检测器
Args:
table_name: 表名
"""
self.table_name = table_name
self.pk_fields = BUSINESS_KEYS.get(table_name, [])
self.staging_model = STAGING_MODEL_MAPPING.get(table_name)
async def check_duplicate_in_staging(
self,
data: Dict[str, Any],
exclude_staging_id: int = None
) -> Tuple[bool, Optional[str]]:
"""
检测缓冲表中是否存在重复数据
Args:
data: 待检测数据
exclude_staging_id: 排除的staging_id(更新时排除自身)
Returns:
(是否唯一, 重复的主键值)
"""
if not self.pk_fields or not self.staging_model:
return True, None
conditions = {}
for pk in self.pk_fields:
value = data.get(pk)
if value is not None and value != '':
conditions[pk] = value
if not conditions:
return True, None
query = self.staging_model.filter(**conditions)
if exclude_staging_id:
query = query.exclude(_staging_id=exclude_staging_id)
try:
count = await query.count()
if count > 0:
pk_value = "/".join([str(data.get(pk, "")) for pk in self.pk_fields])
return False, pk_value
return True, None
except Exception as e:
logger.error(f"检测重复失败: {str(e)}")
return True, None
async def batch_check_duplicates(
self,
data_list: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
批量检测重复(内部去重 + 缓冲表去重)
Args:
data_list: 数据列表
Returns:
{
"unique": [唯一数据],
"duplicates": [重复数据],
"existing": [已存在于缓冲表的数据],
"pk_map": {主键值: [索引列表]}
}
"""
if not self.pk_fields:
return {
"unique": data_list,
"duplicates": [],
"existing": [],
"pk_map": {}
}
pk_map = defaultdict(list)
for idx, data in enumerate(data_list):
pk_value = self._get_pk_value(data)
if pk_value:
pk_map[pk_value].append(idx)
internal_duplicates = []
for pk_value, indices in pk_map.items():
if len(indices) > 1:
internal_duplicates.append({
"pk_value": pk_value,
"indices": indices,
"count": len(indices)
})
unique_indices = set()
for pk_value, indices in pk_map.items():
unique_indices.add(indices[0])
existing_in_db = []
unique_data = []
for idx in unique_indices:
data = data_list[idx]
pk_value = self._get_pk_value(data)
is_unique, _ = await self.check_duplicate_in_staging(data)
if is_unique:
unique_data.append({
"index": idx,
"data": data,
"pk_value": pk_value
})
else:
existing_in_db.append({
"index": idx,
"data": data,
"pk_value": pk_value
})
duplicate_data = []
for dup_info in internal_duplicates:
for idx in dup_info["indices"]:
duplicate_data.append({
"index": idx,
"data": data_list[idx],
"pk_value": dup_info["pk_value"],
"duplicate_count": dup_info["count"]
})
return {
"unique": unique_data,
"duplicates": duplicate_data,
"existing": existing_in_db,
"pk_map": dict(pk_map),
"summary": {
"total": len(data_list),
"unique_count": len(unique_data),
"duplicate_count": len(duplicate_data),
"existing_count": len(existing_in_db)
}
}
def _get_pk_value(self, data: Dict[str, Any]) -> Optional[str]:
"""获取数据的主键值"""
values = []
for pk in self.pk_fields:
value = data.get(pk)
if value is not None and value != '':
values.append(str(value))
else:
return None
return "/".join(values) if values else None
def mark_duplicates_in_dataframe(
self,
df: pd.DataFrame,
duplicate_indices: List[int]
) -> pd.DataFrame:
"""
在DataFrame中标记重复数据
Args:
df: 数据DataFrame
duplicate_indices: 重复数据索引列表
Returns:
标记后的DataFrame
"""
if 'D' not in df.columns:
df['D'] = ''
for idx in duplicate_indices:
if idx in df.index:
df.at[idx, 'D'] = '重复'
return df
async def apply_dedup_strategy(
table_name: str,
data_list: List[Dict[str, Any]],
strategy: DedupStrategy = DedupStrategy.SKIP
) -> Tuple[List[Dict], List[Dict]]:
"""
应用去重策略
Args:
table_name: 表名
data_list: 数据列表
strategy: 去重策略
Returns:
(处理后的数据列表, 被处理的数据列表)
"""
checker = DuplicateChecker(table_name)
result = await checker.batch_check_duplicates(data_list)
processed_data = []
handled_data = []
if strategy == DedupStrategy.SKIP:
for item in result["unique"]:
processed_data.append(item["data"])
for item in result["duplicates"]:
handled_data.append({
"data": item["data"],
"reason": "内部重复",
"pk_value": item["pk_value"]
})
for item in result["existing"]:
handled_data.append({
"data": item["data"],
"reason": "已存在于缓冲表",
"pk_value": item["pk_value"]
})
elif strategy == DedupStrategy.OVERWRITE:
for item in result["unique"]:
processed_data.append(item["data"])
for item in result["duplicates"]:
if item["index"] == item["duplicate_count"] - 1:
processed_data.append(item["data"])
else:
handled_data.append({
"data": item["data"],
"reason": "内部重复(保留最后一条)",
"pk_value": item["pk_value"]
})
for item in result["existing"]:
processed_data.append(item["data"])
elif strategy == DedupStrategy.REJECT:
if result["duplicates"] or result["existing"]:
for item in result["unique"]:
processed_data.append(item["data"])
for item in result["duplicates"]:
handled_data.append({
"data": item["data"],
"reason": "存在重复数据,拒绝导入",
"pk_value": item["pk_value"]
})
for item in result["existing"]:
handled_data.append({
"data": item["data"],
"reason": "存在重复数据,拒绝导入",
"pk_value": item["pk_value"]
})
else:
for item in result["unique"]:
processed_data.append(item["data"])
logger.info(
f"去重处理完成: 策略={strategy.value}, "
f"原始={len(data_list)}, 处理后={len(processed_data)}, 被处理={len(handled_data)}"
)
return processed_data, handled_data
def get_pk_fields(table_name: str) -> List[str]:
"""获取表的业务主键字段"""
return BUSINESS_KEYS.get(table_name, [])
+311
View File
@@ -0,0 +1,311 @@
"""
Excel解析工具
支持字段映射数据校验去重等功能
"""
import pandas as pd
import json
from io import BytesIO
from typing import Dict, List, Any, Tuple, Optional
from datetime import datetime
from globalobjects import logger as log_config
logger = log_config.get_logger(__name__)
class ExcelParser:
"""Excel解析器"""
def __init__(
self,
field_mapper: Dict[str, str] = None,
required_fields: List[str] = None,
sheet_name: str = 0,
skip_empty_rows: bool = True
):
"""
初始化Excel解析器
Args:
field_mapper: 字段映射 {内部字段名: Excel列名}
required_fields: 必填字段列表
sheet_name: 工作表名或索引默认第一个
skip_empty_rows: 是否跳过空行
"""
self.field_mapper = field_mapper or {}
self.required_fields = required_fields or []
self.sheet_name = sheet_name
self.skip_empty_rows = skip_empty_rows
self.parsing_errors = []
def parse(self, file_bytes: bytes) -> Tuple[List[Dict], List[Dict]]:
"""
解析Excel文件
Args:
file_bytes: Excel文件字节流
Returns:
(成功解析的数据列表, 错误记录列表)
"""
self.parsing_errors = []
try:
df = pd.read_excel(BytesIO(file_bytes), sheet_name=self.sheet_name)
except Exception as e:
logger.error(f"读取Excel文件失败: {str(e)}")
return [], [{"error": f"读取Excel文件失败: {str(e)}"}]
if df.empty:
return [], []
column_validation = self._validate_columns(df)
if not column_validation[0]:
return [], [{"error": f"缺少必要列: {column_validation[1]}"}]
df = self._apply_field_mapping(df)
if self.skip_empty_rows:
df = self._remove_empty_rows(df)
data_list = []
errors = []
for idx, row in df.iterrows():
try:
record = self._row_to_dict(row, idx)
validation = self._validate_record(record, idx)
if validation[0]:
data_list.append(record)
else:
errors.append({
"row": idx + 2,
"data": record,
"errors": validation[1]
})
except Exception as e:
errors.append({
"row": idx + 2,
"error": str(e)
})
self.parsing_errors = errors
logger.info(f"Excel解析完成: 成功{len(data_list)}条, 失败{len(errors)}")
return data_list, errors
def _validate_columns(self, df: pd.DataFrame) -> Tuple[bool, List[str]]:
"""校验必填列是否存在"""
if not self.required_fields:
return True, []
excel_columns = set(df.columns.str.strip())
required_excel_cols = set()
for internal_field in self.required_fields:
excel_col = self.field_mapper.get(internal_field, internal_field)
required_excel_cols.add(excel_col)
missing_cols = required_excel_cols - excel_columns
return len(missing_cols) == 0, list(missing_cols)
def _apply_field_mapping(self, df: pd.DataFrame) -> pd.DataFrame:
"""应用字段映射"""
if not self.field_mapper:
return df
reverse_mapper = {v: k for k, v in self.field_mapper.items() if v}
df = df.rename(columns=reverse_mapper)
return df
def _remove_empty_rows(self, df: pd.DataFrame) -> pd.DataFrame:
"""移除空行"""
return df.dropna(how='all')
def _row_to_dict(self, row: pd.Series, idx: int) -> Dict[str, Any]:
"""将行转换为字典"""
record = {}
for col in row.index:
value = row[col]
if pd.isna(value):
record[col] = None
elif isinstance(value, datetime):
record[col] = value.isoformat()
elif isinstance(value, pd.Timestamp):
record[col] = value.isoformat()
else:
record[col] = value
return record
def _validate_record(self, record: Dict, idx: int) -> Tuple[bool, List[str]]:
"""校验单条记录"""
errors = []
for field in self.required_fields:
value = record.get(field)
if value is None or (isinstance(value, str) and value.strip() == ''):
errors.append(f"必填字段 {field} 不能为空")
return len(errors) == 0, errors
def get_parsing_summary(self) -> Dict:
"""获取解析摘要"""
return {
"total_errors": len(self.parsing_errors),
"errors": self.parsing_errors[:10]
}
class ExcelExporter:
"""Excel导出器"""
@staticmethod
def export_to_bytes(
data: List[Dict],
columns: List[str] = None,
sheet_name: str = "Sheet1"
) -> bytes:
"""
导出数据为Excel字节流
Args:
data: 数据列表
columns: 列顺序默认使用数据的所有列
sheet_name: 工作表名
Returns:
Excel文件字节流
"""
if not data:
df = pd.DataFrame()
else:
df = pd.DataFrame(data)
if columns:
df = df[[col for col in columns if col in df.columns]]
output = BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name=sheet_name, index=False)
output.seek(0)
return output.getvalue()
@staticmethod
def export_with_errors(
data: List[Dict],
errors: List[Dict],
columns: List[str] = None
) -> bytes:
"""
导出数据和错误信息到Excel
Args:
data: 成功数据列表
errors: 错误列表
columns: 列顺序
Returns:
Excel文件字节流
"""
output = BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
if data:
df_data = pd.DataFrame(data)
if columns:
df_data = df_data[[col for col in columns if col in df_data.columns]]
df_data.to_excel(writer, sheet_name="成功数据", index=False)
if errors:
df_errors = pd.DataFrame(errors)
df_errors.to_excel(writer, sheet_name="错误数据", index=False)
output.seek(0)
return output.getvalue()
TABLE_FIELD_MAPPERS = {
"t_material": {
"materialno": "物料号",
"description": "物料描述",
"plant": "工厂",
"type": "物料类型",
"phantom": "虚拟件",
"candelay": "可否延迟",
"lotsize": "批量策略",
"leadday": "提前期",
"lotmin": "最小批量",
"lotmax": "最大批量",
"unit": "单位",
},
"t_workcenter": {
"workcenter": "工作中心",
"description": "描述",
"bottleneck": "瓶颈",
"finite": "有限产能",
"capacity": "产能",
},
"t_mat_ver": {
"materialno": "物料号",
"matver": "版本号",
"description": "描述",
"active": "激活",
"lotfrom": "批量下限",
"lotto": "批量上限",
},
"t_mat_wc": {
"materialno": "物料号",
"matver": "版本号",
"itemno": "工序号",
"workcenter": "工作中心",
"sf": "串并行",
"basesec": "基础工时",
},
"t_mat_wc_bom": {
"productno": "父件料号",
"materialno": "子件料号",
"matver": "版本号",
"itemno": "工序号",
"qty": "用量",
"scrap": "损耗率",
"mto": "MTO",
"alt": "替代料",
},
"t_mold": {
"moldno": "模具编号",
"description": "描述",
"type": "类型",
"status": "状态",
"moldnum": "穴数",
"qty": "台数",
},
"t_mat_wc_mold": {
"materialno": "物料号",
"workcenter": "工作中心",
"itemno": "工序号",
"moldno": "模具编号",
"basesec": "UPH",
},
}
TABLE_REQUIRED_FIELDS = {
"t_material": ["materialno", "description", "plant"],
"t_workcenter": ["workcenter"],
"t_mat_ver": ["materialno", "matver"],
"t_mat_wc": ["materialno", "matver", "itemno", "workcenter"],
"t_mat_wc_bom": ["productno", "materialno", "qty"],
"t_mold": ["moldno"],
"t_mat_wc_mold": ["materialno", "workcenter", "moldno"],
}
def get_parser_for_table(table_name: str) -> ExcelParser:
"""获取指定表的Excel解析器"""
field_mapper = TABLE_FIELD_MAPPERS.get(table_name, {})
required_fields = TABLE_REQUIRED_FIELDS.get(table_name, [])
return ExcelParser(
field_mapper=field_mapper,
required_fields=required_fields
)
+4 -2
View File
@@ -8,7 +8,8 @@ from tortoise.contrib.fastapi import register_tortoise
from core.settings import (
BASE_DIR, SQLITE_FILE,
MYAPS_MAIN_DB, MYAPS_DBSET_LIST, MYAPS_DB_HOST, MYAPS_DB_PORT, MYAPS_DB_USER, MYAPS_DB_PASSWORD,
THIS_DB_NAME, THIS_DB_HOST, THIS_DB_PORT, THIS_DB_USER, THIS_DB_PASSWORD
THIS_DB_NAME, THIS_DB_HOST, THIS_DB_PORT, THIS_DB_USER, THIS_DB_PASSWORD,
TIMEZONE_NAME
)
from globalobjects import logger as log_config
@@ -95,12 +96,13 @@ if THIS_DB_NAME:
"user": THIS_DB_USER,
"password": THIS_DB_PASSWORD,
"database": THIS_DB_NAME,
"server_settings": {"TimeZone": TIMEZONE_NAME},
},
"min_size": 3, # 保持最小连接数
"max_size": 10, # 最大连接数
}
TORTOISE_ORM_CONFIG["apps"]["data_opt_models"] = {
"models": ["apps.data_opt.models", "aerich.models"],
"models": ["apps.data_opt.staging_models", "aerich.models"],
"default_connection": THIS_DB_NAME,
}
+8
View File
@@ -11,6 +11,10 @@ API_KEY = os.getenv("API_KEY", "")
DOC_PATHS = ["/docs", "/redoc", "/openapi.json"]
DOC_PREFIXES = ["/static/swagger"]
# MDS页面路径(不需要API Key验证)
MDS_PATHS = ["/mds", "/mds/material", "/mds/workcenter", "/mds/mat-ver",
"/mds/mat-wc", "/mds/mat-wc-bom", "/mds/mold", "/mds/mat-wc-mold"]
# 缓存已注册的路由信息,避免每次请求都重新解析
REGISTERED_ROUTES = []
@@ -208,6 +212,10 @@ def create_security_middleware():
request_method = request.method
client_ip = request.client.host
# MDS页面路径直接放行
if url_path in MDS_PATHS:
return await call_next(request)
# 检查是否为文档相关路径(只能在内网访问)
is_doc_path = url_path in DOC_PATHS or any(url_path.startswith(prefix) for prefix in DOC_PREFIXES)
if is_doc_path:
+60 -3
View File
@@ -2,8 +2,12 @@ from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from apps.io_api.routers import rt as io_rt
from apps.data_opt.routers import rt as do_rt
from apps.data_opt.staging_routers import rt as mds_rt
from apps.common.monitor.routers import router as monitor_rt
from apps.common.help.routers import router as help_rt
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
router = APIRouter()
@@ -11,23 +15,27 @@ router = APIRouter()
def register_routes(app):
app.include_router(io_rt, prefix="/api", tags=[])
app.include_router(do_rt, prefix="/do", tags=[], include_in_schema=False)
app.include_router(mds_rt, prefix="/api", tags=["数据清洗"])
app.include_router(monitor_rt, tags=["monitor"], include_in_schema=False)
app.include_router(help_rt, tags=["help"], include_in_schema=False)
@app.get("/monitor", response_class=HTMLResponse, include_in_schema=False)
async def monitor_dashboard():
with open("static/monitor/index.html", "r", encoding="utf-8") as f:
file_path = os.path.join(BASE_DIR, "static", "monitor", "index.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/monitor/live-logs", response_class=HTMLResponse, include_in_schema=False)
async def live_logs_page():
with open("static/monitor/live-logs.html", "r", encoding="utf-8") as f:
file_path = os.path.join(BASE_DIR, "static", "monitor", "live-logs.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/monitor/history-logs", response_class=HTMLResponse, include_in_schema=False)
async def history_logs_page():
with open("static/monitor/history-logs.html", "r", encoding="utf-8") as f:
file_path = os.path.join(BASE_DIR, "static", "monitor", "history-logs.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/", include_in_schema=False)
@@ -37,3 +45,52 @@ def register_routes(app):
"version": "1.0.0",
"status": "running"
}
# MDS 数据清洗页面路由
@app.get("/mds", response_class=HTMLResponse, include_in_schema=False)
async def mds_index():
file_path = os.path.join(BASE_DIR, "static", "mds", "index.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/material", response_class=HTMLResponse, include_in_schema=False)
async def mds_material():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "material.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/workcenter", response_class=HTMLResponse, include_in_schema=False)
async def mds_workcenter():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "workcenter.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/mat-ver", response_class=HTMLResponse, include_in_schema=False)
async def mds_mat_ver():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "mat-ver.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/mat-wc", response_class=HTMLResponse, include_in_schema=False)
async def mds_mat_wc():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "mat-wc.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/mat-wc-bom", response_class=HTMLResponse, include_in_schema=False)
async def mds_mat_wc_bom():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "mat-wc-bom.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/mold", response_class=HTMLResponse, include_in_schema=False)
async def mds_mold():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "mold.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
@app.get("/mds/mat-wc-mold", response_class=HTMLResponse, include_in_schema=False)
async def mds_mat_wc_mold():
file_path = os.path.join(BASE_DIR, "static", "mds", "pages", "mat-wc-mold.html")
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
+44
View File
@@ -21,6 +21,50 @@ load_dotenv(os.getenv('ENV_FILE', os.path.join(BASE_DIR, '.env')), override=Fals
# 时区,默认东八区
TIMEZONE = os.getenv("TIMEZONE", "+8")
def get_timezone_name(offset_str):
"""
将时区偏移量字符串 +8, -5转换为时区名称 Asia/Shanghai
Args:
offset_str: 时区偏移量字符串格式为 "+8" "-5"
Returns:
时区名称 "Asia/Shanghai"
"""
offset_map = {
"-12": "Etc/GMT+12",
"-11": "Etc/GMT+11",
"-10": "Pacific/Honolulu",
"-9": "America/Anchorage",
"-8": "America/Los_Angeles",
"-7": "America/Denver",
"-6": "America/Chicago",
"-5": "America/New_York",
"-4": "America/Halifax",
"-3": "America/Argentina/Buenos_Aires",
"-2": "Atlantic/South_Georgia",
"-1": "Atlantic/Azores",
"+0": "Europe/London",
"+1": "Europe/Paris",
"+2": "Europe/Helsinki",
"+3": "Europe/Moscow",
"+4": "Asia/Dubai",
"+5": "Asia/Karachi",
"+5.5": "Asia/Kolkata",
"+6": "Asia/Dhaka",
"+7": "Asia/Bangkok",
"+8": "Asia/Shanghai",
"+9": "Asia/Tokyo",
"+9.5": "Australia/Adelaide",
"+10": "Australia/Sydney",
"+11": "Pacific/Auckland",
"+12": "Pacific/Fiji",
}
return offset_map.get(offset_str, "Asia/Shanghai")
TIMEZONE_NAME = get_timezone_name(TIMEZONE)
# 数据库监控开关,默认关闭
TURNON_BINLOG_LISTENER = os.getenv("TURNON_BINLOG_LISTENER", "False").lower().strip() == "true"
+6 -5
View File
@@ -14,11 +14,12 @@ from .._base import (
#################################################################################
hap_conn = None
hap_conn = HapConnection(
base_url='https://api.mingdao.com',
app_key='...',
sign='...'
)
# 延迟初始化,避免启动时导入错误
# hap_conn = HapConnection(
# base_url='https://api.mingdao.com',
# app_key='...',
# sign='...'
# )
#################################################################################
# ⬇️ 项目可复用逻辑
+96
View File
@@ -0,0 +1,96 @@
# 开发环境服务管理脚本
## 脚本说明
| 脚本 | 适用系统 | 说明 |
|------|---------|------|
| `dev_server.sh` | Linux/macOS | Bash脚本 |
| `dev_server.bat` | Windows | 批处理脚本 |
## 使用方法
### Linux/macOS
```bash
# 进入项目目录
cd /opt/myaps_api/myaps_api
# 添加执行权限(首次使用)
chmod +x scripts/dev_server.sh
# 启动服务
./scripts/dev_server.sh start
# 查看状态
./scripts/dev_server.sh status
# 查看日志
./scripts/dev_server.sh logs
# 实时查看日志
./scripts/dev_server.sh logs -f
# 重启服务
./scripts/dev_server.sh restart
# 停止服务
./scripts/dev_server.sh stop
```
### Windows
```cmd
# 进入项目目录
cd D:\myaps_api\myaps_api
# 启动服务
scripts\dev_server.bat start
# 查看状态
scripts\dev_server.bat status
# 重启服务
scripts\dev_server.bat restart
# 停止服务
scripts\dev_server.bat stop
```
## 访问地址
启动成功后访问:
| 地址 | 说明 |
|------|------|
| http://localhost:8000 | 服务首页 |
| http://localhost:8000/docs | Swagger API文档 |
| http://localhost:8000/redoc | ReDoc API文档 |
## 测试MDS路由
在Swagger文档中测试数据清洗API:
1. **接收物料数据到缓冲表**
```
POST /api/mds/material
```
2. **查询缓冲表状态**
```
GET /api/mds/status/t_material
```
3. **校验缓冲表数据**
```
POST /api/mds/validate/t_material
```
4. **同步到正式表**
```
POST /api/mds/sync/t_material
```
## 日志位置
- Linux/macOS: `logs/dev_server.log`
- Windows: 控制台窗口输出
+93
View File
@@ -49,6 +49,99 @@ cd scripts/deploy_docker
| `GUNICORN_BIND` | `0.0.0.0:8000` | 容器内监听 |
| `APP_ROOT` | `/app` | 容器工作目录 |
## PostgreSQL 自有数据库安装(可选)
项目支持自有 PostgreSQL 数据库(THIS_DB),以下为手动安装步骤。
### Ubuntu/Debian 安装
```bash
# 1. 安装 PostgreSQL
sudo apt update
sudo apt install -y postgresql postgresql-contrib
# 2. 启动服务
sudo systemctl start postgresql
sudo systemctl enable postgresql
# 3. 创建数据库和用户
sudo -u postgres psql
# 在 psql 中执行
CREATE USER myaps_user WITH PASSWORD 'your_password';
CREATE DATABASE myaps_db OWNER myaps_user;
GRANT ALL PRIVILEGES ON DATABASE myaps_db TO myaps_user;
\q
# 4. 配置远程访问(如需要)
sudo vim /etc/postgresql/*/main/postgresql.conf
# 修改: listen_addresses = 'localhost' -> listen_addresses = '*'
sudo vim /etc/postgresql/*/main/pg_hba.conf
# 添加: host all all 0.0.0.0/0 md5
# 5. 重启服务
sudo systemctl restart postgresql
# 6. 验证连接
psql -h localhost -U myaps_user -d myaps_db
```
### CentOS/RHEL 安装
```bash
# 1. 安装 PostgreSQL
sudo yum install -y postgresql-server postgresql-contrib
sudo postgresql-setup initdb
# 2. 启动服务
sudo systemctl start postgresql
sudo systemctl enable postgresql
# 3-6. 创建用户和配置(同Ubuntu)
```
### Docker 方式安装 PostgreSQL
```bash
# 启动 PostgreSQL 容器
docker run -d \
--name myaps_postgres \
-e POSTGRES_USER=myaps_user \
-e POSTGRES_PASSWORD=your_password \
-e POSTGRES_DB=myaps_db \
-p 5432:5432 \
-v postgres_data:/var/lib/postgresql/data \
postgres:15-alpine
# 查看连接信息
docker inspect myaps_postgres | grep IPAddress
```
### 配置应用连接
编辑 `.env` 文件:
```ini
# 服务自有数据库配置
THIS_DB_HOST=localhost # 或容器IP / 远程IP
THIS_DB_PORT=5432
THIS_DB_USER=myaps_user
THIS_DB_PASSWORD=your_password
THIS_DB_NAME=myaps_db
```
### 不使用自有数据库
如不需要自有数据库,保持 `.env` 中相关配置为空或注释即可:
```ini
# THIS_DB_HOST=
# THIS_DB_NAME=
```
应用会自动跳过自有数据库初始化。
## 数据持久化
以下目录已配置持久化挂载:
+137
View File
@@ -0,0 +1,137 @@
@echo off
chcp 65001 >nul
REM =====================================================
REM 开发环境服务启停脚本 (Windows)
REM 用法:
REM dev_server.bat start - 启动服务
REM dev_server.bat stop - 停止服务
REM dev_server.bat restart - 重启服务
REM dev_server.bat status - 查看状态
REM =====================================================
setlocal EnableDelayedExpansion
REM 项目根目录
set "PROJECT_DIR=%~dp0.."
cd /d "%PROJECT_DIR%"
REM 配置
set "APP_NAME=myaps_api"
set "PID_FILE=%PROJECT_DIR%\.dev_server.pid"
set "LOG_FILE=%PROJECT_DIR%\logs\dev_server.log"
set "HOST=0.0.0.0"
set "PORT=8000"
REM 创建日志目录
if not exist "%PROJECT_DIR%\logs" mkdir "%PROJECT_DIR%\logs"
REM 主命令
if "%1"=="" goto help
if "%1"=="start" goto start
if "%1"=="stop" goto stop
if "%1"=="restart" goto restart
if "%1"=="status" goto status
if "%1"=="logs" goto logs
if "%1"=="-h" goto help
if "%1"=="--help" goto help
goto help
:start
REM 检查是否已运行
tasklist /FI "IMAGENAME eq python.exe" /FI "WINDOWTITLE eq *main.py*" 2>nul | find "python.exe" >nul
if %errorlevel%==0 (
echo 服务已在运行中
goto end
)
echo 正在启动服务...
echo 项目目录: %PROJECT_DIR%
echo 访问地址: http://localhost:%PORT%
echo API文档: http://localhost:%PORT%/docs
echo.
REM 启动服务
start "myaps_api_server" /min python main.py
timeout /t 3 /nobreak >nul
REM 检查是否启动成功
tasklist /FI "IMAGENAME eq python.exe" 2>nul | find "python.exe" >nul
if %errorlevel%==0 (
echo ✓ 服务启动成功
echo 日志目录: %PROJECT_DIR%\logs
) else (
echo ✗ 服务启动失败
)
goto end
:stop
echo 正在停止服务...
REM 方式1: 通过窗口标题关闭
taskkill /FI "WINDOWTITLE eq myaps_api_server*" /F >nul 2>&1
REM 方式2: 通过命令行参数匹配关闭
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr "PID:"') do (
wmic process where "ProcessId=%%i and CommandLine like '%%main.py%%'" delete >nul 2>&1
)
timeout /t 2 /nobreak >nul
echo ✓ 服务已停止
goto end
:restart
call :stop
timeout /t 1 /nobreak >nul
call :start
goto end
:status
echo 检查服务状态...
echo.
REM 检查进程
tasklist /FI "IMAGENAME eq python.exe" 2>nul | find "python.exe" >nul
if %errorlevel%==0 (
echo ✓ 服务运行中
echo 访问地址: http://localhost:%PORT%
echo API文档: http://localhost:%PORT%/docs
echo.
echo 进程信息:
tasklist /FI "IMAGENAME eq python.exe" /FO TABLE
) else (
echo ✗ 服务未运行
)
goto end
:logs
if not exist "%LOG_FILE%" (
echo 日志文件不存在: %LOG_FILE%
echo.
echo 提示: 日志默认输出到控制台,请查看运行窗口
goto end
)
echo 最近50行日志:
echo ----------------------------------------
type "%LOG_FILE%" | more
goto end
:help
echo.
echo 用法: %~nx0 {start^|stop^|restart^|status^|logs}
echo.
echo 命令:
echo start - 启动服务
echo stop - 停止服务
echo restart - 重启服务
echo status - 查看服务状态
echo logs - 查看日志
echo.
echo 示例:
echo %~nx0 start
echo %~nx0 status
echo.
goto end
:end
endlocal
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
# =====================================================
# 开发环境服务启停脚本
# 用法:
# ./dev_server.sh start - 启动服务
# ./dev_server.sh stop - 停止服务
# ./dev_server.sh restart - 重启服务
# ./dev_server.sh status - 查看状态
# ./dev_server.sh logs - 查看日志
# =====================================================
# 项目根目录
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_DIR"
# 查找Python解释器
find_python() {
# 优先使用虚拟环境
if [ -f "$PROJECT_DIR/venv/bin/python" ]; then
echo "$PROJECT_DIR/venv/bin/python"
return
fi
# 尝试python3
if command -v python3 &> /dev/null; then
echo "python3"
return
fi
# 尝试python
if command -v python &> /dev/null; then
echo "python"
return
fi
echo "python3"
}
PYTHON_CMD=$(find_python)
# 配置
APP_NAME="myaps_api"
PID_FILE="$PROJECT_DIR/.dev_server.pid"
LOG_FILE="$PROJECT_DIR/logs/dev_server.log"
HOST="0.0.0.0"
PORT="8000"
# 创建日志目录
mkdir -p "$PROJECT_DIR/logs"
# 获取进程ID
get_pid() {
if [ -f "$PID_FILE" ]; then
cat "$PID_FILE"
else
pgrep -f "python.*main\.py" | head -1 || pgrep -f "python3.*main\.py" | head -1
fi
}
# 检查服务是否运行
is_running() {
local pid=$(get_pid)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
return 0
fi
return 1
}
# 启动服务
start() {
if is_running; then
echo "服务已在运行中 (PID: $(get_pid))"
return 1
fi
echo "正在启动服务..."
echo "项目目录: $PROJECT_DIR"
echo "Python解释器: $PYTHON_CMD"
echo "访问地址: http://localhost:$PORT"
echo "API文档: http://localhost:$PORT/docs"
# 启动服务
nohup $PYTHON_CMD main.py > "$LOG_FILE" 2>&1 &
local pid=$!
echo $pid > "$PID_FILE"
sleep 2
if is_running; then
echo "✓ 服务启动成功 (PID: $pid)"
echo "日志文件: $LOG_FILE"
else
echo "✗ 服务启动失败,请查看日志:"
tail -20 "$LOG_FILE"
rm -f "$PID_FILE"
return 1
fi
}
# 停止服务
stop() {
if ! is_running; then
echo "服务未运行"
rm -f "$PID_FILE"
return 0
fi
local pid=$(get_pid)
echo "正在停止服务 (PID: $pid)..."
# 发送SIGTERM信号
kill "$pid" 2>/dev/null
# 等待进程结束
local count=0
while kill -0 "$pid" 2>/dev/null && [ $count -lt 10 ]; do
sleep 1
count=$((count + 1))
done
# 如果进程还在运行,强制结束
if kill -0 "$pid" 2>/dev/null; then
echo "强制结束进程..."
kill -9 "$pid" 2>/dev/null
fi
rm -f "$PID_FILE"
echo "✓ 服务已停止"
}
# 清除Python缓存
clear_cache() {
echo "清除Python缓存..."
find "$PROJECT_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null
find "$PROJECT_DIR" -name "*.pyc" -delete 2>/dev/null
echo "✓ 缓存已清除"
}
# 重启服务
restart() {
stop
clear_cache
sleep 1
start
}
# 查看状态
status() {
if is_running; then
local pid=$(get_pid)
echo "✓ 服务运行中"
echo " PID: $pid"
echo " 访问地址: http://localhost:$PORT"
echo " API文档: http://localhost:$PORT/docs"
# 显示进程信息
if command -v ps &> /dev/null; then
ps -p "$pid" -o pid,ppid,%cpu,%mem,etime,cmd 2>/dev/null || true
fi
else
echo "✗ 服务未运行"
fi
}
# 查看日志
logs() {
if [ ! -f "$LOG_FILE" ]; then
echo "日志文件不存在: $LOG_FILE"
return 1
fi
if [ "$1" = "-f" ] || [ "$1" = "--follow" ]; then
echo "实时查看日志 (Ctrl+C 退出)..."
tail -f "$LOG_FILE"
else
echo "最近50行日志:"
tail -50 "$LOG_FILE"
fi
}
# 帮助信息
help() {
echo "用法: $0 {start|stop|restart|status|logs|clear_cache}"
echo ""
echo "命令:"
echo " start - 启动服务"
echo " stop - 停止服务"
echo " restart - 重启服务(自动清除缓存)"
echo " status - 查看服务状态"
echo " logs - 查看日志 (添加 -f 参数实时查看)"
echo " clear_cache - 清除Python缓存"
echo ""
echo "示例:"
echo " $0 start"
echo " $0 restart"
echo " $0 logs -f"
}
# 主入口
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
restart
;;
status)
status
;;
logs)
logs "$2"
;;
clear_cache)
clear_cache
;;
-h|--help|help)
help
;;
*)
echo "错误: 未知命令 '$1'"
help
exit 1
;;
esac
+373
View File
@@ -0,0 +1,373 @@
-- =====================================================
-- APS数据清洗缓冲表建表脚本 (PostgreSQL版本)
-- 生成时间: 自动生成
-- 说明: 用于存储外部系统导入的原始数据,支持数据校验和清洗
-- =====================================================
-- =====================================================
-- 1. 物料缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_material_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoMaterial 字段
"MaterialNo" VARCHAR(64) NOT NULL,
"Description" VARCHAR(128) NOT NULL,
"Size" VARCHAR(128) NULL,
"Plant" VARCHAR(32) NOT NULL,
"Planner" VARCHAR(64) NULL,
"FIFO" INT NOT NULL DEFAULT 0,
"LeadDay" INT NOT NULL DEFAULT 0,
"ExpDay" INT NULL,
"GRDay" INT NOT NULL DEFAULT 0,
"ABC" VARCHAR(8) NULL,
"Unit" VARCHAR(8) NULL,
"Price" DECIMAL(10,2) NULL,
"GroupNo" VARCHAR(32) NULL,
"Type" VARCHAR(1) NULL,
"Phantom" VARCHAR(1) NULL,
"PhantomMin" INT DEFAULT 0,
"FirmDay" INT NULL,
"DayGap" INT NULL,
"CanDelay" VARCHAR(1) NULL,
"LotSize" VARCHAR(2) NULL,
"LotFix" DOUBLE PRECISION NULL,
"LotMin" DOUBLE PRECISION NULL,
"LotMax" DOUBLE PRECISION NULL,
"LotRound" DOUBLE PRECISION NULL,
"LotSS" DOUBLE PRECISION NULL,
"LotPoint" DOUBLE PRECISION NULL,
"LotTop" DOUBLE PRECISION NULL,
"PlanItem" VARCHAR(32) NULL,
"PreDay" INT NULL,
"SubDay" INT NULL,
"Free1" VARCHAR(255) NULL,
"Free2" VARCHAR(255) NULL,
"Free3" VARCHAR(255) NULL,
"Memo" VARCHAR(255) NULL,
"Sys_User" VARCHAR(32) NULL,
"Sys_Date" TIMESTAMP NULL,
"Sys_Stamp" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mat_stg_status ON t_material_staging(_status);
CREATE INDEX IF NOT EXISTS idx_mat_stg_source ON t_material_staging(_source_system);
CREATE INDEX IF NOT EXISTS idx_mat_stg_materialno ON t_material_staging("MaterialNo");
COMMENT ON TABLE t_material_staging IS '物料数据缓冲表';
COMMENT ON COLUMN t_material_staging._staging_id IS '缓冲表主键';
COMMENT ON COLUMN t_material_staging._source_system IS '来源系统';
COMMENT ON COLUMN t_material_staging._status IS '处理状态: pending/validated/approved/rejected/synced';
COMMENT ON COLUMN t_material_staging."MaterialNo" IS '物料号';
COMMENT ON COLUMN t_material_staging."Description" IS '物料描述';
COMMENT ON COLUMN t_material_staging."Type" IS '物料类型: E-自制, P-采购, F-委外, M-模具, B-虚拟';
-- =====================================================
-- 2. 工作中心缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_workcenter_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoWorkcenter 字段
"WorkCenter" VARCHAR(32) NOT NULL,
"WorkCenterName" VARCHAR(255) NULL,
"Pri_WC" INT NULL,
"Bottleneck" VARCHAR(1) NULL,
"SortNo" VARCHAR(4) NULL,
"Plant" VARCHAR(32) NULL,
"Location" VARCHAR(32) NULL,
"Finite" VARCHAR(1) NULL,
"Type" VARCHAR(32) NULL,
"CapNum" INT NULL,
"CapMax" INT NULL,
"Worker" DOUBLE PRECISION NULL,
"SetupNo" VARCHAR(6) NULL,
"GrpNo" VARCHAR(6) NULL,
"Memo" VARCHAR(255) NULL
);
CREATE INDEX IF NOT EXISTS idx_wc_stg_status ON t_workcenter_staging(_status);
CREATE INDEX IF NOT EXISTS idx_wc_stg_workcenter ON t_workcenter_staging("WorkCenter");
COMMENT ON TABLE t_workcenter_staging IS '工作中心数据缓冲表';
-- =====================================================
-- 3. 产线版本缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_mat_ver_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoMatVer 字段
"MaterialNo" VARCHAR(64) NOT NULL,
"MatVer" VARCHAR(4) NOT NULL,
"LotFrom" INT NULL,
"LotTo" INT NULL,
"Priority" INT NULL,
"RefNo" VARCHAR(64) NULL,
"Active" VARCHAR(1) NULL,
"Memo" VARCHAR(255) NULL
);
CREATE INDEX IF NOT EXISTS idx_mvr_stg_status ON t_mat_ver_staging(_status);
CREATE INDEX IF NOT EXISTS idx_mvr_stg_mat_ver ON t_mat_ver_staging("MaterialNo", "MatVer");
COMMENT ON TABLE t_mat_ver_staging IS '产线版本数据缓冲表';
-- =====================================================
-- 4. 工艺路线缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_mat_wc_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoMatWc 字段
"MaterialNo" VARCHAR(64) NOT NULL,
"MatVer" VARCHAR(4) NOT NULL,
"ItemNo" VARCHAR(6) NOT NULL,
"WorkCenter" VARCHAR(32) NOT NULL,
"SortNo" INT NOT NULL,
"BaseSec" INT NOT NULL,
"FixQty" INT NOT NULL,
"FixSec" INT NOT NULL,
"SF" VARCHAR(1) NULL,
"OffSetSec" INT NULL,
"Rate" DOUBLE PRECISION NULL,
"Memo" VARCHAR(255) NULL,
"Sys_Stamp" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mwc_stg_status ON t_mat_wc_staging(_status);
CREATE INDEX IF NOT EXISTS idx_mwc_stg_mat_ver_item ON t_mat_wc_staging("MaterialNo", "MatVer", "ItemNo");
CREATE INDEX IF NOT EXISTS idx_mwc_stg_workcenter ON t_mat_wc_staging("WorkCenter");
COMMENT ON TABLE t_mat_wc_staging IS '工艺路线数据缓冲表';
-- =====================================================
-- 5. 物料清单缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_mat_wc_bom_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoMatWcBom 字段
"ProductNo" VARCHAR(64) NOT NULL,
"MatVer" VARCHAR(4) NOT NULL,
"ItemNo" VARCHAR(6) NOT NULL,
"MaterialNo" VARCHAR(64) NOT NULL,
"Qty" DOUBLE PRECISION NOT NULL,
"OffsetHour" INT NOT NULL,
"TreeNo" INT NULL,
"MTO" VARCHAR(1) NULL,
"Scrap" DOUBLE PRECISION NULL,
"Alt" VARCHAR(1) NULL,
"Memo" VARCHAR(255) NULL,
"Sys_Stamp" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bom_stg_status ON t_mat_wc_bom_staging(_status);
CREATE INDEX IF NOT EXISTS idx_bom_stg_product ON t_mat_wc_bom_staging("ProductNo", "MatVer", "ItemNo", "MaterialNo");
CREATE INDEX IF NOT EXISTS idx_bom_stg_materialno ON t_mat_wc_bom_staging("MaterialNo");
COMMENT ON TABLE t_mat_wc_bom_staging IS '物料清单数据缓冲表';
-- =====================================================
-- 6. 模具缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_mold_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoMold 字段
"MoldNo" VARCHAR(32) NOT NULL,
"MoldName" VARCHAR(255) NULL,
"Type" VARCHAR(8) NULL,
"Status" VARCHAR(8) NULL,
"MoldNum" INT NULL,
"Qty" INT NULL,
"Memo" VARCHAR(255) NULL
);
CREATE INDEX IF NOT EXISTS idx_mold_stg_status ON t_mold_staging(_status);
CREATE INDEX IF NOT EXISTS idx_mold_stg_moldno ON t_mold_staging("MoldNo");
COMMENT ON TABLE t_mold_staging IS '模具数据缓冲表';
COMMENT ON COLUMN t_mold_staging."Type" IS '模具类型: 注塑/冲压/压铸/夹具';
COMMENT ON COLUMN t_mold_staging."Status" IS '模具状态: 空闲/生产中/维修中/报废';
-- =====================================================
-- 7. 机台模具关联缓冲表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_mat_wc_mold_staging (
_staging_id SERIAL PRIMARY KEY,
_source_system VARCHAR(32) DEFAULT 'unknown',
_source_id VARCHAR(128) NULL,
_status VARCHAR(20) DEFAULT 'pending',
_error_msg TEXT NULL,
_transform_rules TEXT NULL,
_retry_count INT DEFAULT 0,
_createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
_synced_id VARCHAR(128) NULL,
_synced_time TIMESTAMP NULL,
-- ProtoMatWcMold 字段
"MaterialNo" VARCHAR(64) NOT NULL,
"WorkCenter" VARCHAR(32) NOT NULL,
"ItemNo" VARCHAR(6) NOT NULL,
"MoldNo" VARCHAR(32) NOT NULL,
"BaseSec" INT NULL,
"FixSec" INT NULL,
"Priority" INT NULL,
"Memo" VARCHAR(255) NULL
);
CREATE INDEX IF NOT EXISTS idx_mwm_stg_status ON t_mat_wc_mold_staging(_status);
CREATE INDEX IF NOT EXISTS idx_mwm_stg_mat_wc ON t_mat_wc_mold_staging("MaterialNo", "WorkCenter", "ItemNo", "MoldNo");
CREATE INDEX IF NOT EXISTS idx_mwm_stg_moldno ON t_mat_wc_mold_staging("MoldNo");
COMMENT ON TABLE t_mat_wc_mold_staging IS '机台模具关联数据缓冲表';
-- =====================================================
-- 8. 校验错误记录表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_validation_error (
id SERIAL PRIMARY KEY,
staging_table VARCHAR(64) NOT NULL,
staging_id INT NOT NULL,
error_type VARCHAR(32) NOT NULL,
error_field VARCHAR(64) NOT NULL,
error_value TEXT NULL,
error_message TEXT NOT NULL,
suggestion TEXT NULL,
createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_err_staging ON t_validation_error(staging_table, staging_id);
CREATE INDEX IF NOT EXISTS idx_err_type ON t_validation_error(error_type);
CREATE INDEX IF NOT EXISTS idx_err_time ON t_validation_error(createtime);
COMMENT ON TABLE t_validation_error IS '校验错误记录表';
-- =====================================================
-- 9. 数据转换规则配置表
-- =====================================================
CREATE TABLE IF NOT EXISTS t_transform_rule (
id SERIAL PRIMARY KEY,
rule_name VARCHAR(64) NOT NULL UNIQUE,
source_system VARCHAR(32) NOT NULL,
target_table VARCHAR(64) NOT NULL,
field_mappings TEXT NOT NULL,
default_values TEXT NULL,
value_mappings TEXT NULL,
validation_rules TEXT NULL,
is_active BOOLEAN DEFAULT TRUE,
priority INT DEFAULT 0,
description TEXT NULL,
createtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatetime TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rule_source ON t_transform_rule(source_system);
CREATE INDEX IF NOT EXISTS idx_rule_target ON t_transform_rule(target_table);
CREATE INDEX IF NOT EXISTS idx_rule_active ON t_transform_rule(is_active);
COMMENT ON TABLE t_transform_rule IS '数据转换规则配置表';
-- =====================================================
-- 创建更新时间触发器函数
-- =====================================================
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW._updatetime = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 为每个缓冲表创建触发器
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN SELECT unnest(ARRAY[
't_material_staging',
't_workcenter_staging',
't_mat_ver_staging',
't_mat_wc_staging',
't_mat_wc_bom_staging',
't_mold_staging',
't_mat_wc_mold_staging'
]) LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trigger_update_%s ON %s;
CREATE TRIGGER trigger_update_%s
BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE FUNCTION update_timestamp()',
tbl, tbl, tbl, tbl
);
END LOOP;
END;
$$;
-- =====================================================
-- 完成提示
-- =====================================================
-- 执行完成后,可通过以下命令验证表创建成功:
-- SELECT tablename FROM pg_tables WHERE tablename LIKE '%staging' OR tablename IN ('t_validation_error', 't_transform_rule');
File diff suppressed because one or more lines are too long
+169
View File
@@ -0,0 +1,169 @@
/* 自定义样式 */
:root {
--primary-color: #0d6efd;
--success-color: #198754;
--warning-color: #ffc107;
--danger-color: #dc3545;
--info-color: #0dcaf0;
}
body {
background-color: #f5f5f5;
}
.navbar-brand {
font-weight: bold;
}
.status-card {
cursor: pointer;
transition: all 0.3s ease;
}
.status-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.status-card.active {
border: 2px solid var(--primary-color);
}
.status-card .card-body {
padding: 1rem;
}
.status-number {
font-size: 2rem;
font-weight: bold;
}
.status-label {
font-size: 0.875rem;
color: #666;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
white-space: nowrap;
}
.table td {
vertical-align: middle;
}
.badge-pending {
background-color: var(--warning-color);
color: #000;
}
.badge-validated {
background-color: var(--success-color);
}
.badge-rejected {
background-color: var(--danger-color);
}
.badge-synced {
background-color: var(--info-color);
}
.action-btn {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.file-upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.file-upload-area:hover {
border-color: var(--primary-color);
background-color: #f8f9fa;
}
.file-upload-area.dragover {
border-color: var(--primary-color);
background-color: #e7f1ff;
}
.pagination {
margin-bottom: 0;
}
.filter-bar {
background-color: #fff;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.data-table-container {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
}
.modal-body pre {
max-height: 300px;
overflow-y: auto;
background-color: #f8f9fa;
padding: 1rem;
border-radius: 4px;
}
.error-detail {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;
}
.error-detail .error-type {
font-weight: bold;
color: var(--danger-color);
}
.error-detail .error-field {
color: #666;
}
.error-detail .error-message {
margin-top: 0.5rem;
}
.error-detail .suggestion {
margin-top: 0.5rem;
font-style: italic;
color: #198754;
}
+193
View File
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据清洗管理系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
border: none;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px 16px 0 0 !important;
padding: 2rem;
}
.table-link {
display: block;
padding: 1.5rem;
border-radius: 8px;
transition: all 0.3s ease;
text-decoration: none;
color: #333;
}
.table-link:hover {
background-color: #f8f9fa;
transform: translateX(5px);
text-decoration: none;
}
.table-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.table-info {
flex: 1;
}
.table-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.table-desc {
color: #666;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="container">
<div class="card" style="max-width: 800px; margin: 0 auto;">
<div class="card-header text-center text-white">
<h1 class="mb-2">数据清洗管理系统</h1>
<p class="mb-0 opacity-75">主数据管理与数据质量控制平台</p>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<a href="/mds/material" class="table-link border-bottom">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #0d6efd, #0dcaf0);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.186 1.113a.5.5 0 0 0 0 1l1.5 1.5a.5.5 0 0 0 1 0l1.5-1.5a.5.5 0 0 0 0-1l-1.5-1.5a.5.5 0 0 0-1 0zM4 4a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1A.5.5 0 0 0 5 4zm2 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1A.5.5 0 0 0 7 4zm2 0a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1A.5.5 0 0 0 9 4z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">物料数据</div>
<div class="table-desc">物料主数据管理,包含物料号、描述、类型等信息</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
<a href="/mds/workcenter" class="table-link border-bottom">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #198754, #20c997);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M9 5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H9zM4 6a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5A.5.5 0 0 1 4 6zm0 2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5A.5.5 0 0 1 4 8zm0 2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">工作中心</div>
<div class="table-desc">工作中心管理,包含产能、瓶颈标识等信息</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
<a href="/mds/mat-ver" class="table-link border-bottom">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #fd7e14, #ffc107);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M12 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm-2 11.5v-6a.5.5 0 0 1 1 0v6a.5.5 0 0 1-1 0zm-3 0v-4a.5.5 0 0 1 1 0v4a.5.5 0 0 1-1 0zm-2 0v-2a.5.5 0 0 1 1 0v2a.5.5 0 0 1-1 0z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">产线版本</div>
<div class="table-desc">物料版本管理,定义不同生产版本的批量范围</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
<a href="/mds/mat-wc" class="table-link border-bottom">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #6f42c1, #d63384);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 0h1v15h15v1H0V0Zm14.848 2.646a.5.5 0 0 1 .706 0l.647.646a.5.5 0 0 1 0 .708l-4.5 4.5a.5.5 0 0 1-.708 0l-.646-.646a.5.5 0 0 1 0-.708l4.5-4.5ZM8 7a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 0-1h-3A.5.5 0 0 0 8 7Z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">工艺路线</div>
<div class="table-desc">工艺路线管理,定义物料生产的工序流程</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
<a href="/mds/mat-wc-bom" class="table-link border-bottom">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #dc3545, #fd7e14);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L8 2.207l6.646 6.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.707 1.5Z"/>
<path d="m8 3.293 6 6V13.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5V9.293l6-6Z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">物料清单 (BOM)</div>
<div class="table-desc">物料清单管理,定义产品的组成结构和用量</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
<a href="/mds/mold" class="table-link border-bottom">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #343a40, #6c757d);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 1a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V1Zm3 4a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5Z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">模具</div>
<div class="table-desc">模具主数据管理,包含模具类型、状态、穴数等</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
<a href="/mds/mat-wc-mold" class="table-link">
<div class="d-flex align-items-center">
<div class="table-icon" style="background: linear-gradient(135deg, #0dcaf0, #20c997);">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 0h1v15h15v1H0V0Zm3 3h10v10H3V3Zm2 2v6h6V5H5Z"/>
</svg>
</div>
<div class="table-info ms-3">
<div class="table-title">机台模具关联</div>
<div class="table-desc">机台与模具的关联关系管理</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#ccc" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
</svg>
</div>
</a>
</div>
</div>
<div class="card-footer text-center py-3 bg-light">
<small class="text-muted">数据清洗管理系统 v1.0 | <a href="/docs" class="text-decoration-none">API文档</a></small>
</div>
</div>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long
+193
View File
@@ -0,0 +1,193 @@
/**
* 公共函数库
*/
const API_BASE = '/api/mds';
const STATUS_COLORS = {
'pending': 'warning',
'validated': 'success',
'rejected': 'danger',
'synced': 'info'
};
const STATUS_TEXTS = {
'pending': '待处理',
'validated': '校验通过',
'rejected': '校验失败',
'synced': '已同步'
};
async function callApi(endpoint, method = 'GET', data = null) {
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (data && method !== 'GET') {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(`${API_BASE}${endpoint}`, options);
const result = await response.json();
return result;
} catch (error) {
console.error('API调用失败:', error);
return { success: 0, message: error.message };
}
}
function handleResponse(response, onSuccess, onError) {
if (response.success === 1) {
if (onSuccess) onSuccess(response);
} else {
if (onError) {
onError(response);
} else {
showMessage(response.message || '操作失败', 'danger');
}
}
}
function showMessage(message, type = 'info') {
const container = document.querySelector('.toast-container') || createToastContainer();
const toast = document.createElement('div');
toast.className = `toast show align-items-center text-bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" onclick="this.parentElement.parentElement.remove()"></button>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
function createToastContainer() {
const container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
return container;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function formatStatus(status) {
const color = STATUS_COLORS[status] || 'secondary';
const text = STATUS_TEXTS[status] || status;
return `<span class="badge badge-${status}">${text}</span>`;
}
function showLoading() {
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.id = 'loadingOverlay';
overlay.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
`;
document.body.appendChild(overlay);
}
function hideLoading() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function truncateText(text, maxLength = 50) {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
async function uploadFile(tableName, file, dedupStrategy = 'skip') {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(
`${API_BASE}/upload/${tableName}?dedup_strategy=${dedupStrategy}`,
{
method: 'POST',
body: formData
}
);
const result = await response.json();
return result;
} catch (error) {
console.error('文件上传失败:', error);
return { success: 0, message: error.message };
}
}
function downloadTemplate(tableName) {
const templates = {
't_material': [
['物料号', '物料描述', '工厂', '物料类型', '虚拟件', '可否延迟', '批量策略', '提前期', '最小批量', '最大批量', '单位'],
['MAT001', '示例物料', '1000', 'P', 'N', 'Y', 'EX', '10', '1', '100', 'EA']
],
't_workcenter': [
['工作中心', '描述', '瓶颈', '有限产能', '产能'],
['WC001', '示例工作中心', 'N', 'Y', '100']
],
't_mat_ver': [
['物料号', '版本号', '描述', '激活', '批量下限', '批量上限'],
['MAT001', 'V1', '示例版本', 'Y', '1', '1000']
],
't_mat_wc': [
['物料号', '版本号', '工序号', '工作中心', '串并行', '基础工时'],
['MAT001', 'V1', 'P01', 'WC001', 'S', '60']
],
't_mat_wc_bom': [
['父件料号', '子件料号', '版本号', '工序号', '用量', '损耗率', 'MTO', '替代料'],
['MAT001', 'COMP001', 'V1', 'P01', '2', '5', 'N', 'N']
],
't_mold': [
['模具编号', '描述', '类型', '状态', '穴数', '台数'],
['MOLD001', '示例模具', '注塑', '空闲', '4', '1']
],
't_mat_wc_mold': [
['物料号', '工作中心', '工序号', '模具编号', 'UPH'],
['MAT001', 'WC001', 'P01', 'MOLD001', '100']
]
};
const data = templates[tableName] || [['暂无模板']];
let csv = data.map(row => row.join(',')).join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${tableName}_template.csv`;
link.click();
}
+307
View File
@@ -0,0 +1,307 @@
/**
* 数据列表组件
*/
class DataTable {
constructor(config) {
this.tableName = config.tableName;
this.columns = config.columns || [];
this.container = config.container || document.getElementById('tableContainer');
this.pageSize = config.pageSize || 20;
this.currentPage = 1;
this.total = 0;
this.data = [];
this.filters = {};
this.sortField = '_createtime';
this.sortOrder = 'desc';
this.onRowClick = config.onRowClick;
this.onSelectionChange = config.onSelectionChange;
this.selectedIds = new Set();
this.init();
}
init() {
this.render();
this.bindEvents();
}
render() {
this.container.innerHTML = `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAll">
</th>
${this.columns.map(col => `
<th style="${col.width ? 'width:' + col.width : ''}"
data-field="${col.field}"
class="${col.sortable ? 'sortable' : ''}">
${col.title}
${col.sortable ? '<i class="bi bi-arrow-down-up"></i>' : ''}
</th>
`).join('')}
</tr>
</thead>
<tbody id="tableBody">
<tr>
<td colspan="${this.columns.length + 1}" class="text-center text-muted py-4">
加载中...
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center p-3">
<div>
<span id="totalInfo"> 0 </span>
<button class="btn btn-sm btn-outline-danger ms-2" id="batchDeleteBtn" disabled>
批量删除 (<span id="selectedCount">0</span>)
</button>
</div>
<nav>
<ul class="pagination" id="pagination"></ul>
</nav>
</div>
`;
}
bindEvents() {
const selectAll = document.getElementById('selectAll');
if (selectAll) {
selectAll.addEventListener('change', (e) => {
const checked = e.target.checked;
document.querySelectorAll('.row-checkbox').forEach(cb => {
cb.checked = checked;
const id = parseInt(cb.dataset.id);
if (checked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
});
this.updateSelectedCount();
});
}
const batchDeleteBtn = document.getElementById('batchDeleteBtn');
if (batchDeleteBtn) {
batchDeleteBtn.addEventListener('click', () => this.batchDelete());
}
this.container.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.field;
if (this.sortField === field) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortOrder = 'desc';
}
this.loadData();
});
});
}
async loadData(params = {}) {
showLoading();
this.filters = { ...this.filters, ...params };
const queryParams = new URLSearchParams({
page: this.currentPage,
page_size: this.pageSize,
sort_field: this.sortField,
sort_order: this.sortOrder,
...this.filters
});
const response = await callApi(`/list/${this.tableName}?${queryParams}`);
hideLoading();
handleResponse(response, (data) => {
this.data = data.data.records || [];
this.total = data.data.total || 0;
this.renderTable();
this.renderPagination();
});
}
renderTable() {
const tbody = document.getElementById('tableBody');
const totalInfo = document.getElementById('totalInfo');
if (totalInfo) {
totalInfo.textContent = `${this.total}`;
}
if (this.data.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="${this.columns.length + 1}" class="text-center text-muted py-4">
暂无数据
</td>
</tr>
`;
return;
}
tbody.innerHTML = this.data.map(row => `
<tr data-id="${row._staging_id}" class="table-row">
<td>
<input type="checkbox" class="form-check-input row-checkbox"
data-id="${row._staging_id}"
${this.selectedIds.has(row._staging_id) ? 'checked' : ''}>
</td>
${this.columns.map(col => `
<td>${this.renderCell(col, row)}</td>
`).join('')}
</tr>
`).join('');
tbody.querySelectorAll('.row-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => {
const id = parseInt(e.target.dataset.id);
if (e.target.checked) {
this.selectedIds.add(id);
} else {
this.selectedIds.delete(id);
}
this.updateSelectedCount();
});
});
tbody.querySelectorAll('.table-row').forEach(tr => {
tr.addEventListener('click', (e) => {
if (e.target.type === 'checkbox') return;
const id = parseInt(tr.dataset.id);
const rowData = this.data.find(r => r._staging_id === id);
if (this.onRowClick) {
this.onRowClick(rowData);
}
});
});
}
renderCell(col, row) {
let value = row[col.field];
if (col.render) {
return col.render(value, row);
}
if (col.field === '_status') {
return formatStatus(value);
}
if (col.field === '_createtime' || col.field === '_updatetime' || col.field === '_synced_time') {
return formatDate(value);
}
if (value === null || value === undefined) {
return '<span class="text-muted">-</span>';
}
if (typeof value === 'string' && value.length > 30) {
return `<span title="${escapeHtml(value)}">${escapeHtml(truncateText(value, 30))}</span>`;
}
return escapeHtml(value);
}
renderPagination() {
const pagination = document.getElementById('pagination');
const totalPages = Math.ceil(this.total / this.pageSize);
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
html += `
<li class="page-item ${this.currentPage === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${this.currentPage - 1}">上一页</a>
</li>
`;
const startPage = Math.max(1, this.currentPage - 2);
const endPage = Math.min(totalPages, this.currentPage + 2);
for (let i = startPage; i <= endPage; i++) {
html += `
<li class="page-item ${i === this.currentPage ? 'active' : ''}">
<a class="page-link" href="#" data-page="${i}">${i}</a>
</li>
`;
}
html += `
<li class="page-item ${this.currentPage === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${this.currentPage + 1}">下一页</a>
</li>
`;
pagination.innerHTML = html;
pagination.querySelectorAll('.page-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const page = parseInt(link.dataset.page);
if (page >= 1 && page <= totalPages && page !== this.currentPage) {
this.currentPage = page;
this.loadData();
}
});
});
}
updateSelectedCount() {
const count = this.selectedIds.size;
const selectedCount = document.getElementById('selectedCount');
const batchDeleteBtn = document.getElementById('batchDeleteBtn');
if (selectedCount) selectedCount.textContent = count;
if (batchDeleteBtn) batchDeleteBtn.disabled = count === 0;
if (this.onSelectionChange) {
this.onSelectionChange(Array.from(this.selectedIds));
}
}
async batchDelete() {
if (this.selectedIds.size === 0) return;
if (!confirm(`确定删除选中的 ${this.selectedIds.size} 条记录吗?`)) return;
showLoading();
const response = await callApi(`/batch_delete/${this.tableName}`, 'POST', Array.from(this.selectedIds));
hideLoading();
handleResponse(response, () => {
showMessage('删除成功', 'success');
this.selectedIds.clear();
this.loadData();
});
}
refresh() {
this.loadData();
}
setFilter(key, value) {
if (value) {
this.filters[key] = value;
} else {
delete this.filters[key];
}
this.currentPage = 1;
this.loadData();
}
}
+376
View File
@@ -0,0 +1,376 @@
/**
* 物料数据特定逻辑
*/
const TABLE_NAME = 't_material';
const TABLE_COLUMNS = [
{ field: '_staging_id', title: 'ID', width: '60px' },
{ field: 'materialno', title: '物料号', width: '120px', sortable: true },
{ field: 'description', title: '物料描述', width: '200px' },
{ field: 'plant', title: '工厂', width: '80px' },
{ field: 'type', title: '类型', width: '60px' },
{ field: 'unit', title: '单位', width: '60px' },
{ field: '_source_system', title: '来源', width: '80px' },
{ field: '_status', title: '状态', width: '100px' },
{ field: '_createtime', title: '创建时间', width: '150px', sortable: true }
];
let dataTable;
let statusCard;
function initPage() {
statusCard = new StatusCard({
tableName: TABLE_NAME,
container: document.getElementById('statusCardContainer'),
onStatusClick: (status) => {
dataTable.setFilter('_status', status);
}
});
dataTable = new DataTable({
tableName: TABLE_NAME,
columns: TABLE_COLUMNS,
container: document.getElementById('tableContainer'),
pageSize: 20,
onRowClick: (row) => showDetailModal(row)
});
bindFilterEvents();
bindActionEvents();
bindUploadEvents();
}
function bindFilterEvents() {
const statusFilter = document.getElementById('statusFilter');
const sourceFilter = document.getElementById('sourceFilter');
const keywordInput = document.getElementById('keywordInput');
const searchBtn = document.getElementById('searchBtn');
const resetBtn = document.getElementById('resetBtn');
if (statusFilter) {
statusFilter.addEventListener('change', (e) => {
dataTable.setFilter('_status', e.target.value);
});
}
if (sourceFilter) {
sourceFilter.addEventListener('change', (e) => {
dataTable.setFilter('source_system', e.target.value);
});
}
if (searchBtn && keywordInput) {
searchBtn.addEventListener('click', () => {
dataTable.setFilter('keyword', keywordInput.value.trim());
});
keywordInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
dataTable.setFilter('keyword', keywordInput.value.trim());
}
});
}
if (resetBtn) {
resetBtn.addEventListener('click', () => {
if (statusFilter) statusFilter.value = '';
if (sourceFilter) sourceFilter.value = '';
if (keywordInput) keywordInput.value = '';
statusCard.setActiveStatus(null);
dataTable.filters = {};
dataTable.loadData();
});
}
}
function bindActionEvents() {
const validateBtn = document.getElementById('validateBtn');
const validateAllBtn = document.getElementById('validateAllBtn');
const syncBtn = document.getElementById('syncBtn');
const syncAllBtn = document.getElementById('syncAllBtn');
if (validateBtn) {
validateBtn.addEventListener('click', () => validateData());
}
if (validateAllBtn) {
validateAllBtn.addEventListener('click', () => validateAllData());
}
if (syncBtn) {
syncBtn.addEventListener('click', () => syncData());
}
if (syncAllBtn) {
syncAllBtn.addEventListener('click', () => syncAllData());
}
}
function bindUploadEvents() {
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const dedupStrategy = document.getElementById('dedupStrategy');
if (uploadArea && fileInput) {
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileUpload(e.target.files[0]);
}
});
}
if (uploadBtn) {
uploadBtn.addEventListener('click', () => {
if (fileInput && fileInput.files.length > 0) {
handleFileUpload(fileInput.files[0]);
} else {
showMessage('请先选择文件', 'warning');
}
});
}
}
async function handleFileUpload(file) {
if (!file.name.match(/\.(xlsx|xls|csv)$/i)) {
showMessage('请上传Excel或CSV文件', 'warning');
return;
}
const strategy = document.getElementById('dedupStrategy')?.value || 'skip';
showLoading();
const response = await uploadFile(TABLE_NAME, file, strategy);
hideLoading();
handleResponse(response, (data) => {
const result = data.data;
showMessage(`导入完成: 成功${result.inserted}条, 跳过${result.skipped}`, 'success');
if (fileInput) fileInput.value = '';
dataTable.refresh();
statusCard.refresh();
});
}
async function validateData() {
showLoading();
const response = await callApi(`/validate/${TABLE_NAME}`, 'POST');
hideLoading();
handleResponse(response, (data) => {
const stats = data.data;
showMessage(`校验完成: 通过${stats.validated}条, 失败${stats.rejected}`, 'success');
dataTable.refresh();
statusCard.refresh();
});
}
async function validateAllData() {
if (!confirm('确定要校验所有待处理数据吗?')) return;
showLoading();
const response = await callApi('/validate_all', 'POST');
hideLoading();
handleResponse(response, (data) => {
showMessage('所有表校验完成', 'success');
dataTable.refresh();
statusCard.refresh();
});
}
async function syncData() {
showLoading();
const response = await callApi(`/sync/${TABLE_NAME}`, 'POST');
hideLoading();
handleResponse(response, (data) => {
const stats = data.data;
showMessage(`同步完成: 成功${stats.synced}条, 失败${stats.failed}`, 'success');
dataTable.refresh();
statusCard.refresh();
});
}
async function syncAllData() {
if (!confirm('确定要同步所有校验通过的数据吗?')) return;
showLoading();
const response = await callApi('/sync_all', 'POST');
hideLoading();
handleResponse(response, (data) => {
showMessage('所有表同步完成', 'success');
dataTable.refresh();
statusCard.refresh();
});
}
function showDetailModal(row) {
const modal = new bootstrap.Modal(document.getElementById('detailModal'));
document.getElementById('detailTitle').textContent = `记录详情 - ${row.materialno}`;
const detailContent = document.getElementById('detailContent');
detailContent.innerHTML = `
<table class="table table-sm">
<tbody>
${Object.entries(row).map(([key, value]) => `
<tr>
<th style="width: 150px;">${escapeHtml(key)}</th>
<td>${formatDetailValue(key, value)}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
const editBtn = document.getElementById('editBtn');
const deleteBtn = document.getElementById('deleteBtn');
if (editBtn) {
editBtn.onclick = () => showEditModal(row);
}
if (deleteBtn) {
deleteBtn.onclick = () => deleteRecord(row._staging_id);
}
if (row._status === 'rejected' && row._error_msg) {
showErrorDetail(row._error_msg);
}
modal.show();
}
function formatDetailValue(key, value) {
if (key === '_status') {
return formatStatus(value);
}
if (key === '_createtime' || key === '_updatetime' || key === '_synced_time') {
return formatDate(value);
}
if (value === null || value === undefined) {
return '<span class="text-muted">-</span>';
}
return escapeHtml(String(value));
}
function showErrorDetail(errorMsg) {
try {
const errors = JSON.parse(errorMsg);
const errorContainer = document.getElementById('errorContainer');
if (errorContainer && errors.length > 0) {
errorContainer.innerHTML = `
<div class="alert alert-danger">
<strong>校验错误</strong>
${errors.map(e => `
<div class="error-detail mt-2">
<div><span class="error-type">${e.error_type}</span> - <span class="error-field">${e.error_field}</span></div>
<div class="error-message">${escapeHtml(e.error_message)}</div>
</div>
`).join('')}
</div>
`;
}
} catch (e) {
console.error('解析错误信息失败:', e);
}
}
function showEditModal(row) {
const modal = new bootstrap.Modal(document.getElementById('editModal'));
const editForm = document.getElementById('editForm');
editForm.innerHTML = TABLE_COLUMNS
.filter(col => !col.field.startsWith('_'))
.map(col => `
<div class="mb-3">
<label class="form-label">${col.title}</label>
<input type="text" class="form-control" name="${col.field}"
value="${escapeHtml(row[col.field] || '')}">
</div>
`).join('');
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
saveBtn.onclick = () => saveRecord(row._staging_id);
}
modal.show();
}
async function saveRecord(stagingId) {
const form = document.getElementById('editForm');
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
data[key] = value;
});
showLoading();
const response = await callApi(`/update/${TABLE_NAME}/${stagingId}`, 'PATCH', data);
hideLoading();
handleResponse(response, () => {
showMessage('保存成功', 'success');
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
dataTable.refresh();
});
}
async function deleteRecord(stagingId) {
if (!confirm('确定删除此记录吗?')) return;
showLoading();
const response = await callApi(`/delete/${TABLE_NAME}/${stagingId}`, 'DELETE');
hideLoading();
handleResponse(response, () => {
showMessage('删除成功', 'success');
bootstrap.Modal.getInstance(document.getElementById('detailModal')).hide();
dataTable.refresh();
statusCard.refresh();
});
}
document.addEventListener('DOMContentLoaded', initPage);
+129
View File
@@ -0,0 +1,129 @@
/**
* 状态统计卡片组件
*/
class StatusCard {
constructor(config) {
this.tableName = config.tableName;
this.container = config.container || document.getElementById('statusCardContainer');
this.onStatusClick = config.onStatusClick;
this.activeStatus = null;
this.stats = {};
this.init();
}
init() {
this.render();
this.loadStats();
}
render() {
this.container.innerHTML = `
<div class="row g-3">
<div class="col-md-3">
<div class="card status-card" data-status="pending">
<div class="card-body text-center">
<div class="status-number text-warning" id="pendingCount">-</div>
<div class="status-label">待处理</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card status-card" data-status="validated">
<div class="card-body text-center">
<div class="status-number text-success" id="validatedCount">-</div>
<div class="status-label">校验通过</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card status-card" data-status="rejected">
<div class="card-body text-center">
<div class="status-number text-danger" id="rejectedCount">-</div>
<div class="status-label">校验失败</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card status-card" data-status="synced">
<div class="card-body text-center">
<div class="status-number text-info" id="syncedCount">-</div>
<div class="status-label">已同步</div>
</div>
</div>
</div>
</div>
<div class="text-center mt-2">
<small class="text-muted">总计: <span id="totalCount">0</span> </small>
</div>
`;
this.bindEvents();
}
bindEvents() {
this.container.querySelectorAll('.status-card').forEach(card => {
card.addEventListener('click', () => {
const status = card.dataset.status;
if (this.activeStatus === status) {
this.activeStatus = null;
card.classList.remove('active');
if (this.onStatusClick) {
this.onStatusClick(null);
}
} else {
this.container.querySelectorAll('.status-card').forEach(c => c.classList.remove('active'));
card.classList.add('active');
this.activeStatus = status;
if (this.onStatusClick) {
this.onStatusClick(status);
}
}
});
});
}
async loadStats() {
const response = await callApi(`/status/${this.tableName}`);
handleResponse(response, (data) => {
this.stats = data.data || {};
this.updateDisplay();
});
}
updateDisplay() {
const pending = document.getElementById('pendingCount');
const validated = document.getElementById('validatedCount');
const rejected = document.getElementById('rejectedCount');
const synced = document.getElementById('syncedCount');
const total = document.getElementById('totalCount');
if (pending) pending.textContent = this.stats.pending || 0;
if (validated) validated.textContent = this.stats.validated || 0;
if (rejected) rejected.textContent = this.stats.rejected || 0;
if (synced) synced.textContent = this.stats.synced || 0;
if (total) total.textContent = this.stats.total || 0;
}
refresh() {
this.loadStats();
}
setActiveStatus(status) {
this.activeStatus = status;
this.container.querySelectorAll('.status-card').forEach(card => {
if (card.dataset.status === status) {
card.classList.add('active');
} else {
card.classList.remove('active');
}
});
}
getStats() {
return this.stats;
}
}
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>产线版本数据清洗管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/mds/material">物料</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/workcenter">工作中心</a></li>
<li class="nav-item"><a class="nav-link active" href="/mds/mat-ver">产线版本</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc">工艺路线</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-bom">BOM</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mold">模具</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-mold">机台模具</a></li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div class="alert alert-info text-center py-5">
<h3>产线版本数据清洗管理</h3>
<p class="mb-0">功能开发中,敬请期待...</p>
</div>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOM数据清洗管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/mds/material">物料</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/workcenter">工作中心</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-ver">产线版本</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc">工艺路线</a></li>
<li class="nav-item"><a class="nav-link active" href="/mds/mat-wc-bom">BOM</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mold">模具</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-mold">机台模具</a></li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div class="alert alert-info text-center py-5">
<h3>BOM数据清洗管理</h3>
<p class="mb-0">功能开发中,敬请期待...</p>
</div>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>机台模具关联数据清洗管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/mds/material">物料</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/workcenter">工作中心</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-ver">产线版本</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc">工艺路线</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-bom">BOM</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mold">模具</a></li>
<li class="nav-item"><a class="nav-link active" href="/mds/mat-wc-mold">机台模具</a></li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div class="alert alert-info text-center py-5">
<h3>机台模具关联数据清洗管理</h3>
<p class="mb-0">功能开发中,敬请期待...</p>
</div>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工艺路线数据清洗管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/mds/material">物料</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/workcenter">工作中心</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-ver">产线版本</a></li>
<li class="nav-item"><a class="nav-link active" href="/mds/mat-wc">工艺路线</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-bom">BOM</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mold">模具</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-mold">机台模具</a></li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div class="alert alert-info text-center py-5">
<h3>工艺路线数据清洗管理</h3>
<p class="mb-0">功能开发中,敬请期待...</p>
</div>
</div>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>物料数据清洗管理</title>
<!-- Bootstrap CSS (CDN) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/mds/css/custom.css">
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="/mds/material">物料</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/mds/workcenter">工作中心</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/mds/mat-ver">产线版本</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/mds/mat-wc">工艺路线</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/mds/mat-wc-bom">BOM</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/mds/mold">模具</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/mds/mat-wc-mold">机台模具</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<!-- 页面标题 -->
<div class="row mb-4">
<div class="col-12">
<h4>物料数据清洗管理</h4>
</div>
</div>
<!-- 状态统计卡片 -->
<div class="row mb-4">
<div class="col-12" id="statusCardContainer"></div>
</div>
<!-- 操作按钮区域 -->
<div class="row mb-3">
<div class="col-md-6">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
导入数据
</button>
<button class="btn btn-success" id="validateBtn">触发校验</button>
<button class="btn btn-info" id="syncBtn">同步到正式表</button>
<button class="btn btn-outline-secondary" id="validateAllBtn">校验所有表</button>
<button class="btn btn-outline-secondary" id="syncAllBtn">同步所有表</button>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-outline-primary" onclick="downloadTemplate('t_material')">下载模板</button>
</div>
</div>
<!-- 筛选区域 -->
<div class="filter-bar">
<div class="row">
<div class="col-md-3">
<select class="form-select" id="statusFilter">
<option value="">全部状态</option>
<option value="pending">待处理</option>
<option value="validated">校验通过</option>
<option value="rejected">校验失败</option>
<option value="synced">已同步</option>
</select>
</div>
<div class="col-md-3">
<select class="form-select" id="sourceFilter">
<option value="">全部来源</option>
<option value="ERP">ERP</option>
<option value="MES">MES</option>
<option value="PLM">PLM</option>
<option value="excel">Excel</option>
</select>
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="keywordInput" placeholder="搜索物料号...">
</div>
<div class="col-md-2">
<button class="btn btn-primary" id="searchBtn">查询</button>
<button class="btn btn-outline-secondary" id="resetBtn">重置</button>
</div>
</div>
</div>
<!-- 数据列表区域 -->
<div class="data-table-container" id="tableContainer"></div>
</div>
<!-- 上传弹窗 -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">导入Excel数据</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">去重策略</label>
<select class="form-select" id="dedupStrategy">
<option value="skip">跳过重复(推荐)</option>
<option value="overwrite">覆盖重复</option>
<option value="reject">拒绝重复</option>
</select>
</div>
<div class="file-upload-area" id="uploadArea">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="text-muted mb-3" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
<p class="mb-0">点击或拖拽文件到此处上传</p>
<small class="text-muted">支持 .xlsx, .xls, .csv 格式</small>
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" style="display: none;">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="uploadBtn">上传</button>
</div>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<div class="modal fade" id="detailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="detailTitle">记录详情</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="errorContainer"></div>
<div id="detailContent"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-primary" id="editBtn">编辑</button>
<button type="button" class="btn btn-outline-danger" id="deleteBtn">删除</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑记录</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editForm"></form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="saveBtn">保存</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/mds/js/common.js"></script>
<script src="/static/mds/js/data-table.js"></script>
<script src="/static/mds/js/status-card.js"></script>
<script src="/static/mds/js/material.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模具数据清洗管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/mds/material">物料</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/workcenter">工作中心</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-ver">产线版本</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc">工艺路线</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-bom">BOM</a></li>
<li class="nav-item"><a class="nav-link active" href="/mds/mold">模具</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-mold">机台模具</a></li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div class="alert alert-info text-center py-5">
<h3>模具数据清洗管理</h3>
<p class="mb-0">功能开发中,敬请期待...</p>
</div>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工作中心数据清洗管理</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container-fluid">
<a class="navbar-brand" href="/mds">数据清洗管理系统</a>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/mds/material">物料</a></li>
<li class="nav-item"><a class="nav-link active" href="/mds/workcenter">工作中心</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-ver">产线版本</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc">工艺路线</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-bom">BOM</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mold">模具</a></li>
<li class="nav-item"><a class="nav-link" href="/mds/mat-wc-mold">机台模具</a></li>
</ul>
</div>
</div>
</nav>
<div class="container">
<div class="alert alert-info text-center py-5">
<h3>工作中心数据清洗管理</h3>
<p class="mb-0">功能开发中,敬请期待...</p>
</div>
</div>
</body>
</html>