mirror of
https://github.com/rnvm9wjdtj-bot/myaps_api.git
synced 2026-06-02 05:54:40 +00:00
初步搭建缓冲表功能
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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, [])
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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='...'
|
||||
# )
|
||||
|
||||
#################################################################################
|
||||
# ⬇️ 项目可复用逻辑
|
||||
|
||||
@@ -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: 控制台窗口输出
|
||||
@@ -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=
|
||||
```
|
||||
|
||||
应用会自动跳过自有数据库初始化。
|
||||
|
||||
## 数据持久化
|
||||
|
||||
以下目录已配置持久化挂载:
|
||||
|
||||
@@ -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
|
||||
Executable
+224
@@ -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
|
||||
@@ -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');
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
+7
File diff suppressed because one or more lines are too long
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user