certflow.handlers.data_cleaner 源代码
"""数据清洗处理器模块
提供销售计划数据的清洗和校验功能,包括字符串清理、
数量字段规范化、订单号去特殊字符、统一空值处理等.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any
import pandas as pd
from loguru import logger
from certflow.handlers.id_generator import IDGenerator
from certflow.utils.date_utils import normalize_plan_date
[文档]
class DataCleaner:
"""数据清洗器
提供销售计划数据的清洗和校验功能.
负责处理原始导入数据中的格式问题、空值处理和字段规范化.
Attributes:
DATE_FIELDS: 日期字段列表,定义需要规范化的日期字段名称
"""
# 定义日期字段列表
DATE_FIELDS = ["plan_date", "planned_delivery_date", "contract_delivery_date"]
@staticmethod
def _normalize_date(value: Any) -> str:
"""规范化日期格式,只保留年月日
将各种格式的日期输入统一转换为YYYY-MM-DD格式.
委托给全局工具方法 normalize_plan_date 处理.
Args:
value: 原始日期值,可以是datetime对象、Timestamp对象或字符串
Returns:
str: 格式化为YYYY-MM-DD的日期字符串,转换失败时返回空字符串
Examples:
>>> DataCleaner._normalize_date("2024-01-15 14:30:00")
"2024-01-15"
>>> DataCleaner._normalize_date("2024/01/15")
"2024-01-15"
>>> DataCleaner._normalize_date(None)
""
"""
if value is None or value == "":
return ""
try:
# _fill_empty_date 会给空日期加 "*" 后缀,先去掉
value_str = str(value).rstrip("*") if isinstance(value, str) else value
result = normalize_plan_date(value_str)
if result is None:
return str(value) if value else ""
return result
except Exception as e:
logger.warning(f"日期格式转换失败: {value}, 错误: {e}")
return str(value) if value else ""
[文档]
@staticmethod
def clean_sale_plan(df: pd.DataFrame) -> pd.DataFrame:
"""清洗销售计划数据
对原始导入的 DataFrame 执行标准化清洗:去除字符串首尾空格、
规范化日期字段、清洗订单号特殊字符、数值/文本字段规范化,
并对销售计划日期填充当月默认值。
Args:
df: 原始销售计划 DataFrame,列应为字符串或数值类型
Returns:
pd.DataFrame: 清洗后的 DataFrame(字段已规范化,空值填充为【空白】标记)
Examples:
>>> import pandas as pd
>>> df = pd.DataFrame({
... "product_model": [" 阀门A ", ""],
... "order_no": ["PO-001", "PO/002"],
... "quantity": ["10", "5"],
... })
>>> cleaned = DataCleaner.clean_sale_plan(df)
>>> cleaned["product_model"][0]
"阀门A"
"""
df_clean = df.copy()
# 1. 清理字符串列的空格
string_columns = df_clean.select_dtypes(include=["object"]).columns
for col in string_columns:
df_clean[col] = df_clean[col].astype(str).str.strip()
# 2. 填充空计划日期
if "plan_date" in df_clean.columns:
df_clean["plan_date"] = df_clean["plan_date"].apply(DataCleaner._fill_empty_date)
# 3. 处理日期字段
for col in DataCleaner.DATE_FIELDS:
if col in df_clean.columns:
df_clean[col] = df_clean[col].apply(DataCleaner._normalize_date)
# 4. 清洗订单号
if "order_no" in df_clean.columns:
df_clean["order_no"] = df_clean["order_no"].apply(
lambda x: re.sub(r"[^\w\-]", "", str(x)) if pd.notna(x) else ""
)
# 5. 处理数值和文本字段
df_clean = DataCleaner._normalize_fields(df_clean)
# §9 落地待办③:注入源表原始行号(血缘追溯,仅作追溯,不用于去重)。
# 必须放在 _normalize_fields 之后,否则会被当成文本列填 【空白】 标记。
# 1-based 文件内序号(清洗后),随 to_dict("records") 自动透传到 save_handler。
df_clean = df_clean.reset_index(drop=True)
df_clean["source_row_index"] = range(1, len(df_clean) + 1)
# 统计
logger.info(
f"数据清洗完成: {len(df_clean)} 条记录, "
f"原始记录数: {len(df)}, 清洗掉 {len(df) - len(df_clean)} 条记录"
)
return df_clean
@staticmethod
def _fill_empty_date(x: Any) -> str:
"""空日期填充为当月,带*标记
Args:
x: 原始日期值(可能为 NaN、空字符串或日期字符串)
Returns:
str: 填充后的日期字符串(当月 YYYY-MM-DD 加 "*" 标记),否则原样返回
"""
if pd.isna(x) or str(x).strip() == "":
return datetime.now().strftime("%Y-%m-%d") + "*"
return x
@staticmethod
def _normalize_fields(df_clean: pd.DataFrame) -> pd.DataFrame:
"""识别数值/文本字段并规范化"""
numeric_keywords = ["quantity", "price", "total", "weight", "unit", "amount"]
text_fields = []
for col in df_clean.columns:
is_numeric = any(k in col.lower() for k in numeric_keywords) or col in [
"quantity",
"unit_price",
"total_price",
"weight",
]
if is_numeric:
df_clean[col] = pd.to_numeric(df_clean[col], errors="coerce")
else:
text_fields.append(col)
for col in text_fields:
empty_marker = IDGenerator._get_empty_marker(col)
df_clean[col] = df_clean[col].fillna(empty_marker)
df_clean[col] = df_clean[col].replace(["", "nan", "None", "NaN"], empty_marker)
df_clean[col] = df_clean[col].astype(str).str.strip()
if "quantity" in df_clean.columns:
df_clean["quantity"] = df_clean["quantity"].fillna(1).astype(int)
return df_clean
[文档]
@staticmethod
def validate_data(row: dict[str, Any]) -> tuple[bool, str]:
"""校验单条数据
检查数据行中产品型号是否为空、数量是否大于0.
Args:
row: 单条数据字典,包含product_model和quantity等字段
Returns:
Tuple[bool, str]: 校验结果元组
- 第一个元素: 是否通过校验,True表示通过,False表示不通过
- 第二个元素: 校验结果描述信息,通过时返回"校验通过"
Examples:
>>> row = {"product_model": "阀门A", "quantity": 10}
>>> is_valid, message = DataCleaner.validate_data(row)
>>> print(is_valid)
True
>>>
>>> row = {"product_model": "", "quantity": 5}
>>> is_valid, message = DataCleaner.validate_data(row)
>>> print(message)
"产品型号不能为空"
"""
# 注意: 此时空值已经被规范化为【空白产品型号】格式
product_model = row.get("product_model", "")
if not product_model or product_model == IDGenerator._get_empty_marker("product_model"):
return False, "产品型号不能为空"
quantity = row.get("quantity", 1)
try:
quantity = int(quantity)
except (ValueError, TypeError):
return False, "数量格式错误"
if quantity <= 0:
return False, "数量必须大于0"
return True, "校验通过"