Compare commits

...

8 Commits

Author SHA1 Message Date
Misaka_Company
1addb55df1 fix: auto-save report after clean() completes in GUI and CLI
- Add save_report parameter to DiscreteMaterialPlanCleaner (default True)
- Auto-save report at end of clean() method when save_report_enabled
- Remove explicit save_report() call from main() (now automatic)
- Pass save_report=True in GUI _delete_worker()

This ensures reports are saved for both GUI and CLI execution paths.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-27 17:21:29 +08:00
Misaka_Company
a37cf4ad91 fix: persist execution.dryrun setting and add headless mode option for User
- Add EXECUTION_DRYRUN to save_to_env() to fix dryrun setting not persisting
  after restart
- Add headless mode checkbox in User mode settings for browser visibility control

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-27 16:04:17 +08:00
Misaka_Company
2d485be0ac feat: add ERP credential configuration for User mode in settings
Allow User type users to configure ERP username and password through
the settings interface. The credentials are stored as shared config
in .env file, and the URL remains admin-configured.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-27 15:21:46 +08:00
Misaka_Company
4a17c8fd11 refactor: change error report format from table to code blocks
Display error messages as separate sections with code blocks instead of
table rows. This improves readability for long error messages that would
overflow in table cells.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-27 14:15:29 +08:00
Misaka_Company
f1d42ad708 fix: prevent table content overflow in Markdown reports
Add CSS properties to ensure table cells wrap long content properly:
- table-layout: fixed for consistent column widths
- word-wrap and overflow-wrap: break-word for content wrapping

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-27 14:14:15 +08:00
Misaka
3b7c00377f style: format all Python files with Black
Apply Black formatter to the entire codebase for consistent code style.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-26 22:44:03 +08:00
Misaka
1b16842a2c refactor: move action buttons to right panel for User mode in material validation
Move the four action buttons (全选, 取消全选, 确认删除, 执行删除) from the
top control panel to the right-side panel below 显示全部 for User mode.
Admin users continue to see these buttons in the top control panel.

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-26 22:40:44 +08:00
Misaka
18f784f067 refactor: extract common tab functionality to BaseTab and add decorators
- Add BaseTab base class for common tab initialization and logging
- Add constants module for shared GUI constants
- Move CheckboxTreeview to separate widget file
- Add admin_only and require_session decorators to utils.py
- Refactor DataExtractionTab and MaterialValidationTab to inherit BaseTab
- Remove duplicate _update_log method from tab classes

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-26 21:17:37 +08:00
46 changed files with 2267 additions and 1241 deletions

View File

@@ -1,6 +1,7 @@
"""
Auth package for user authentication and session management
"""
from .session_manager import SessionManager
__all__ = ['SessionManager']
__all__ = ["SessionManager"]

View File

@@ -1,6 +1,7 @@
"""
Session Manager - Singleton pattern for managing authenticated user session
"""
from typing import Optional, Dict, Any
@@ -28,7 +29,7 @@ class SessionManager:
self._initialized = True
@classmethod
def get_instance(cls) -> 'SessionManager':
def get_instance(cls) -> "SessionManager":
"""
Get the singleton instance of SessionManager
@@ -57,8 +58,8 @@ class SessionManager:
if user_info:
self._current_user = {
'username': user_info['username'],
'user_type': user_info['user_type']
"username": user_info["username"],
"user_type": user_info["user_type"],
}
return True
return False
@@ -79,8 +80,8 @@ class SessionManager:
if user_info:
self._current_user = {
'username': user_info['username'],
'user_type': user_info['user_type']
"username": user_info["username"],
"user_type": user_info["user_type"],
}
return True
return False
@@ -107,7 +108,7 @@ class SessionManager:
"""
if not self._current_user:
return False
return self._current_user.get('user_type') == 'Admin'
return self._current_user.get("user_type") == "Admin"
def is_guest(self) -> bool:
"""
@@ -118,7 +119,7 @@ class SessionManager:
"""
if not self._current_user:
return False
return self._current_user.get('user_type') == 'Guest'
return self._current_user.get("user_type") == "Guest"
def get_username(self) -> Optional[str]:
"""
@@ -129,7 +130,7 @@ class SessionManager:
"""
if not self._current_user:
return None
return self._current_user.get('username')
return self._current_user.get("username")
def get_user_type(self) -> Optional[str]:
"""
@@ -140,7 +141,7 @@ class SessionManager:
"""
if not self._current_user:
return None
return self._current_user.get('user_type')
return self._current_user.get("user_type")
def get_user_info(self) -> Optional[dict]:
"""
@@ -168,12 +169,12 @@ class SessionManager:
return False
# Store original admin user for reference
if not hasattr(self, '_original_admin_user'):
if not hasattr(self, "_original_admin_user"):
self._original_admin_user = self._current_user.copy()
self._current_user = {
'username': user_info['username'],
'user_type': user_info['user_type']
"username": user_info["username"],
"user_type": user_info["user_type"],
}
return True
@@ -184,4 +185,4 @@ class SessionManager:
Returns:
Original admin user dict if a switch occurred, None otherwise
"""
return getattr(self, '_original_admin_user', None)
return getattr(self, "_original_admin_user", None)

View File

@@ -5,6 +5,7 @@
定义所有配置项的默认值,从环境变量加载。
"""
from config.schema import (
ERPConfig,
DatabaseConfig,
@@ -17,7 +18,6 @@ from config.schema import (
DatabaseType,
)
# 默认配置 - 从环境变量加载
DEFAULT_APP_CONFIG = AppConfig.from_env()

View File

@@ -5,6 +5,7 @@
使用 python-dotenv 加载 .env 文件,并提供类型转换功能。
"""
import os
from pathlib import Path
from typing import Any, Optional, Type, TypeVar
@@ -111,7 +112,9 @@ def set_env(key: str, value: Any) -> None:
os.environ[key] = str(value)
def save_env_file(env_file: Optional[str] = None, env_dict: Optional[dict] = None) -> bool:
def save_env_file(
env_file: Optional[str] = None, env_dict: Optional[dict] = None
) -> bool:
"""
保存环境变量到 .env 文件

View File

@@ -5,6 +5,7 @@
负责加载、合并和验证配置,优先从环境变量加载。
"""
import json
import os
from typing import Any, Dict
@@ -27,7 +28,9 @@ class ConfigLoader:
"""配置加载器"""
@staticmethod
def load(config_file: str = "config/user_settings.json", use_env: bool = True) -> AppConfig:
def load(
config_file: str = "config/user_settings.json", use_env: bool = True
) -> AppConfig:
"""
加载配置
@@ -111,18 +114,36 @@ class ConfigLoader:
"ERP_IGNORE_HTTPS_ERRORS": config.erp.ignore_https_errors,
"ERP_AUTO_CLOSE_BROWSER": config.erp.auto_close_browser,
# 数据库配置
"DB_TYPE": config.database.db_type.value if isinstance(config.database.db_type, DatabaseType) else config.database.db_type,
"DB_TYPE": (
config.database.db_type.value
if isinstance(config.database.db_type, DatabaseType)
else config.database.db_type
),
"DB_SERVER": config.database.server,
"DB_NAME": config.database.database,
"DB_USERNAME": config.database.username,
"DB_PASSWORD": config.database.password,
# SQL Server 特定配置
"DB_SQLSERVER_DRIVER": config.database.sqlserver.driver if config.database.sqlserver else "ODBC Driver 18 for SQL Server",
"DB_TRUST_SERVER_CERTIFICATE": config.database.sqlserver.trust_server_certificate if config.database.sqlserver else "yes",
"DB_SQLSERVER_DRIVER": (
config.database.sqlserver.driver
if config.database.sqlserver
else "ODBC Driver 18 for SQL Server"
),
"DB_TRUST_SERVER_CERTIFICATE": (
config.database.sqlserver.trust_server_certificate
if config.database.sqlserver
else "yes"
),
# MySQL 特定配置
"DB_MYSQL_HOST": config.database.mysql.host if config.database.mysql else "",
"DB_MYSQL_PORT": config.database.mysql.port if config.database.mysql else 3306,
"DB_MYSQL_CHARSET": config.database.mysql.charset if config.database.mysql else "utf8mb4",
"DB_MYSQL_HOST": (
config.database.mysql.host if config.database.mysql else ""
),
"DB_MYSQL_PORT": (
config.database.mysql.port if config.database.mysql else 3306
),
"DB_MYSQL_CHARSET": (
config.database.mysql.charset if config.database.mysql else "utf8mb4"
),
# 路径配置
"PATH_DATA_DIR": config.paths.data_dir,
"PATH_PRODUCTION_ID_FILE": config.paths.production_id_file,
@@ -141,6 +162,8 @@ class ConfigLoader:
"VALIDATION_ENABLE_CRUD": config.validation.enable_crud_operations,
"VALIDATION_DEFAULT_MANAGER": config.validation.default_manager,
"VALIDATION_MATCH_MODE": config.validation.match_mode,
# 执行配置
"EXECUTION_DRYRUN": config.execution.dryrun,
}
return save_env_file(env_file, env_dict)
@@ -199,7 +222,9 @@ class ConfigLoader:
sqlserver_dict = database_dict.get("sqlserver", {})
sqlserver_config = SQLServerConfig(
driver=sqlserver_dict.get("driver", "ODBC Driver 18 for SQL Server"),
trust_server_certificate=sqlserver_dict.get("trust_server_certificate", "yes"),
trust_server_certificate=sqlserver_dict.get(
"trust_server_certificate", "yes"
),
)
# 解析 MySQL 配置
@@ -243,16 +268,18 @@ class ConfigLoader:
verbose=extraction_dict.get("verbose", True),
auto_convert=extraction_dict.get("auto_convert", True),
merge_batches=extraction_dict.get("merge_batches", True),
enable_db_persistence=extraction_dict.get("enable_db_persistence", False),
enable_db_persistence=extraction_dict.get(
"enable_db_persistence", False
),
),
validation=ValidationConfig(
data_source=validation_dict.get("data_source", "database_full"),
use_database=validation_dict.get("use_database", True),
batch_size=validation_dict.get("batch_size", 2000),
enable_crud_operations=validation_dict.get("enable_crud_operations", False),
enable_crud_operations=validation_dict.get(
"enable_crud_operations", False
),
default_manager=validation_dict.get("default_manager", ""),
match_mode=validation_dict.get("match_mode", "substring"),
),
)

View File

@@ -5,6 +5,7 @@
使用 dataclass 定义所有配置项的结构和类型。
"""
from dataclasses import dataclass, field
from typing import Optional
from pathlib import Path
@@ -13,6 +14,7 @@ from enum import Enum
class DatabaseType(str, Enum):
"""数据库类型枚举"""
SQLSERVER = "sqlserver"
MYSQL = "mysql"
@@ -57,6 +59,7 @@ class ERPConfig:
@dataclass
class SQLServerConfig:
"""SQL Server 特定配置"""
driver: str = "ODBC Driver 18 for SQL Server"
trust_server_certificate: str = "yes"
@@ -74,6 +77,7 @@ class SQLServerConfig:
@dataclass
class MySQLConfig:
"""MySQL 特定配置"""
host: str = ""
port: int = 3306
charset: str = "utf8mb4"
@@ -167,7 +171,9 @@ class PathConfig:
data_dir=get_env("PATH_DATA_DIR", "D:/python/playwrite/data/"),
production_id_file=get_env("PATH_PRODUCTION_ID_FILE", "ProductionID.txt"),
default_output=get_env("PATH_DEFAULT_OUTPUT", "离散备料计划维护_合并.xlsx"),
validation_output=get_env("PATH_VALIDATION_OUTPUT", "物料状态校验结果.xlsx"),
validation_output=get_env(
"PATH_VALIDATION_OUTPUT", "物料状态校验结果.xlsx"
),
)
def validate(self) -> list[str]:
@@ -200,7 +206,9 @@ class ExtractionConfig:
verbose=get_env_bool("EXTRACTION_VERBOSE", True),
auto_convert=get_env_bool("EXTRACTION_AUTO_CONVERT", True),
merge_batches=get_env_bool("EXTRACTION_MERGE_BATCHES", True),
enable_db_persistence=get_env_bool("EXTRACTION_ENABLE_DB_PERSISTENCE", False),
enable_db_persistence=get_env_bool(
"EXTRACTION_ENABLE_DB_PERSISTENCE", False
),
)
def validate(self) -> list[str]:
@@ -246,7 +254,7 @@ class ValidationConfig:
"database_full",
"database_filtered",
"excel_existing",
"excel_full"
"excel_full",
]
if self.data_source not in valid_sources:
errors.append(
@@ -281,6 +289,7 @@ class UIConfig:
def from_env(cls) -> "UIConfig":
"""从环境变量创建配置"""
from config.env_loader import get_env, get_env_int
return cls(
font_family=get_env("UI_FONT_FAMILY", "Microsoft YaHei UI"),
font_size=get_env_int("UI_FONT_SIZE", 10),
@@ -366,19 +375,35 @@ class AppConfig:
"auto_close_browser": self.erp.auto_close_browser,
},
"database": {
"db_type": self.database.db_type if isinstance(self.database.db_type, str) else self.database.db_type.value,
"db_type": (
self.database.db_type
if isinstance(self.database.db_type, str)
else self.database.db_type.value
),
"server": self.database.server,
"database": self.database.database,
"username": self.database.username,
"password": self.database.password,
"sqlserver": {
"driver": self.database.sqlserver.driver if self.database.sqlserver else "ODBC Driver 18 for SQL Server",
"trust_server_certificate": self.database.sqlserver.trust_server_certificate if self.database.sqlserver else "yes",
"driver": (
self.database.sqlserver.driver
if self.database.sqlserver
else "ODBC Driver 18 for SQL Server"
),
"trust_server_certificate": (
self.database.sqlserver.trust_server_certificate
if self.database.sqlserver
else "yes"
),
},
"mysql": {
"host": self.database.mysql.host if self.database.mysql else "",
"port": self.database.mysql.port if self.database.mysql else 3306,
"charset": self.database.mysql.charset if self.database.mysql else "utf8mb4",
"charset": (
self.database.mysql.charset
if self.database.mysql
else "utf8mb4"
),
},
},
"paths": {

View File

@@ -37,7 +37,9 @@ class BaseDatabaseConnection(ABC):
pass
@abstractmethod
def execute_query(self, sql: str, params: Optional[tuple] = None) -> List[Dict[str, Any]]:
def execute_query(
self, sql: str, params: Optional[tuple] = None
) -> List[Dict[str, Any]]:
"""
执行查询语句并返回结果

View File

@@ -19,6 +19,7 @@ class BaseDAO:
self.db: Optional[BaseDatabaseConnection] = None
# 从配置文件加载数据库类型
from config.loader import ConfigLoader
app_config = ConfigLoader.load()
self._db_type = app_config.database.db_type
@@ -50,7 +51,7 @@ class BaseDAO:
"""
if self._db_type == DatabaseType.MYSQL:
# SQL Server → MySQL
return TableNameConverter.convert_sql(sql, 'mysql')
return TableNameConverter.convert_sql(sql, "mysql")
return sql
def _get_placeholder(self) -> str:

View File

@@ -1,6 +1,7 @@
"""
BIPUsers DAO - Data access object for user authentication and management
"""
from typing import Optional, Dict, Any, List
from db.base_dao import BaseDAO
from db.connection import get_connection
@@ -22,7 +23,7 @@ class BIPUsersDAO(BaseDAO):
Dict with user info if authentication successful, None otherwise
Returns: {id, username, user_type}
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -43,13 +44,15 @@ class BIPUsersDAO(BaseDAO):
results = db.execute_query(sql, (username, password))
if results:
return {
'id': results[0]['ID'],
'username': results[0]['UserName'],
'user_type': results[0]['UserType']
"id": results[0]["ID"],
"username": results[0]["UserName"],
"user_type": results[0]["UserType"],
}
return None
def authenticate_by_computer_name(self, computer_name: str) -> Optional[Dict[str, Any]]:
def authenticate_by_computer_name(
self, computer_name: str
) -> Optional[Dict[str, Any]]:
"""
Authenticate a user using computer name (silent login)
@@ -60,7 +63,7 @@ class BIPUsersDAO(BaseDAO):
Dict with user info if authentication successful, None otherwise
Returns: {id, username, user_type}
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# Note: Column name is 'ComputerNmae' (typo in database schema)
@@ -81,9 +84,9 @@ class BIPUsersDAO(BaseDAO):
results = db.execute_query(sql, (computer_name,))
if results:
return {
'id': results[0]['ID'],
'username': results[0]['UserName'],
'user_type': results[0]['UserType']
"id": results[0]["ID"],
"username": results[0]["UserName"],
"user_type": results[0]["UserType"],
}
return None
@@ -94,7 +97,7 @@ class BIPUsersDAO(BaseDAO):
Returns:
List of user dictionaries: [{id, username, user_type, create_time}]
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -114,15 +117,17 @@ class BIPUsersDAO(BaseDAO):
results = db.execute_query(sql)
return [
{
'id': row['ID'],
'username': row['UserName'],
'user_type': row['UserType'],
'create_time': row['CreateTime']
"id": row["ID"],
"username": row["UserName"],
"user_type": row["UserType"],
"create_time": row["CreateTime"],
}
for row in results
]
def create_user(self, username: str, password: str, user_type: str, computer_name: str = '') -> bool:
def create_user(
self, username: str, password: str, user_type: str, computer_name: str = ""
) -> bool:
"""
Create a new user with optional computer name for silent login
@@ -135,7 +140,7 @@ class BIPUsersDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -185,7 +190,7 @@ class BIPUsersDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -221,7 +226,7 @@ class BIPUsersDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -256,7 +261,7 @@ class BIPUsersDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -289,7 +294,7 @@ class BIPUsersDAO(BaseDAO):
Returns:
True if username exists, False otherwise
"""
table_name = self._convert_sql('[dbo].[BIPUsers]')
table_name = self._convert_sql("[dbo].[BIPUsers]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -306,4 +311,4 @@ class BIPUsersDAO(BaseDAO):
with get_connection() as db:
results = db.execute_query(sql, (username,))
return results[0]['count'] > 0 if results else False
return results[0]["count"] > 0 if results else False

View File

@@ -34,6 +34,7 @@ def get_connection(config=None) -> BaseDatabaseConnection:
else:
# 从用户配置文件加载
from config.loader import ConfigLoader
app_config = ConfigLoader.load()
database_config = app_config.database

View File

@@ -16,8 +16,7 @@ class ConnectionFactory:
@staticmethod
def create_connection(
db_type: DatabaseType,
config: Optional[Dict[str, Any]] = None
db_type: DatabaseType, config: Optional[Dict[str, Any]] = None
) -> BaseDatabaseConnection:
"""
根据数据库类型创建对应的连接实例
@@ -58,14 +57,14 @@ class ConnectionFactory:
if db_type == DatabaseType.SQLSERVER:
# 构建 SQL Server 配置字典
config = {
'server': database_config.server,
'database': database_config.database,
'username': database_config.username,
'password': database_config.password,
"server": database_config.server,
"database": database_config.database,
"username": database_config.username,
"password": database_config.password,
}
if database_config.sqlserver:
config['driver'] = database_config.sqlserver.driver
config['trust_server_certificate'] = (
config["driver"] = database_config.sqlserver.driver
config["trust_server_certificate"] = (
database_config.sqlserver.trust_server_certificate
)
return SQLServerConnection(config)
@@ -73,17 +72,17 @@ class ConnectionFactory:
elif db_type == DatabaseType.MYSQL:
# 构建 MySQL 配置字典
config = {
'database': database_config.database,
'username': database_config.username,
'password': database_config.password,
"database": database_config.database,
"username": database_config.username,
"password": database_config.password,
}
if database_config.mysql:
config['host'] = database_config.mysql.host
config['port'] = database_config.mysql.port
config['charset'] = database_config.mysql.charset
config["host"] = database_config.mysql.host
config["port"] = database_config.mysql.port
config["charset"] = database_config.mysql.charset
else:
# 回退到 server 字段(兼容旧配置)
config['host'] = database_config.server
config["host"] = database_config.server
return MySQLConnection(config)
else:

View File

@@ -37,19 +37,21 @@ class DiscreteMaterialPlanDAO(BaseDAO):
... print(f"Deleted: {stats['deleted']}, Inserted: {stats['inserted']}")
"""
if df.empty:
return {'deleted': 0, 'inserted': 0}
return {"deleted": 0, "inserted": 0}
# Remove duplicates based on PlanNumber and SequenceNumber
original_count = len(df)
df = df.drop_duplicates(subset=['备料计划单号', '序号'], keep='first')
df = df.drop_duplicates(subset=["备料计划单号", "序号"], keep="first")
duplicates_removed = original_count - len(df)
if duplicates_removed > 0:
print(f"[INFO] 检测到 {duplicates_removed} 条重复记录(相同计划单号和序号),已自动去重")
print(
f"[INFO] 检测到 {duplicates_removed} 条重复记录(相同计划单号和序号),已自动去重"
)
with get_connection() as db:
# Get unique plan numbers
plan_numbers = df['备料计划单号'].unique().tolist()
plan_numbers = df["备料计划单号"].unique().tolist()
# Delete existing records
deleted = self._delete_by_plan_numbers(db, plan_numbers)
@@ -57,7 +59,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
# Insert new records in batches
inserted = self._batch_insert(db, df)
return {'deleted': deleted, 'inserted': inserted}
return {"deleted": deleted, "inserted": inserted}
def _delete_by_plan_numbers(self, db, plan_numbers: List[str]) -> int:
"""
@@ -79,12 +81,12 @@ class DiscreteMaterialPlanDAO(BaseDAO):
total_deleted = 0
for i in range(0, len(plan_numbers), batch_size):
batch = plan_numbers[i:i + batch_size]
batch = plan_numbers[i : i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
placeholders = ",".join([placeholder for _ in batch])
# 根据数据库类型选择表名
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"DELETE FROM {table_name} WHERE PlanNumber IN ({placeholders})"
deleted = db.execute_update(sql, tuple(batch))
@@ -108,7 +110,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
Total number of records inserted
"""
# 根据数据库类型选择表名
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
placeholder = self._get_placeholder()
sql = f"""
@@ -126,7 +128,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
records = self._convert_df_to_records(df)
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
batch = records[i : i + batch_size]
for record in batch:
db.execute_update(sql, record)
total_inserted += 1
@@ -150,20 +152,44 @@ class DiscreteMaterialPlanDAO(BaseDAO):
"""
# Column order must match INSERT statement
column_order = [
'工厂', '备料状态', '备料计划单号', '来源单号', '备料类型', '产品编码',
'产品名称', '产品单位', '产品计划数量', '用料部门', '备注', '制单人',
'制单日期', '审批人', '审批日期', '序号', '材料编码', '材料名称',
'规格', '型号', '图号', '物料材质', '计划数量', '单位', '需用日期',
'发料仓库', '单位用量', '累计出库数量', 'BOM版本'
"工厂",
"备料状态",
"备料计划单号",
"来源单号",
"备料类型",
"产品编码",
"产品名称",
"产品单位",
"产品计划数量",
"用料部门",
"备注",
"制单人",
"制单日期",
"审批人",
"审批日期",
"序号",
"材料编码",
"材料名称",
"规格",
"型号",
"图号",
"物料材质",
"计划数量",
"单位",
"需用日期",
"发料仓库",
"单位用量",
"累计出库数量",
"BOM版本",
]
# Numeric columns with their default values and data types
numeric_columns = {
'产品计划数量': (0, int),
'序号': (0, int),
'计划数量': (0, int),
'单位用量': (0.0, float),
'累计出库数量': (0, int),
"产品计划数量": (0, int),
"序号": (0, int),
"计划数量": (0, int),
"单位用量": (0.0, float),
"累计出库数量": (0, int),
}
records = []
@@ -172,7 +198,11 @@ class DiscreteMaterialPlanDAO(BaseDAO):
for col in column_order:
value = row.get(col)
# Handle NaN, None, or empty string values
if pd.isna(value) or value is None or (isinstance(value, str) and value.strip() == ''):
if (
pd.isna(value)
or value is None
or (isinstance(value, str) and value.strip() == "")
):
if col in numeric_columns:
# Use default value for numeric columns
record.append(numeric_columns[col][0])
@@ -209,7 +239,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
List of dictionaries representing records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
placeholder = self._get_placeholder()
sql = f"SELECT * FROM {table_name} WHERE PlanNumber = {placeholder}"
return db.execute_query(sql, (plan_number,))
@@ -227,8 +257,8 @@ class DiscreteMaterialPlanDAO(BaseDAO):
if not plan_numbers:
return []
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in plan_numbers])
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
placeholders = ",".join([placeholder for _ in plan_numbers])
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"SELECT * FROM {table_name} WHERE PlanNumber IN ({placeholders})"
with get_connection() as db:
return db.execute_query(sql, tuple(plan_numbers))
@@ -244,7 +274,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
List of dictionaries representing records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
placeholder = self._get_placeholder()
sql = f"SELECT * FROM {table_name} WHERE SourceNumber = {placeholder}"
return db.execute_query(sql, (order_id,))
@@ -260,11 +290,11 @@ class DiscreteMaterialPlanDAO(BaseDAO):
Number of records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
placeholder = self._get_placeholder()
sql = f"SELECT COUNT(*) as count FROM {table_name} WHERE PlanNumber = {placeholder}"
result = db.execute_query(sql, (plan_number,))
return result[0]['count'] if result else 0
return result[0]["count"] if result else 0
def count_all(self) -> int:
"""
@@ -274,10 +304,10 @@ class DiscreteMaterialPlanDAO(BaseDAO):
Total number of records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"SELECT COUNT(*) as count FROM {table_name}"
result = db.execute_query(sql)
return result[0]['count'] if result else 0
return result[0]["count"] if result else 0
def delete_by_plan_numbers(self, plan_numbers: List[str]) -> int:
"""
@@ -301,7 +331,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
unique plans, unique orders, and date range
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"""
SELECT
COUNT(*) as total_records,
@@ -324,7 +354,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
List of dictionaries representing all records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"SELECT * FROM {table_name}"
return db.execute_query(sql)
@@ -346,10 +376,10 @@ class DiscreteMaterialPlanDAO(BaseDAO):
all_results = []
for i in range(0, len(source_numbers), batch_size):
batch = source_numbers[i:i + batch_size]
batch = source_numbers[i : i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
placeholders = ",".join([placeholder for _ in batch])
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"SELECT * FROM {table_name} WHERE SourceNumber IN ({placeholders})"
with get_connection() as db:
results = db.execute_query(sql, tuple(batch))
@@ -368,7 +398,7 @@ class DiscreteMaterialPlanDAO(BaseDAO):
List of dictionaries representing deduplicated records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"""
WITH RankedRecords AS (
@@ -414,10 +444,10 @@ class DiscreteMaterialPlanDAO(BaseDAO):
all_results = []
for i in range(0, len(source_numbers), batch_size):
batch = source_numbers[i:i + batch_size]
batch = source_numbers[i : i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
placeholders = ",".join([placeholder for _ in batch])
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
sql = f"""
WITH RankedRecords AS (
@@ -458,23 +488,23 @@ class DiscreteMaterialPlanDAO(BaseDAO):
Returns:
List of unique material names
"""
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
table_name = self._convert_sql("[dbo].[DiscreteMaterialPlanData]")
if source_numbers is None or not source_numbers:
# No filter - get all unique material names
sql = f"SELECT DISTINCT MaterialName FROM {table_name} WHERE MaterialName IS NOT NULL"
with get_connection() as db:
results = db.execute_query(sql)
return [r['MaterialName'] for r in results if r.get('MaterialName')]
return [r["MaterialName"] for r in results if r.get("MaterialName")]
else:
# Filter by SourceNumber list
batch_size = 2000
all_material_names = set()
for i in range(0, len(source_numbers), batch_size):
batch = source_numbers[i:i + batch_size]
batch = source_numbers[i : i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
placeholders = ",".join([placeholder for _ in batch])
sql = f"""
SELECT DISTINCT MaterialName
FROM {table_name}
@@ -483,7 +513,9 @@ class DiscreteMaterialPlanDAO(BaseDAO):
"""
with get_connection() as db:
results = db.execute_query(sql, tuple(batch))
batch_materials = [r['MaterialName'] for r in results if r.get('MaterialName')]
batch_materials = [
r["MaterialName"] for r in results if r.get("MaterialName")
]
all_material_names.update(batch_materials)
return list(all_material_names)

View File

@@ -16,9 +16,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
# ==================== CREATE ====================
def insert_material(
self, material_name: str, manager_name: str
) -> bool:
def insert_material(self, material_name: str, manager_name: str) -> bool:
"""
Insert a single material record.
@@ -29,7 +27,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -65,7 +63,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
if not materials:
return 0
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -100,7 +98,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
List of all materials with MaterialName and ManagerName
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -131,7 +129,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
List of materials for the specified manager
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -160,7 +158,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
List of unique manager names
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -180,7 +178,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
with get_connection() as db:
results = db.execute_query(sql)
return [r['ManagerName'] for r in results if r.get('ManagerName')]
return [r["ManagerName"] for r in results if r.get("ManagerName")]
def get_material_names_by_manager(self, manager_name: str) -> List[str]:
"""
@@ -193,15 +191,12 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
List of material names for the specified manager
"""
results = self.get_materials_by_manager(manager_name)
return [r['MaterialName'] for r in results if r.get('MaterialName')]
return [r["MaterialName"] for r in results if r.get("MaterialName")]
# ==================== UPDATE ====================
def update_manager(
self,
material_name: str,
old_manager: str,
new_manager: str
self, material_name: str, old_manager: str, new_manager: str
) -> bool:
"""
Update manager for a specific material.
@@ -214,7 +209,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -233,7 +228,9 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
try:
with get_connection() as db:
affected = db.execute_update(sql, (new_manager, material_name, old_manager))
affected = db.execute_update(
sql, (new_manager, material_name, old_manager)
)
return affected > 0
except Exception as e:
print(f"Error updating manager: {e}")
@@ -241,11 +238,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
# ==================== DELETE ====================
def delete_material(
self,
material_name: str,
manager_name: str
) -> bool:
def delete_material(self, material_name: str, manager_name: str) -> bool:
"""
Delete a specific material record.
@@ -256,7 +249,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -289,7 +282,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
Number of records deleted
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -318,7 +311,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
Number of records deleted
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
sql = f"DELETE FROM {table_name}"
try:
@@ -340,7 +333,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
True if material exists, False otherwise
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -359,7 +352,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
with get_connection() as db:
result = db.execute_query(sql, (material_name,))
return result[0]['count'] > 0 if result else False
return result[0]["count"] > 0 if result else False
def count_by_manager(self, manager_name: str) -> int:
"""
@@ -371,7 +364,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
Number of materials for the manager
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -390,7 +383,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
with get_connection() as db:
result = db.execute_query(sql, (manager_name,))
return result[0]['count'] if result else 0
return result[0]["count"] if result else 0
def get_statistics(self) -> Dict[str, Any]:
"""
@@ -400,7 +393,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Dictionary with statistics including total materials,
unique managers, and materials per manager
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -442,8 +435,8 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
# Get materials per manager
manager_results = db.execute_query(manager_sql)
stats['materials_per_manager'] = [
{r['ManagerName']: r['count']} for r in manager_results
stats["materials_per_manager"] = [
{r["ManagerName"]: r["count"]} for r in manager_results
]
return stats
@@ -458,7 +451,7 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
Returns:
List of matching materials
"""
table_name = self._convert_sql('[dbo].[MaterialsTypeToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsTypeToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -478,4 +471,4 @@ class MaterialsTypeToBeDeletedDAO(BaseDAO):
"""
with get_connection() as db:
return db.execute_query(sql, (f'%{keyword}%',))
return db.execute_query(sql, (f"%{keyword}%",))

View File

@@ -40,7 +40,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
with get_connection() as db:
if self._db_type == DatabaseType.MYSQL:
# MySQL 使用 INSERT ... ON DUPLICATE KEY UPDATE
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
if self._db_type == DatabaseType.MYSQL:
@@ -57,7 +57,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
"""
else:
# SQL Server 使用 MERGE
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
sql = f"""
MERGE {table_name} AS target
USING (SELECT {self._get_placeholder()} AS MaterialCode, {self._get_placeholder()} AS ManagerName) AS source
@@ -69,7 +69,13 @@ class MaterialsToBeDeletedDAO(BaseDAO):
VALUES (source.MaterialCode, source.ManagerName);
"""
db.execute_update(sql, (material_code.strip(), manager_name.strip() if manager_name else None))
db.execute_update(
sql,
(
material_code.strip(),
manager_name.strip() if manager_name else None,
),
)
return True
except Exception as e:
print(f"Error upserting material: {e}")
@@ -86,24 +92,26 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Dictionary with statistics: {'total': int, 'success': int, 'failed': int}
"""
if not materials:
return {'total': 0, 'success': 0, 'failed': 0}
return {"total": 0, "success": 0, "failed": 0}
stats = {'total': len(materials), 'success': 0, 'failed': 0}
stats = {"total": len(materials), "success": 0, "failed": 0}
try:
with get_connection() as db:
for material in materials:
material_code = material.get('material_code', '').strip()
manager_name = material.get('manager_name', '')
material_code = material.get("material_code", "").strip()
manager_name = material.get("manager_name", "")
if not material_code:
stats['failed'] += 1
stats["failed"] += 1
continue
try:
if self._db_type == DatabaseType.MYSQL:
# MySQL 使用 INSERT ... ON DUPLICATE KEY UPDATE
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql(
"[dbo].[MaterialsToBeDeleted]"
)
placeholder = self._get_placeholder()
if self._db_type == DatabaseType.MYSQL:
@@ -120,7 +128,9 @@ class MaterialsToBeDeletedDAO(BaseDAO):
"""
else:
# SQL Server 使用 MERGE
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql(
"[dbo].[MaterialsToBeDeleted]"
)
sql = f"""
MERGE {table_name} AS target
USING (SELECT {self._get_placeholder()} AS MaterialCode, {self._get_placeholder()} AS ManagerName) AS source
@@ -132,15 +142,21 @@ class MaterialsToBeDeletedDAO(BaseDAO):
VALUES (source.MaterialCode, source.ManagerName);
"""
db.execute_update(sql, (material_code, manager_name.strip() if manager_name else None))
stats['success'] += 1
db.execute_update(
sql,
(
material_code,
manager_name.strip() if manager_name else None,
),
)
stats["success"] += 1
except Exception as e:
print(f"Error upserting material {material_code}: {e}")
stats['failed'] += 1
stats["failed"] += 1
except Exception as e:
print(f"Error in batch upsert: {e}")
stats['failed'] = stats['total'] - stats['success']
stats["failed"] = stats["total"] - stats["success"]
return stats
@@ -153,7 +169,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
Set of material codes
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -172,7 +188,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
try:
with get_connection() as db:
results = db.execute_query(sql)
return {r['MaterialCode'] for r in results if r.get('MaterialCode')}
return {r["MaterialCode"] for r in results if r.get("MaterialCode")}
except Exception as e:
print(f"Error getting material codes: {e}")
return set()
@@ -184,7 +200,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
List of all material records with all fields
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -215,7 +231,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
List of materials for the specified manager
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -244,7 +260,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
List of unique manager names
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -264,7 +280,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
with get_connection() as db:
results = db.execute_query(sql)
return [r['ManagerName'] for r in results if r.get('ManagerName')]
return [r["ManagerName"] for r in results if r.get("ManagerName")]
def get_records_by_manager(self, manager_name: str) -> List[Dict[str, Any]]:
"""
@@ -280,7 +296,9 @@ class MaterialsToBeDeletedDAO(BaseDAO):
"""
return self.get_materials_by_manager(manager_name)
def get_record_by_material_code(self, material_code: str) -> Optional[Dict[str, Any]]:
def get_record_by_material_code(
self, material_code: str
) -> Optional[Dict[str, Any]]:
"""
Get a specific record by material code.
@@ -290,7 +308,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
Dictionary representing the record, or None if not found
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -323,7 +341,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
True if successful, False otherwise
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -356,7 +374,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
Number of records deleted
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -385,7 +403,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
Number of records deleted
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
sql = f"DELETE FROM {table_name}"
try:
@@ -412,16 +430,18 @@ class MaterialsToBeDeletedDAO(BaseDAO):
total_deleted = 0
for i in range(0, len(material_codes), batch_size):
batch = material_codes[i:i + batch_size]
batch = material_codes[i : i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
placeholders = ",".join([placeholder for _ in batch])
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
sql = f"DELETE FROM {table_name} WHERE MaterialCode IN ({placeholders})"
else:
sql = f"DELETE FROM {table_name} WHERE [MaterialCode] IN ({placeholders})"
sql = (
f"DELETE FROM {table_name} WHERE [MaterialCode] IN ({placeholders})"
)
try:
with get_connection() as db:
@@ -444,7 +464,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
True if material exists, False otherwise
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -463,7 +483,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
with get_connection() as db:
result = db.execute_query(sql, (material_code.strip(),))
return result[0]['count'] > 0 if result else False
return result[0]["count"] > 0 if result else False
def count_all(self) -> int:
"""
@@ -472,12 +492,12 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
Total number of records
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
sql = f"SELECT COUNT(*) as count FROM {table_name}"
with get_connection() as db:
result = db.execute_query(sql)
return result[0]['count'] if result else 0
return result[0]["count"] if result else 0
def count_by_manager(self, manager_name: str) -> int:
"""
@@ -489,7 +509,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Returns:
Number of materials for the manager
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
placeholder = self._get_placeholder()
# 根据数据库类型选择列名格式
@@ -508,7 +528,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
with get_connection() as db:
result = db.execute_query(sql, (manager_name,))
return result[0]['count'] if result else 0
return result[0]["count"] if result else 0
def get_statistics(self) -> Dict[str, Any]:
"""
@@ -518,7 +538,7 @@ class MaterialsToBeDeletedDAO(BaseDAO):
Dictionary with statistics including total materials,
unique managers, and materials per manager
"""
table_name = self._convert_sql('[dbo].[MaterialsToBeDeleted]')
table_name = self._convert_sql("[dbo].[MaterialsToBeDeleted]")
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -560,8 +580,8 @@ class MaterialsToBeDeletedDAO(BaseDAO):
# Get materials per manager
manager_results = db.execute_query(manager_sql)
stats['materials_per_manager'] = [
{r['ManagerName']: r['count']} for r in manager_results
stats["materials_per_manager"] = [
{r["ManagerName"]: r["count"]} for r in manager_results
]
return stats

View File

@@ -72,7 +72,9 @@ def get_materials_to_delete_by_managers(
"""
with get_connection() as conn:
results = conn.execute_query(query)
material_codes = [row["MaterialCode"] for row in results if row["MaterialCode"]]
material_codes = [
row["MaterialCode"] for row in results if row["MaterialCode"]
]
return material_codes
else:
# 使用 IN 子句查询多个负责人
@@ -85,7 +87,9 @@ def get_materials_to_delete_by_managers(
"""
with get_connection() as conn:
results = conn.execute_query(query, tuple(manager_names))
material_codes = [row["MaterialCode"] for row in results if row["MaterialCode"]]
material_codes = [
row["MaterialCode"] for row in results if row["MaterialCode"]
]
return material_codes

View File

@@ -40,13 +40,13 @@ class MySQLConnection(BaseDatabaseConnection):
try:
self.connection = mysql.connector.connect(
host=self.config.get('host', 'localhost'),
port=self.config.get('port', 3306),
database=self.config['database'],
user=self.config['username'],
password=self.config['password'],
charset=self.config.get('charset', 'utf8mb4'),
autocommit=False
host=self.config.get("host", "localhost"),
port=self.config.get("port", 3306),
database=self.config["database"],
user=self.config["username"],
password=self.config["password"],
charset=self.config.get("charset", "utf8mb4"),
autocommit=False,
)
print(
f"成功连接到 MySQL 数据库: {self.config.get('host', 'localhost')}"
@@ -153,7 +153,7 @@ class MySQLConnection(BaseDatabaseConnection):
Returns:
str: SQL query with MySQL-compatible placeholders
"""
return sql.replace('?', '%s')
return sql.replace("?", "%s")
def _convert_table_names(self, sql: str) -> str:
"""
@@ -171,10 +171,10 @@ class MySQLConnection(BaseDatabaseConnection):
import re
# Convert [dbo].[TableName] to dbo_TableName
sql = re.sub(r'\[dbo\]\.\[([^\]]+)\]', r'dbo_\1', sql)
sql = re.sub(r"\[dbo\]\.\[([^\]]+)\]", r"dbo_\1", sql)
# Remove square brackets from column names (e.g., [Column] -> Column)
sql = re.sub(r'\[([^\]]+)\]', r'\1', sql)
sql = re.sub(r"\[([^\]]+)\]", r"\1", sql)
return sql

View File

@@ -32,12 +32,14 @@ class ProductionContractDataDAO(BaseDAO):
all_results = []
for i in range(0, len(总排号_list), batch_size):
batch = 总排号_list[i:i + batch_size]
batch = 总排号_list[i : i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
placeholders = ",".join([placeholder for _ in batch])
# 根据数据库类型选择表名
table_name = self._convert_sql('[productionContractData].[26年压力表合同数据]')
table_name = self._convert_sql(
"[productionContractData].[26年压力表合同数据]"
)
# 根据数据库类型选择列名格式
if self._db_type == DatabaseType.MYSQL:
@@ -73,9 +75,9 @@ class ProductionContractDataDAO(BaseDAO):
"""
results = self.query_by_总排号(总排号_list)
# Extract unique 生产订单号 values, excluding None/null values
source_numbers = list(set(
[r['生产订单号'] for r in results if r.get('生产订单号')]
))
source_numbers = list(
set([r["生产订单号"] for r in results if r.get("生产订单号")])
)
return source_numbers
def get_生产订单号_map(self, 总排号_list: List[str]) -> Dict[str, str]:
@@ -90,7 +92,7 @@ class ProductionContractDataDAO(BaseDAO):
"""
results = self.query_by_总排号(总排号_list)
return {
r['总排号']: r['生产订单号']
r["总排号"]: r["生产订单号"]
for r in results
if r.get('总排号') and r.get('生产订单号')
if r.get("总排号") and r.get("生产订单号")
}

View File

@@ -95,7 +95,9 @@ def _query_order_numbers_from_db(production_ids, db_type):
results = conn.execute_query(query, tuple(batch))
# 提取生产订单号并去除空值
batch_numbers = [row["生产订单号"] for row in results if row.get("生产订单号")]
batch_numbers = [
row["生产订单号"] for row in results if row.get("生产订单号")
]
all_results.extend(batch_numbers)
return all_results
@@ -118,7 +120,7 @@ def query_production_order_numbers(inputs):
return []
production_ids = [] # 需要查询数据库的
order_numbers = [] # 直接使用的
order_numbers = [] # 直接使用的
for item in inputs:
input_type = identify_input_type(item)

View File

@@ -38,7 +38,7 @@ class SQLServerConnection(BaseDatabaseConnection):
return self.connection
# 构建连接字符串
driver = self.config.get('driver', 'ODBC Driver 18 for SQL Server')
driver = self.config.get("driver", "ODBC Driver 18 for SQL Server")
conn_str = (
f"DRIVER={{{driver}}};"
f"SERVER={self.config['server']};"

View File

@@ -12,7 +12,7 @@ class TableNameConverter:
"""表名转换工具类"""
# 匹配 SQL Server 表名格式:[schema].[tablename] 或 [schema].[table name]
SQLSERVER_PATTERN = re.compile(r'\[([^\]]+)\]\.\[([^\]]+)\]')
SQLSERVER_PATTERN = re.compile(r"\[([^\]]+)\]\.\[([^\]]+)\]")
@staticmethod
def to_mysql(table_name: str) -> str:
@@ -66,7 +66,7 @@ class TableNameConverter:
'[productionContractData].[26年压力表合同数据]'
"""
# 分割第一个下划线
parts = table_name.split('_', 1)
parts = table_name.split("_", 1)
if len(parts) == 2:
schema = parts[0]
table = parts[1]
@@ -92,21 +92,24 @@ class TableNameConverter:
>>> TableNameConverter.convert_sql(sql, 'mysql')
'SELECT * FROM dbo_BIPUsers WHERE ID = ?'
"""
if db_type == 'mysql':
if db_type == "mysql":
# SQL Server → MySQL
def replace_to_mysql(match):
schema = match.group(1)
table = match.group(2)
return f"{schema}_{table}"
result = TableNameConverter.SQLSERVER_PATTERN.sub(replace_to_mysql, sql)
return result
elif db_type == 'sqlserver':
elif db_type == "sqlserver":
# MySQL → SQL Server
# 首先查找可能的 MySQL 格式表名schema_table 格式)
# 这是一个简化版本,可能无法处理所有边缘情况
result = sql
# 查找单词字符_单词字符 的模式(可能是表名)
mysql_pattern = re.compile(r'\b([a-zA-Z_][a-zA-Z0-9_]*)_([a-zA-Z0-9_\u4e00-\u9fff]+)\b')
mysql_pattern = re.compile(
r"\b([a-zA-Z_][a-zA-Z0-9_]*)_([a-zA-Z0-9_\u4e00-\u9fff]+)\b"
)
matches = mysql_pattern.findall(result)
for schema, table in set(matches):
mysql_name = f"{schema}_{table}"
@@ -133,7 +136,7 @@ class TableNameConverter:
tables.append(f"{schema}_{table}")
# 查找可能的 MySQL 格式
mysql_pattern = re.compile(r'\b[a-zA-Z_][a-zA-Z0-9_]*_[a-zA-Z0-9_]+\b')
mysql_pattern = re.compile(r"\b[a-zA-Z_][a-zA-Z0-9_]*_[a-zA-Z0-9_]+\b")
mysql_matches = mysql_pattern.findall(sql)
tables.extend(mysql_matches)

View File

@@ -5,3 +5,27 @@ ERP 自动化工具 - GUI 模块
"""
__version__ = "1.0.0"
# 导出常用模块
from .base_tab import BaseTab
from .constants import (
WINDOW_SIZE,
MIN_WINDOW_SIZE,
POLL_INTERVAL_MS,
LOG_COLORS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
)
from .utils import admin_only, require_session
__all__ = [
"BaseTab",
"WINDOW_SIZE",
"MIN_WINDOW_SIZE",
"POLL_INTERVAL_MS",
"LOG_COLORS",
"DEFAULT_FONT_FAMILY",
"DEFAULT_FONT_SIZE",
"admin_only",
"require_session",
]

132
gui/base_tab.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
BaseTab - 标签页基类
提供所有标签页共享的通用功能,包括:
- 统一的日志更新方法
- 线程安全的 GUI 操作
- 通用工具方法
"""
import logging
import tkinter as tk
from tkinter import ttk
class BaseTab(ttk.Frame):
"""标签页基类
提供所有标签页共享的通用功能。
子类应继承此类并实现 create_widgets 方法。
"""
def __init__(self, parent, config=None, main_window=None):
"""初始化基类
Args:
parent: 父容器
config: 配置管理器(可选)
main_window: 主窗口引用(可选)
"""
super().__init__(parent)
self.config = config
self.main_window = main_window
def _update_log(self, message: str, level: str = "INFO"):
"""
线程安全的日志更新方法
通过统一的 logging 系统输出日志,自动同时输出到控制台和 GUI。
Args:
message: 日志消息
level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG)
Note:
子类需要设置 self.logger 和 self._gui_handler 才能使用此方法。
如果 self.logger 未设置,将使用 logging.getLogger(__name__) 作为后备。
"""
# 获取 logger优先使用实例的 logger否则使用模块 logger
logger = getattr(self, "logger", None) or logging.getLogger(__name__)
# 将自定义级别映射到 logging 级别
level_upper = level.upper()
if level_upper == "SUCCESS":
# SUCCESS 映射到 INFO但在 UI 中仍显示为 SUCCESS
logger.info(message)
else:
# 其他级别直接映射
log_level = getattr(logging, level_upper, logging.INFO)
logger.log(log_level, message)
def _run_on_main_thread(self, callback, *args, **kwargs):
"""在主线程中执行回调函数(线程安全)
Args:
callback: 要执行的回调函数
*args: 位置参数
**kwargs: 关键字参数
Note:
使用 after(0, ...) 确保在主线程中执行。
"""
self.after(0, lambda: callback(*args, **kwargs))
def _is_admin(self) -> bool:
"""检查当前用户是否为管理员
Returns:
bool: 如果是管理员返回 True否则返回 False
Note:
需要 main_window 或 session_manager 支持。
"""
# 尝试从 session_manager 获取
if hasattr(self, "session_manager") and self.session_manager:
return self.session_manager.is_admin()
# 尝试从 main_window 获取
if self.main_window and hasattr(self.main_window, "session_manager"):
return self.main_window.session_manager.is_admin()
return False
def _get_username(self) -> str:
"""获取当前用户名
Returns:
str: 当前用户名,如果无法获取则返回空字符串
Note:
需要 session_manager 支持。
"""
if hasattr(self, "session_manager") and self.session_manager:
return self.session_manager.get_username() or ""
if self.main_window and hasattr(self.main_window, "session_manager"):
return self.main_window.session_manager.get_username() or ""
return ""
def set_busy(self, busy: bool):
"""设置窗口忙碌状态(显示等待光标)
Args:
busy: True 显示等待光标False 恢复正常光标
"""
try:
cursor = "watch" if busy else "arrow"
# 获取顶层窗口
toplevel = self.winfo_toplevel()
if toplevel:
toplevel.config(cursor=cursor)
toplevel.update()
except tk.TclError:
# 窗口可能已被销毁
pass
def reload_config(self):
"""重新加载配置(子类可覆盖此方法)"""
if self.config and hasattr(self.config, "reload"):
self.config.reload()

View File

@@ -6,6 +6,7 @@
负责加载、保存和管理用户配置。
支持从环境变量和 .env 文件加载配置。
"""
import os
from typing import TYPE_CHECKING
from config.loader import ConfigLoader
@@ -20,7 +21,9 @@ if TYPE_CHECKING:
class ConfigManager:
"""配置管理器"""
def __init__(self, config_file: str = "config/user_settings.json", use_env: bool = True):
def __init__(
self, config_file: str = "config/user_settings.json", use_env: bool = True
):
"""
初始化配置管理器
@@ -99,7 +102,9 @@ class ConfigManager:
field_type = type(getattr(obj, target_field))
# 如果是字符串且目标字段是枚举类型,进行转换
if isinstance(value, str) and hasattr(field_type, "__members__"): # 它是一个 Enum
if isinstance(value, str) and hasattr(
field_type, "__members__"
): # 它是一个 Enum
try:
value = field_type(value)
except ValueError:

112
gui/constants.py Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
GUI 常量模块
集中管理 GUI 相关的常量配置,包括:
- 窗口尺寸
- 进度条轮询间隔
- 日志颜色
- 默认字体配置
"""
# ============================================================================
# 窗口尺寸
# ============================================================================
# 主窗口默认尺寸
WINDOW_SIZE = (1000, 700)
# 主窗口最小尺寸
MIN_WINDOW_SIZE = (800, 600)
# 日志面板默认高度(行数)
LOG_PANEL_HEIGHT = 15
# 结果表格默认高度(行数)
RESULT_TABLE_HEIGHT = 10
# ============================================================================
# 时间间隔(毫秒)
# ============================================================================
# 进度队列轮询间隔
POLL_INTERVAL_MS = 50
# UI 更新延迟
UI_UPDATE_DELAY_MS = 100
# 标题存储延迟
HEADING_STORE_DELAY_MS = 100
# ============================================================================
# 日志颜色
# ============================================================================
# 日志级别对应的颜色
LOG_COLORS = {
"INFO": "#000000", # 黑色
"SUCCESS": "#008000", # 绿色
"WARNING": "#FF8C00", # 橙色
"ERROR": "#FF0000", # 红色
"DEBUG": "#808080", # 灰色
}
# ============================================================================
# 字体配置
# ============================================================================
# 默认字体
DEFAULT_FONT_FAMILY = "Microsoft YaHei UI"
# 默认字号
DEFAULT_FONT_SIZE = 10
# 可用字体列表
AVAILABLE_FONTS = [
"Microsoft YaHei UI",
"SimSun",
"KaiTi",
"FangSong",
"Arial",
"Segoe UI",
]
# ============================================================================
# 文件类型过滤器
# ============================================================================
# Excel 文件过滤器
EXCEL_FILE_TYPES = [("Excel 文件", "*.xlsx"), ("所有文件", "*.*")]
# 文本文件过滤器
TEXT_FILE_TYPES = [("文本文件", "*.txt"), ("所有文件", "*.*")]
# ============================================================================
# 默认值
# ============================================================================
# 默认数据目录
DEFAULT_DATA_DIR = "data/"
# 默认输出文件名
DEFAULT_OUTPUT_FILE = "离散备料计划维护_合并.xlsx"
# 默认校验输出文件名
DEFAULT_VALIDATION_OUTPUT = "物料状态校验结果.xlsx"
# 默认批次大小
DEFAULT_BATCH_SIZE = 100
# 默认数据库批次大小
DEFAULT_DB_BATCH_SIZE = 2000
# ============================================================================
# 复选框字符
# ============================================================================
# 选中状态
CHECKBOX_CHECKED = ""
# 未选中状态
CHECKBOX_UNCHECKED = ""

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据提取标签页 - 稳定性修复版
数据提取标签页
修复了 LogText.info 不支持 add_timestamp 参数导致的 TypeError
从 ERP 系统提取生产订单数据的标签页
继承自 BaseTab使用统一的日志系统。
"""
import os
@@ -14,6 +15,7 @@ import queue
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
from gui.base_tab import BaseTab
from gui.widgets import FileSelector, LogText, ProductionIdInput, GuiTextHandler
from gui.config_manager import ConfigManager
from gui.log_config import setup_gui_logging, get_logger
@@ -21,13 +23,11 @@ from gui.progress import ProgressInfo, ProgressCalculator
from gui.utils import RealtimeOutput
class DataExtractionTab(ttk.Frame):
class DataExtractionTab(BaseTab):
"""数据提取标签页"""
def __init__(self, parent, config: ConfigManager, main_window=None):
super().__init__(parent)
self.config = config
self.main_window = main_window
super().__init__(parent, config, main_window)
self.extracting = False
self.extractor = None
self.extraction_thread = None
@@ -42,10 +42,11 @@ class DataExtractionTab(ttk.Frame):
self.create_widgets()
self._apply_ui_config()
# 初始化日志消息
try:
self.log_text.info("数据提取标签页已就绪")
except:
pass
except Exception as e:
self.logger.debug(f"初始化日志消息失败: {e}")
def create_widgets(self):
horizontal_paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
@@ -69,10 +70,12 @@ class DataExtractionTab(ttk.Frame):
input_group.pack(fill=tk.BOTH, expand=True)
self.production_id_input = ProductionIdInput(
input_group,
placeholder="每行输入一个 Production ID\n\n示例:\n26B848\n26B849"
placeholder="每行输入一个 Production ID\n\n示例:\n26B848\n26B849",
)
self.production_id_input.pack(fill=tk.BOTH, expand=True)
self.production_id_input.text_widget.bind("<FocusOut>", self._on_production_ids_changed)
self.production_id_input.text_widget.bind(
"<FocusOut>", self._on_production_ids_changed
)
def _create_right_panel(self, parent):
main_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL)
@@ -88,7 +91,9 @@ class DataExtractionTab(ttk.Frame):
output_group = ttk.LabelFrame(parent, text="输出文件", padding=10)
output_group.pack(fill=tk.X, pady=5)
self.output_file_selector = FileSelector(
output_group, label_text="保存为:", file_type="file",
output_group,
label_text="保存为:",
file_type="file",
file_types=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")],
initial_dir=self.config.get("paths.data_dir", "data/"),
)
@@ -102,20 +107,28 @@ class DataExtractionTab(ttk.Frame):
options_group = ttk.LabelFrame(parent, text="提取选项", padding=10)
options_group.pack(fill=tk.X, pady=5)
self.headless_var = tk.BooleanVar(value=self.config.get("erp.headless", True))
ttk.Checkbutton(options_group, text="无头模式", variable=self.headless_var).grid(row=0, column=0, sticky="w", padx=5)
ttk.Checkbutton(
options_group, text="无头模式", variable=self.headless_var
).grid(row=0, column=0, sticky="w", padx=5)
progress_group = ttk.LabelFrame(parent, text="进度", padding=10)
progress_group.pack(fill=tk.X, pady=5)
self.progress_bar = ttk.Progressbar(progress_group, mode="determinate")
self.progress_bar.pack(fill=tk.X, pady=5)
self.status_label = ttk.Label(progress_group, text="就绪", relief=tk.SUNKEN, anchor=tk.W)
self.status_label = ttk.Label(
progress_group, text="就绪", relief=tk.SUNKEN, anchor=tk.W
)
self.status_label.pack(fill=tk.X)
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=10)
self.start_button = ttk.Button(button_frame, text="开始提取", command=self.start_extraction)
self.start_button = ttk.Button(
button_frame, text="开始提取", command=self.start_extraction
)
self.start_button.pack(side=tk.LEFT, padx=5)
self.stop_button = ttk.Button(button_frame, text="停止", command=self.stop_extraction, state=tk.DISABLED)
self.stop_button = ttk.Button(
button_frame, text="停止", command=self.stop_extraction, state=tk.DISABLED
)
self.stop_button.pack(side=tk.LEFT, padx=5)
def _create_log_panel(self, parent):
@@ -124,10 +137,11 @@ class DataExtractionTab(ttk.Frame):
# 设置 GUI 日志处理器,将 logging 输出桥接到 LogText 组件
self._gui_handler = GuiTextHandler(self.log_text)
self._gui_handler.setFormatter(logging.Formatter(
'%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
self._gui_handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
)
self.logger.addHandler(self._gui_handler)
def _apply_ui_config(self):
@@ -135,13 +149,16 @@ class DataExtractionTab(ttk.Frame):
font_family = self.config.get("ui.font_family", "Microsoft YaHei UI")
font_size = self.config.get("ui.font_size", 10)
self.production_id_input.apply_font(font_family, font_size)
if hasattr(self.log_text, 'apply_font'):
if hasattr(self.log_text, "apply_font"):
self.log_text.apply_font(font_family, font_size)
except: pass
except Exception as e:
self.logger.debug(f"应用 UI 配置失败: {e}")
def _set_pane_width(self, width: int):
try: self.horizontal_paned.sashpos(0, width)
except: pass
try:
self.horizontal_paned.sashpos(0, width)
except tk.TclError as e:
self.logger.debug(f"设置窗格宽度失败: {e}")
def start_extraction(self):
production_ids = self.production_id_input.get()
@@ -159,7 +176,9 @@ class DataExtractionTab(ttk.Frame):
self.status_label.config(text="正在初始化...")
self.log_text.clear()
self.extraction_thread = threading.Thread(
target=self._extraction_worker, args=(production_ids, output_file), daemon=True
target=self._extraction_worker,
args=(production_ids, output_file),
daemon=True,
)
self.extraction_thread.start()
@@ -170,20 +189,28 @@ class DataExtractionTab(ttk.Frame):
def _extraction_worker(self, production_ids: list[str], output_file: str):
import tempfile
temp_file = None
try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
) as f:
temp_file = f.name
f.write('\n'.join(production_ids))
f.write("\n".join(production_ids))
from utils.discrete_material_plan_extractor import (
DiscreteMaterialPlanExtractor,
)
from utils.discrete_material_plan_extractor import DiscreteMaterialPlanExtractor
self.extractor = DiscreteMaterialPlanExtractor(
username=self.config.get("erp.username"),
password=self.config.get("erp.password"),
headless=self.headless_var.get(),
verbose=self.config.get("extraction.verbose", True),
batch_size=self.config.get("extraction.batch_size", 100),
enable_db_persistence=self.config.get("extraction.enable_db_persistence", False),
enable_db_persistence=self.config.get(
"extraction.enable_db_persistence", False
),
)
# 修复:直接调用标准的 _update_log不再传入 add_timestamp 参数
@@ -192,11 +219,15 @@ class DataExtractionTab(ttk.Frame):
level = progress_info.detail.get("log_level", "INFO").upper()
self._update_log(progress_info.message, level)
else:
percent = self.progress_calculator.calculate_overall_percent(progress_info)
percent = self.progress_calculator.calculate_overall_percent(
progress_info
)
self._update_progress(percent, progress_info.message)
result = self.extractor.extract(
production_id_file=temp_file, output_file=output_file, progress_callback=progress_callback
production_id_file=temp_file,
output_file=output_file,
progress_callback=progress_callback,
)
if result and self.extracting:
@@ -208,8 +239,10 @@ class DataExtractionTab(ttk.Frame):
self._update_log(f"运行时错误: {str(e)}", "ERROR")
finally:
if temp_file and os.path.exists(temp_file):
try: os.unlink(temp_file)
except: pass
try:
os.unlink(temp_file)
except OSError as e:
self.logger.debug(f"清理临时文件失败: {e}")
self.after(0, self._extraction_complete)
def _extraction_complete(self):
@@ -225,37 +258,25 @@ class DataExtractionTab(ttk.Frame):
value, message = self.progress_queue.get_nowait()
self.progress_bar["value"] = value
self.status_label.config(text=message)
except queue.Empty: break
finally: self.after(50, self._poll_progress_queue)
except queue.Empty:
break
except Exception as e:
self.logger.debug(f"轮询进度队列失败: {e}")
finally:
self.after(50, self._poll_progress_queue)
def _update_progress(self, value: int, message: str):
try: self.progress_queue.put_nowait((value, message))
except: pass
def _update_log(self, message: str, level: str = "INFO"):
"""
标准的日志更新方法(兼容接口)
通过统一的 logging 系统输出日志,自动同时输出到控制台和 GUI。
Args:
message: 日志消息
level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG)
"""
# 将自定义级别映射到 logging 级别
level_upper = level.upper()
if level_upper == "SUCCESS":
# SUCCESS 映射到 INFO但在 UI 中仍显示为 SUCCESS
self.logger.info(message)
else:
# 其他级别直接映射
log_level = getattr(logging, level_upper, logging.INFO)
self.logger.log(log_level, message)
try:
self.progress_queue.put_nowait((value, message))
except queue.Full as e:
self.logger.debug(f"进度队列已满: {e}")
def _on_production_ids_changed(self, event=None):
if self.main_window:
self.main_window.update_shared_production_ids(self.production_id_input.get())
self.main_window.update_shared_production_ids(
self.production_id_input.get()
)
def reload_config(self):
self._apply_ui_config()
self._on_production_ids_changed()
self._on_production_ids_changed()

View File

@@ -4,11 +4,12 @@
GUI 日志配置模块
统一配置 GUI 应用和控制台的日志输出
"""
import logging
# 日志格式配置
LOG_FORMAT = '%(asctime)s [%(levelname)s] %(message)s'
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def setup_gui_logging(level=logging.INFO):
@@ -25,7 +26,7 @@ def setup_gui_logging(level=logging.INFO):
level=level,
format=LOG_FORMAT,
datefmt=DATE_FORMAT,
force=True # 确保重新配置(即使之前配置过)
force=True, # 确保重新配置(即使之前配置过)
)
return logging.getLogger()

View File

@@ -1,6 +1,7 @@
"""
Login Dialog - Modal dialog for user authentication
"""
import socket
import tkinter as tk
from tkinter import ttk, messagebox
@@ -62,7 +63,7 @@ class LoginDialog:
self._create_widgets()
# Bind Enter key to login button
self.dialog.bind('<Return>', lambda e: self._on_login())
self.dialog.bind("<Return>", lambda e: self._on_login())
# Focus on username entry
self.username_entry.focus_set()
@@ -74,19 +75,15 @@ class LoginDialog:
main_frame.pack(fill=tk.BOTH, expand=True)
# Title
title_label = ttk.Label(
main_frame,
text="请登录",
font=('', 16, 'bold')
)
title_label = ttk.Label(main_frame, text="请登录", font=("", 16, "bold"))
title_label.pack(pady=(0, 10))
# Computer name display
computer_name_label = ttk.Label(
main_frame,
text=f"当前计算机: {socket.gethostname()}",
font=('', 9),
foreground='gray'
font=("", 9),
foreground="gray",
)
computer_name_label.pack(pady=(0, 15))
@@ -112,28 +109,19 @@ class LoginDialog:
# Login button
login_btn = ttk.Button(
button_frame,
text="登录",
command=self._on_login,
width=10
button_frame, text="登录", command=self._on_login, width=10
)
login_btn.pack(side=tk.LEFT, padx=5)
# Cancel button
cancel_btn = ttk.Button(
button_frame,
text="取消",
command=self._on_cancel,
width=10
button_frame, text="取消", command=self._on_cancel, width=10
)
cancel_btn.pack(side=tk.LEFT, padx=5)
# Version info
version_label = ttk.Label(
main_frame,
text="v1.0",
font=('', 8),
foreground='gray'
main_frame, text="v1.0", font=("", 8), foreground="gray"
)
version_label.pack(side=tk.BOTTOM, pady=10)

View File

@@ -36,7 +36,9 @@ class MainWindow:
# 设置窗口属性(包含用户信息)
user_type_display = "管理员" if session_manager.is_admin() else "用户"
self.root.title(f"ERP 自动化工具 v1.0 - {session_manager.get_username()} ({user_type_display})")
self.root.title(
f"ERP 自动化工具 v1.0 - {session_manager.get_username()} ({user_type_display})"
)
self.root.geometry("1000x700")
# 设置最小窗口大小
@@ -62,8 +64,8 @@ class MainWindow:
"""更新共享的 Production ID 列表"""
self.shared_production_ids = production_ids
# 通知物料校验标签页 Production ID 已更新
if hasattr(self, 'validation_tab'):
if hasattr(self.validation_tab, 'on_production_ids_updated'):
if hasattr(self, "validation_tab"):
if hasattr(self.validation_tab, "on_production_ids_updated"):
self.validation_tab.on_production_ids_updated(production_ids)
def create_menu(self):
@@ -92,11 +94,15 @@ class MainWindow:
self.notebook.add(self.extraction_tab, text="数据提取")
# 物料校验标签页(传入 session_manager 和 main_window
self.validation_tab = MaterialValidationTab(self.notebook, self.config, self.session_manager, self)
self.validation_tab = MaterialValidationTab(
self.notebook, self.config, self.session_manager, self
)
self.notebook.add(self.validation_tab, text="物料校验")
# 设置标签页(传入 session_manager
self.settings_tab = SettingsTab(self.notebook, self.config, self.session_manager)
self.settings_tab = SettingsTab(
self.notebook, self.config, self.session_manager
)
self.notebook.add(self.settings_tab, text="设置")
# 初始化:如果数据提取页面已有 Production ID通知物料校验页面
@@ -105,7 +111,7 @@ class MainWindow:
def _initialize_shared_production_ids(self):
"""初始化共享的 Production ID从数据提取页面获取"""
try:
if hasattr(self.extraction_tab, 'production_id_input'):
if hasattr(self.extraction_tab, "production_id_input"):
production_ids = self.extraction_tab.production_id_input.get()
if production_ids:
self.update_shared_production_ids(production_ids)
@@ -134,7 +140,9 @@ class MainWindow:
user_info_text = f"当前用户: {self.session_manager.get_username()} ({user_type_display}) - 以 {original_admin['username']} 身份登录"
else:
# 正常登录
user_info_text = f"当前用户: {self.session_manager.get_username()} ({user_type_display})"
user_info_text = (
f"当前用户: {self.session_manager.get_username()} ({user_type_display})"
)
self.user_info_var = tk.StringVar()
self.user_info_var.set(user_info_text)
@@ -181,9 +189,9 @@ class MainWindow:
self.config.reload()
# 通知各个标签页重新加载配置
if hasattr(self.extraction_tab, 'reload_config'):
if hasattr(self.extraction_tab, "reload_config"):
self.extraction_tab.reload_config()
if hasattr(self.validation_tab, 'reload_config'):
if hasattr(self.validation_tab, "reload_config"):
self.validation_tab.reload_config()
# 更新状态栏

View File

@@ -62,34 +62,22 @@ class ResultDialog(tk.Toplevel):
text="",
font=("Arial", 48),
fg="#22c55e", # 绿色
bg="#f0fdf4" # 浅绿背景
bg="#f0fdf4", # 浅绿背景
)
icon_label.pack(side=tk.LEFT, padx=(0, 20))
# 背景框
icon_frame = tk.Frame(
content_frame,
bg="#f0fdf4",
width=80,
height=80
)
icon_frame = tk.Frame(content_frame, bg="#f0fdf4", width=80, height=80)
icon_frame.place(x=0, y=0)
icon_frame.pack_propagate(False)
icon_label = tk.Label(
icon_frame,
text="",
font=("Arial", 48),
fg="#22c55e",
bg="#f0fdf4"
icon_frame, text="", font=("Arial", 48), fg="#22c55e", bg="#f0fdf4"
)
icon_label.place(relx=0.5, rely=0.5, anchor="center")
else:
# 失败图标:红色叉叉
icon_frame = tk.Frame(
content_frame,
bg="#fef2f2", # 浅红背景
width=80,
height=80
content_frame, bg="#fef2f2", width=80, height=80 # 浅红背景
)
icon_frame.pack_propagate(False)
icon_frame.pack(side=tk.LEFT, padx=(0, 20))
@@ -99,7 +87,7 @@ class ResultDialog(tk.Toplevel):
text="",
font=("Arial", 48),
fg="#ef4444", # 红色
bg="#fef2f2"
bg="#fef2f2",
)
icon_label.place(relx=0.5, rely=0.5, anchor="center")
@@ -109,7 +97,7 @@ class ResultDialog(tk.Toplevel):
text=message,
font=("Microsoft YaHei UI", 10),
justify=tk.LEFT,
wraplength=280
wraplength=280,
)
msg_label.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
@@ -117,12 +105,9 @@ class ResultDialog(tk.Toplevel):
button_frame = ttk.Frame(main_frame)
button_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Button(
button_frame,
text="确定",
command=self.destroy,
width=10
).pack(side=tk.RIGHT)
ttk.Button(button_frame, text="确定", command=self.destroy, width=10).pack(
side=tk.RIGHT
)
# 等待窗口关闭
self.wait_window()
@@ -260,7 +245,9 @@ class EditableTreeview(ttk.Treeview):
new_value = self.edit_entry.get()
values = self.item(self.editing_item, "values")
old_value = values[self.editing_column] if self.editing_column < len(values) else ""
old_value = (
values[self.editing_column] if self.editing_column < len(values) else ""
)
# 销毁 Entry先销毁防止重复触发
entry = self.edit_entry
@@ -276,7 +263,9 @@ class EditableTreeview(ttk.Treeview):
# 调用回调
try:
if self.on_edit_complete:
self.on_edit_complete(editing_item, editing_column, old_value, new_value)
self.on_edit_complete(
editing_item, editing_column, old_value, new_value
)
except Exception as e:
# 回调出错,清除状态
print(f"Error in on_edit_complete: {e}")
@@ -287,7 +276,9 @@ class EditableTreeview(ttk.Treeview):
return
values = self.item(self.editing_item, "values")
old_value = values[self.editing_column] if self.editing_column < len(values) else ""
old_value = (
values[self.editing_column] if self.editing_column < len(values) else ""
)
# 销毁 Entry先保存状态引用
entry = self.edit_entry
@@ -361,9 +352,11 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 数据缓存
self.original_data: List[Dict[str, Any]] = [] # 原始数据(从数据库加载)
self.current_data: List[Dict[str, Any]] = [] # 当前显示的数据
self.current_data: List[Dict[str, Any]] = [] # 当前显示的数据
self.row_status: Dict[str, str] = {} # key -> 行状态
self.original_values: Dict[str, Tuple[str, str]] = {} # key -> (original_material, original_manager) 用于修改
self.original_values: Dict[str, Tuple[str, str]] = (
{}
) # key -> (original_material, original_manager) 用于修改
# 筛选相关
self.managers: List[str] = []
@@ -387,12 +380,18 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 顶部:筛选区域 - 仅管理员可见
if self.session_manager.is_admin():
filter_frame = ttk.LabelFrame(main_container, text="筛选(按负责人)", padding=10)
filter_frame = ttk.LabelFrame(
main_container, text="筛选(按负责人)", padding=10
)
filter_frame.pack(fill=tk.X, pady=(0, 10))
self._create_filter_area(filter_frame)
# 中部:数据表格
table_frame = ttk.LabelFrame(main_container, text="数据列表双击编辑Delete删除Insert新增", padding=10)
table_frame = ttk.LabelFrame(
main_container,
text="数据列表双击编辑Delete删除Insert新增",
padding=10,
)
table_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self._create_table(table_frame)
@@ -411,12 +410,16 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# Canvas和滚动条
self.filter_canvas = tk.Canvas(canvas_container, height=80)
scrollbar = ttk.Scrollbar(canvas_container, orient="vertical", command=self.filter_canvas.yview)
scrollbar = ttk.Scrollbar(
canvas_container, orient="vertical", command=self.filter_canvas.yview
)
self.filter_frame = ttk.Frame(self.filter_canvas)
self.filter_frame.bind(
"<Configure>",
lambda e: self.filter_canvas.configure(scrollregion=self.filter_frame.bbox("all"))
lambda e: self.filter_canvas.configure(
scrollregion=self.filter_frame.bbox("all")
),
)
self.filter_canvas.create_window((0, 0), window=self.filter_frame, anchor="nw")
@@ -428,15 +431,20 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 鼠标滚轮支持
def _on_mousewheel(event):
self.filter_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
self.filter_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
self.filter_canvas.bind("<MouseWheel>", _on_mousewheel)
# 快捷按钮
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Button(button_frame, text="全选", command=self._select_all_managers).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="取消全选", command=self._deselect_all_managers).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="全选", command=self._select_all_managers).pack(
side=tk.LEFT, padx=5
)
ttk.Button(
button_frame, text="取消全选", command=self._deselect_all_managers
).pack(side=tk.LEFT, padx=5)
def _create_table(self, parent):
"""创建数据表格"""
@@ -447,7 +455,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
show="headings",
selectmode="extended",
on_edit_complete=self._on_edit_complete,
on_edit_cancelled=self._on_edit_cancelled
on_edit_cancelled=self._on_edit_cancelled,
)
# 设置列标题和宽度
@@ -459,11 +467,12 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 添加滚动条
scrollbar_y = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.tree.yview)
scrollbar_x = ttk.Scrollbar(parent, orient=tk.HORIZONTAL, command=self.tree.xview)
scrollbar_x = ttk.Scrollbar(
parent, orient=tk.HORIZONTAL, command=self.tree.xview
)
self.tree.configure(
yscrollcommand=scrollbar_y.set,
xscrollcommand=scrollbar_x.set
yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set
)
# 布局
@@ -480,18 +489,30 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 右键菜单
self.context_menu = tk.Menu(self.tree, tearoff=0)
self.context_menu.add_command(label="新增记录 (Insert)", command=self._add_new_row)
self.context_menu.add_command(label="编辑记录 (F2)", command=self._edit_selected_cell)
self.context_menu.add_command(
label="新增记录 (Insert)", command=self._add_new_row
)
self.context_menu.add_command(
label="编辑记录 (F2)", command=self._edit_selected_cell
)
self.context_menu.add_separator()
self.context_menu.add_command(label="删除记录 (Delete)", command=self._delete_selected_rows)
self.context_menu.add_command(
label="删除记录 (Delete)", command=self._delete_selected_rows
)
self.tree.bind("<Button-3>", self._show_context_menu)
def _create_buttons(self, parent):
"""创建底部按钮"""
ttk.Button(parent, text="新增 (Insert)", command=self._add_new_row).pack(side=tk.LEFT, padx=5)
ttk.Button(parent, text="删除 (Delete)", command=self._delete_selected_rows).pack(side=tk.LEFT, padx=5)
ttk.Button(parent, text="刷新", command=self._load_data).pack(side=tk.LEFT, padx=5)
ttk.Button(parent, text="新增 (Insert)", command=self._add_new_row).pack(
side=tk.LEFT, padx=5
)
ttk.Button(
parent, text="删除 (Delete)", command=self._delete_selected_rows
).pack(side=tk.LEFT, padx=5)
ttk.Button(parent, text="刷新", command=self._load_data).pack(
side=tk.LEFT, padx=5
)
# 待保存提示
self.status_label = ttk.Label(parent, text="")
@@ -501,8 +522,12 @@ class MaterialTypeManagementDialog(tk.Toplevel):
right_frame = ttk.Frame(parent)
right_frame.pack(side=tk.RIGHT)
ttk.Button(right_frame, text="保存", command=self._save_changes).pack(side=tk.LEFT, padx=5)
ttk.Button(right_frame, text="关闭", command=self._close_dialog).pack(side=tk.LEFT, padx=5)
ttk.Button(right_frame, text="保存", command=self._save_changes).pack(
side=tk.LEFT, padx=5
)
ttk.Button(right_frame, text="关闭", command=self._close_dialog).pack(
side=tk.LEFT, padx=5
)
def _show_context_menu(self, event):
"""显示右键菜单"""
@@ -525,6 +550,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 管理员:获取所有负责人
from db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO
dao = MaterialsTypeToBeDeletedDAO()
self.managers = dao.get_managers()
@@ -539,7 +565,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
self.filter_frame,
text="全选",
variable=self.select_all_var,
command=self._on_select_all_toggle
command=self._on_select_all_toggle,
).grid(row=0, column=0, sticky="w", padx=5, pady=2)
# 负责人复选框
@@ -554,7 +580,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
self.filter_frame,
text=manager,
variable=var,
command=self._on_manager_checkbox_change
command=self._on_manager_checkbox_change,
).grid(row=row, column=col, sticky="w", padx=5, pady=2)
def _on_select_all_toggle(self):
@@ -587,8 +613,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
def _get_selected_managers(self) -> List[str]:
"""获取选中的负责人列表"""
return [
manager for manager, var in self.manager_checkboxes.items()
if var.get()
manager for manager, var in self.manager_checkboxes.items() if var.get()
]
def _load_data(self):
@@ -603,7 +628,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# PERMISSION CHECK: 非管理员用户只加载自己的数据
if not self.session_manager.is_admin():
self.original_data = dao.get_materials_by_manager(self.session_manager.get_username())
self.original_data = dao.get_materials_by_manager(
self.session_manager.get_username()
)
else:
self.original_data = dao.get_all_materials()
@@ -653,8 +680,12 @@ class MaterialTypeManagementDialog(tk.Toplevel):
else:
# 获取未删除的数据
base_data = [
r for r in self.original_data
if self.row_status.get(self._get_record_key(r), self.ROW_STATUS_UNCHANGED) != self.ROW_STATUS_DELETED
r
for r in self.original_data
if self.row_status.get(
self._get_record_key(r), self.ROW_STATUS_UNCHANGED
)
!= self.ROW_STATUS_DELETED
]
# 添加新增的记录
@@ -665,18 +696,16 @@ class MaterialTypeManagementDialog(tk.Toplevel):
try:
if self.tree.exists(item_id):
values = self.tree.item(item_id, "values")
new_records.append({
'MaterialName': values[0],
'ManagerName': values[1]
})
new_records.append(
{"MaterialName": values[0], "ManagerName": values[1]}
)
except:
pass
# 合并数据并筛选
all_data = base_data + new_records
self.current_data = [
r for r in all_data
if r.get('ManagerName') in selected_managers
r for r in all_data if r.get("ManagerName") in selected_managers
]
self._refresh_tree()
@@ -714,10 +743,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
item_id = self.tree.insert(
"",
tk.END,
values=(
record.get('MaterialName', ''),
record.get('ManagerName', '')
)
values=(record.get("MaterialName", ""), record.get("ManagerName", "")),
)
# 恢复行状态
@@ -725,9 +751,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
if key in self.row_status:
status = self.row_status[key]
if status == self.ROW_STATUS_NEW:
self.tree.item(item_id, tags=('new',))
self.tree.item(item_id, tags=("new",))
elif status == self.ROW_STATUS_MODIFIED:
self.tree.item(item_id, tags=('modified',))
self.tree.item(item_id, tags=("modified",))
# 恢复正在编辑的新增行
for temp_key, values in editing_data.items():
@@ -735,17 +761,19 @@ class MaterialTypeManagementDialog(tk.Toplevel):
new_item_id = self.tree.insert("", tk.END, values=values)
# 更新 key 映射
if values[0] and values[1]:
new_key = self._get_record_key({'MaterialName': values[0], 'ManagerName': values[1]})
new_key = self._get_record_key(
{"MaterialName": values[0], "ManagerName": values[1]}
)
self.row_status[new_key] = self.ROW_STATUS_NEW
self.tree.item(new_item_id, tags=('new',))
self.tree.item(new_item_id, tags=("new",))
else:
# 空行,保持临时 key
self.row_status[temp_key] = self.ROW_STATUS_NEW
self.row_status[new_key] = self.ROW_STATUS_NEW
# 配置标签样式
self.tree.tag_configure('new', background='#e6f7e6') # 浅绿色
self.tree.tag_configure('modified', background='#fff4e6') # 浅黄色
self.tree.tag_configure("new", background="#e6f7e6") # 浅绿色
self.tree.tag_configure("modified", background="#fff4e6") # 浅黄色
# 恢复选中状态
for material, manager in selected_data:
@@ -765,7 +793,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
self.row_status[temp_key] = self.ROW_STATUS_NEW
# 设置标签
self.tree.item(item_id, tags=('new',))
self.tree.item(item_id, tags=("new",))
# 选中并开始编辑第一个单元格
self.tree.selection_set(item_id)
@@ -787,7 +815,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
if not selection:
return
if not messagebox.askyesno("确认", f"确定要删除选中的 {len(selection)} 条记录吗?"):
if not messagebox.askyesno(
"确认", f"确定要删除选中的 {len(selection)} 条记录吗?"
):
return
for item in selection:
@@ -795,11 +825,16 @@ class MaterialTypeManagementDialog(tk.Toplevel):
material_name = values[0]
manager_name = values[1]
key = self._get_record_key({'MaterialName': material_name, 'ManagerName': manager_name})
key = self._get_record_key(
{"MaterialName": material_name, "ManagerName": manager_name}
)
temp_key = f"temp:{item}"
# 如果是新增的行,直接移除
if temp_key in self.row_status and self.row_status[temp_key] == self.ROW_STATUS_NEW:
if (
temp_key in self.row_status
and self.row_status[temp_key] == self.ROW_STATUS_NEW
):
del self.row_status[temp_key]
else:
# 标记为删除
@@ -809,7 +844,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
self._update_status()
def _on_edit_complete(self, item_id: str, column: int, old_value: str, new_value: str):
def _on_edit_complete(
self, item_id: str, column: int, old_value: str, new_value: str
):
"""编辑完成回调"""
new_value = new_value.strip()
@@ -825,7 +862,10 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 检查是否是新增的行
temp_key = f"temp:{item_id}"
is_new_row = temp_key in self.row_status and self.row_status[temp_key] == self.ROW_STATUS_NEW
is_new_row = (
temp_key in self.row_status
and self.row_status[temp_key] == self.ROW_STATUS_NEW
)
# 对于新增行,如果只输入了部分字段,允许继续
if is_new_row:
@@ -834,13 +874,18 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 检查是否已输入完整数据
if new_material and new_manager:
# 输入完整,更新 key
new_key = self._get_record_key({'MaterialName': new_material, 'ManagerName': new_manager})
new_key = self._get_record_key(
{"MaterialName": new_material, "ManagerName": new_manager}
)
# 检查重复
for other_item in self.tree.get_children():
if other_item == item_id:
continue
other_values = self.tree.item(other_item, "values")
if other_values[0] == new_material and other_values[1] == new_manager:
if (
other_values[0] == new_material
and other_values[1] == new_manager
):
messagebox.showwarning("警告", "该记录已存在")
self.tree.item(item_id, values=("", ""))
self._update_status()
@@ -864,7 +909,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
self.tree.item(item_id, values=new_values)
return
new_key = self._get_record_key({'MaterialName': new_material, 'ManagerName': new_manager})
new_key = self._get_record_key(
{"MaterialName": new_material, "ManagerName": new_manager}
)
# 检查是否重复(除了自己)
for other_item in self.tree.get_children():
@@ -883,7 +930,10 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 更新行状态
# 检查是否是新增的行(查找临时 key
temp_key = f"temp:{item_id}"
is_new_row = temp_key in self.row_status and self.row_status[temp_key] == self.ROW_STATUS_NEW
is_new_row = (
temp_key in self.row_status
and self.row_status[temp_key] == self.ROW_STATUS_NEW
)
if is_new_row:
# 新增行:从临时 key 更新为实际 key
@@ -904,7 +954,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
self.row_status[new_key] = self.ROW_STATUS_MODIFIED
# 设置标签
self.tree.item(item_id, tags=('modified',))
self.tree.item(item_id, tags=("modified",))
self._update_status()
@@ -926,9 +976,11 @@ class MaterialTypeManagementDialog(tk.Toplevel):
for key, status in self.row_status.items():
if status == self.ROW_STATUS_DELETED:
# 解析 key
parts = key.split('|')
parts = key.split("|")
if len(parts) == 2:
to_delete.append({'MaterialName': parts[0], 'ManagerName': parts[1]})
to_delete.append(
{"MaterialName": parts[0], "ManagerName": parts[1]}
)
elif status == self.ROW_STATUS_NEW:
# 从表格中获取数据
@@ -943,7 +995,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 正常 key在表格中查找匹配的行
for item in self.tree.get_children():
values = self.tree.item(item, "values")
item_key = self._get_record_key({'MaterialName': values[0], 'ManagerName': values[1]})
item_key = self._get_record_key(
{"MaterialName": values[0], "ManagerName": values[1]}
)
if item_key == key:
item_to_find = item
break
@@ -951,7 +1005,9 @@ class MaterialTypeManagementDialog(tk.Toplevel):
if item_to_find:
values = self.tree.item(item_to_find, "values")
if values[0] and values[1]: # 只保存非空行
to_insert.append({'MaterialName': values[0], 'ManagerName': values[1]})
to_insert.append(
{"MaterialName": values[0], "ManagerName": values[1]}
)
elif status == self.ROW_STATUS_MODIFIED:
# 从表格中获取新数据,从 original_values 获取旧数据
@@ -961,12 +1017,22 @@ class MaterialTypeManagementDialog(tk.Toplevel):
# 从表格中找到对应的新数据
for item in self.tree.get_children():
values = self.tree.item(item, "values")
item_key = self._get_record_key({'MaterialName': values[0], 'ManagerName': values[1]})
item_key = self._get_record_key(
{"MaterialName": values[0], "ManagerName": values[1]}
)
if item_key == key:
to_update.append({
'old': {'MaterialName': old_material, 'ManagerName': old_manager},
'new': {'MaterialName': values[0], 'ManagerName': values[1]}
})
to_update.append(
{
"old": {
"MaterialName": old_material,
"ManagerName": old_manager,
},
"new": {
"MaterialName": values[0],
"ManagerName": values[1],
},
}
)
break
total_changes = len(to_insert) + len(to_delete) + len(to_update)
@@ -985,8 +1051,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
msg_parts.append(f"更新 {len(to_update)}")
if not messagebox.askyesno(
"确认保存",
"确定要将以下更改保存到数据库吗?\n\n" + "\n".join(msg_parts)
"确认保存", "确定要将以下更改保存到数据库吗?\n\n" + "\n".join(msg_parts)
):
return
@@ -996,60 +1061,62 @@ class MaterialTypeManagementDialog(tk.Toplevel):
dao = MaterialsTypeToBeDeletedDAO()
stats = {
'insert_success': 0,
'insert_failed': 0,
'delete_success': 0,
'delete_failed': 0,
'update_success': 0,
'update_failed': 0
"insert_success": 0,
"insert_failed": 0,
"delete_success": 0,
"delete_failed": 0,
"update_success": 0,
"update_failed": 0,
}
# 执行插入
for record in to_insert:
if dao.insert_material(record['MaterialName'], record['ManagerName']):
stats['insert_success'] += 1
if dao.insert_material(record["MaterialName"], record["ManagerName"]):
stats["insert_success"] += 1
else:
stats['insert_failed'] += 1
stats["insert_failed"] += 1
# 执行删除
for record in to_delete:
if dao.delete_material(record['MaterialName'], record['ManagerName']):
stats['delete_success'] += 1
if dao.delete_material(record["MaterialName"], record["ManagerName"]):
stats["delete_success"] += 1
else:
stats['delete_failed'] += 1
stats["delete_failed"] += 1
# 执行更新
for update in to_update:
old = update['old']
new = update['new']
if dao.delete_material(old['MaterialName'], old['ManagerName']):
if dao.insert_material(new['MaterialName'], new['ManagerName']):
stats['update_success'] += 1
old = update["old"]
new = update["new"]
if dao.delete_material(old["MaterialName"], old["ManagerName"]):
if dao.insert_material(new["MaterialName"], new["ManagerName"]):
stats["update_success"] += 1
else:
dao.insert_material(old['MaterialName'], old['ManagerName'])
stats['update_failed'] += 1
dao.insert_material(old["MaterialName"], old["ManagerName"])
stats["update_failed"] += 1
else:
stats['update_failed'] += 1
stats["update_failed"] += 1
# 显示结果
result_parts = []
if stats['insert_success'] > 0:
if stats["insert_success"] > 0:
result_parts.append(f"新增成功:{stats['insert_success']}")
if stats['insert_failed'] > 0:
if stats["insert_failed"] > 0:
result_parts.append(f"新增失败:{stats['insert_failed']}")
if stats['delete_success'] > 0:
if stats["delete_success"] > 0:
result_parts.append(f"删除成功:{stats['delete_success']}")
if stats['delete_failed'] > 0:
if stats["delete_failed"] > 0:
result_parts.append(f"删除失败:{stats['delete_failed']}")
if stats['update_success'] > 0:
if stats["update_success"] > 0:
result_parts.append(f"更新成功:{stats['update_success']}")
if stats['update_failed'] > 0:
if stats["update_failed"] > 0:
result_parts.append(f"更新失败:{stats['update_failed']}")
result_msg = "\n".join(result_parts)
# 使用自定义对话框显示结果
has_failures = (stats['insert_failed'] + stats['delete_failed'] + stats['update_failed']) > 0
has_failures = (
stats["insert_failed"] + stats["delete_failed"] + stats["update_failed"]
) > 0
if has_failures:
ResultDialog(self, "保存完成(部分失败)", result_msg, success=False)
else:
@@ -1084,8 +1151,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
if total_changes > 0:
self.status_label.config(
text=f"{total_changes} 项待保存的更改",
foreground="red"
text=f"{total_changes} 项待保存的更改", foreground="red"
)
else:
self.status_label.config(text="")
@@ -1113,8 +1179,7 @@ class MaterialTypeManagementDialog(tk.Toplevel):
if total_changes > 0:
if not messagebox.askyesno(
"警告",
f"{total_changes} 项未保存的更改,确定要关闭吗?"
"警告", f"{total_changes} 项未保存的更改,确定要关闭吗?"
):
return

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,7 @@ class ProgressInfo:
用于在后台任务和 GUI 之间传递进度信息。
"""
stage: (
str # 阶段标识: 'login', 'query', 'download', 'logout', 'convert', 'database', 'complete'
)
stage: str # 阶段标识: 'login', 'query', 'download', 'logout', 'convert', 'database', 'complete'
current: int # 当前进度值
total: int # 总量
message: str # 显示给用户的消息

View File

@@ -38,7 +38,9 @@ class SettingsTab(ttk.Frame):
def create_widgets(self):
"""创建界面组件"""
# 判断用户类型
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
is_user_only = (
self.session_manager and self.session_manager.get_user_type() == "User"
)
# 创建主容器,带滚动条
canvas = tk.Canvas(self)
@@ -63,15 +65,16 @@ class SettingsTab(ttk.Frame):
self._create_validation_group(scrollable_frame)
self._create_ui_group(scrollable_frame)
else:
# User 用户:显示路径配置和执行设置
# User 用户:显示 ERP 凭据、路径配置和执行设置
self._create_user_erp_group(scrollable_frame)
self._create_paths_group(scrollable_frame)
self._create_user_execution_group(scrollable_frame)
# 按钮区域 - 根据用户类型显示不同按钮
button_frame = ttk.Frame(scrollable_frame)
if is_user_only:
# User 用户显示测试按钮和保存设置按钮row=4 因为有执行设置组)
button_frame.grid(row=4, column=0, columnspan=2, pady=20, sticky="ew")
# User 用户显示测试按钮和保存设置按钮row=3 因为有 ERP 凭据组、路径设置组、执行设置组)
button_frame.grid(row=3, column=0, columnspan=2, pady=20, sticky="ew")
else:
# 管理员显示所有按钮
button_frame.grid(row=7, column=0, columnspan=2, pady=20, sticky="ew")
@@ -136,6 +139,41 @@ class SettingsTab(ttk.Frame):
group.columnconfigure(1, weight=1)
def _create_user_erp_group(self, parent):
"""创建 User 用户的 ERP 凭据配置组"""
group = ttk.LabelFrame(parent, text="ERP 登录凭据", padding=10)
group.grid(row=0, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# 用户名
ttk.Label(group, text="ERP 用户名:").grid(row=0, column=0, sticky="w", pady=5)
self.user_erp_username_var = tk.StringVar()
ttk.Entry(group, textvariable=self.user_erp_username_var, width=40).grid(
row=0, column=1, pady=5, sticky="ew"
)
# 密码(带显示/隐藏切换)
ttk.Label(group, text="ERP 密码:").grid(row=1, column=0, sticky="w", pady=5)
self.user_erp_password_var = tk.StringVar()
password_entry = ttk.Entry(group, textvariable=self.user_erp_password_var, width=40, show="*")
password_entry.grid(row=1, column=1, pady=5, sticky="ew")
# 显示/隐藏密码按钮
self.password_visible = tk.BooleanVar(value=False)
ttk.Checkbutton(
group, text="显示密码", variable=self.password_visible,
command=lambda: password_entry.configure(show="" if self.password_visible.get() else "*")
).grid(row=2, column=1, sticky="w", pady=2)
# 提示文字
hint_label = ttk.Label(
group,
text="提示:此凭据为共享配置,修改后所有用户将使用新的 ERP 账号。",
foreground="gray",
)
hint_label.grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0))
group.columnconfigure(1, weight=1)
def _create_database_group(self, parent):
"""创建数据库配置组"""
group = ttk.LabelFrame(parent, text="数据库配置", padding=10)
@@ -158,7 +196,9 @@ class SettingsTab(ttk.Frame):
self.sqlserver_frame = ttk.Frame(group)
self.sqlserver_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(self.sqlserver_frame, text="服务器:").grid(row=0, column=0, sticky="w", pady=5)
ttk.Label(self.sqlserver_frame, text="服务器:").grid(
row=0, column=0, sticky="w", pady=5
)
self.db_server_var = tk.StringVar()
ttk.Entry(self.sqlserver_frame, textvariable=self.db_server_var, width=50).grid(
row=0, column=1, pady=5, sticky="ew"
@@ -167,16 +207,24 @@ class SettingsTab(ttk.Frame):
# MySQL 配置
self.mysql_frame = ttk.Frame(group)
ttk.Label(self.mysql_frame, text="主机:").grid(row=0, column=0, sticky="w", pady=5)
ttk.Label(self.mysql_frame, text="主机:").grid(
row=0, column=0, sticky="w", pady=5
)
self.mysql_host_var = tk.StringVar()
ttk.Entry(self.mysql_frame, textvariable=self.mysql_host_var, width=50).grid(
row=0, column=1, pady=5, sticky="ew"
)
ttk.Label(self.mysql_frame, text="端口:").grid(row=1, column=0, sticky="w", pady=5)
ttk.Label(self.mysql_frame, text="端口:").grid(
row=1, column=0, sticky="w", pady=5
)
self.mysql_port_var = tk.IntVar(value=3306)
ttk.Spinbox(
self.mysql_frame, from_=1, to=65535, textvariable=self.mysql_port_var, width=10
self.mysql_frame,
from_=1,
to=65535,
textvariable=self.mysql_port_var,
width=10,
).grid(row=1, column=1, sticky="w", pady=5)
# 通用配置(两种数据库都需要)
@@ -207,7 +255,9 @@ class SettingsTab(ttk.Frame):
self.mysql_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
else:
self.mysql_frame.grid_forget()
self.sqlserver_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
self.sqlserver_frame.grid(
row=1, column=0, columnspan=2, sticky="ew", pady=5
)
def _create_browser_group(self, parent):
"""创建浏览器配置组"""
@@ -232,11 +282,14 @@ class SettingsTab(ttk.Frame):
def _create_paths_group(self, parent):
"""创建路径配置组"""
# 根据用户类型调整 grid 位置
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
is_user_only = (
self.session_manager and self.session_manager.get_user_type() == "User"
)
group = ttk.LabelFrame(parent, text="路径设置", padding=10)
if is_user_only:
group.grid(row=2, column=1, pady=10, padx=10, sticky="nsew")
# User 用户ERP 凭据在 row=0路径设置在 row=1
group.grid(row=1, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
else:
group.grid(row=2, column=1, pady=10, padx=10, sticky="nsew")
@@ -261,32 +314,45 @@ class SettingsTab(ttk.Frame):
# 校验输出文件
ttk.Label(group, text="校验输出文件:").grid(row=3, column=0, sticky="w", pady=5)
ttk.Entry(group, textvariable=self.validation_output_filename_var, width=40).grid(
row=3, column=1, columnspan=2, sticky="ew", pady=5
)
ttk.Entry(
group, textvariable=self.validation_output_filename_var, width=40
).grid(row=3, column=1, columnspan=2, sticky="ew", pady=5)
group.columnconfigure(0, weight=1)
def _create_user_execution_group(self, parent):
"""创建 User 用户的执行设置组"""
group = ttk.LabelFrame(parent, text="执行设置", padding=10)
group.grid(row=3, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# User 用户ERP 凭据在 row=0路径设置在 row=1执行设置在 row=2
group.grid(row=2, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# dryrun 模式设置
self.user_dryrun_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
group,
text="预览模式 (执行删除时不保存更改)",
variable=self.user_dryrun_var
group, text="预览模式 (执行删除时不保存更改)", variable=self.user_dryrun_var
).grid(row=0, column=0, sticky="w", pady=5)
# 说明文字
hint_label = ttk.Label(
dryrun_hint = ttk.Label(
group,
text="提示:勾选后,执行删除操作时将只预览不实际保存,用于测试流程。",
foreground="gray"
foreground="gray",
)
hint_label.grid(row=1, column=0, sticky="w", pady=(0, 5))
dryrun_hint.grid(row=1, column=0, sticky="w", pady=(0, 5))
# 无头模式设置
self.user_headless_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
group, text="无头模式 (不显示浏览器)", variable=self.user_headless_var
).grid(row=2, column=0, sticky="w", pady=5)
# 说明文字
headless_hint = ttk.Label(
group,
text="提示:勾选后浏览器将在后台运行,不显示窗口。",
foreground="gray",
)
headless_hint.grid(row=3, column=0, sticky="w", pady=(0, 5))
def _create_extraction_group(self, parent):
"""创建处理配置组"""
@@ -335,7 +401,12 @@ class SettingsTab(ttk.Frame):
data_source_combo = ttk.Combobox(
group,
textvariable=self.validation_data_source_var,
values=["database_full", "database_filtered", "excel_existing", "excel_full"],
values=[
"database_full",
"database_filtered",
"excel_existing",
"excel_full",
],
state="readonly",
width=30,
)
@@ -344,20 +415,28 @@ class SettingsTab(ttk.Frame):
# 使用数据库
self.validation_use_database_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="使用数据库作为数据源", variable=self.validation_use_database_var
group,
text="使用数据库作为数据源",
variable=self.validation_use_database_var,
).grid(row=1, column=0, columnspan=2, sticky="w", pady=5)
# 输出文件名
ttk.Label(group, text="输出文件名:").grid(row=2, column=0, sticky="w", pady=5)
ttk.Entry(group, textvariable=self.validation_output_filename_var, width=30).grid(
row=2, column=1, sticky="w", pady=5
)
ttk.Entry(
group, textvariable=self.validation_output_filename_var, width=30
).grid(row=2, column=1, sticky="w", pady=5)
# 批次大小
ttk.Label(group, text="数据库批次大小:").grid(row=3, column=0, sticky="w", pady=5)
ttk.Label(group, text="数据库批次大小:").grid(
row=3, column=0, sticky="w", pady=5
)
self.validation_batch_size_var = tk.IntVar(value=2000)
ttk.Spinbox(
group, from_=100, to=2000, textvariable=self.validation_batch_size_var, width=10
group,
from_=100,
to=2000,
textvariable=self.validation_batch_size_var,
width=10,
).grid(row=3, column=1, sticky="w", pady=5)
# 匹配模式
@@ -375,15 +454,17 @@ class SettingsTab(ttk.Frame):
# CRUD 操作
self.validation_enable_crud_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="启用 CRUD 操作(管理待删除物料)", variable=self.validation_enable_crud_var
group,
text="启用 CRUD 操作(管理待删除物料)",
variable=self.validation_enable_crud_var,
).grid(row=5, column=0, columnspan=2, sticky="w", pady=5)
# 默认负责人
ttk.Label(group, text="默认负责人:").grid(row=6, column=0, sticky="w", pady=5)
self.validation_default_manager_var = tk.StringVar()
ttk.Entry(group, textvariable=self.validation_default_manager_var, width=30).grid(
row=6, column=1, sticky="w", pady=5
)
ttk.Entry(
group, textvariable=self.validation_default_manager_var, width=30
).grid(row=6, column=1, sticky="w", pady=5)
group.columnconfigure(1, weight=1)
@@ -398,7 +479,14 @@ class SettingsTab(ttk.Frame):
font_combo = ttk.Combobox(
group,
textvariable=self.ui_font_family_var,
values=["Microsoft YaHei UI", "SimSun", "KaiTi", "FangSong", "Arial", "Segoe UI"],
values=[
"Microsoft YaHei UI",
"SimSun",
"KaiTi",
"FangSong",
"Arial",
"Segoe UI",
],
state="readonly",
width=30,
)
@@ -412,7 +500,9 @@ class SettingsTab(ttk.Frame):
).grid(row=1, column=1, sticky="w", pady=5)
# Production ID 输入框宽度
ttk.Label(group, text="输入框宽度(字符):").grid(row=2, column=0, sticky="w", pady=5)
ttk.Label(group, text="输入框宽度(字符):").grid(
row=2, column=0, sticky="w", pady=5
)
self.ui_input_width_var = tk.IntVar(value=20)
ttk.Spinbox(
group, from_=10, to=100, textvariable=self.ui_input_width_var, width=10
@@ -423,16 +513,25 @@ class SettingsTab(ttk.Frame):
def load_settings(self):
"""从配置加载设置到界面"""
# 判断是否为仅测试用户模式
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
is_user_only = (
self.session_manager and self.session_manager.get_user_type() == "User"
)
if is_user_only:
# User 用户模式 - 只需要加载路径设置到界面
# User 用户模式 - 只需要加载 ERP 凭据、路径设置到界面
# ERP 凭据
self.user_erp_username_var.set(self.config.get("erp.username", ""))
self.user_erp_password_var.set(self.config.get("erp.password", ""))
# 路径设置
self.data_dir_selector.set(self.config.get("paths.data_dir", ""))
self.default_output_var.set(self.config.get("paths.default_output", ""))
self.validation_output_filename_var.set(self.config.get("paths.validation_output", ""))
self.validation_output_filename_var.set(
self.config.get("paths.validation_output", "")
)
# 执行设置
self.user_dryrun_var.set(self.config.get("execution.dryrun", False))
# 无头模式
self.user_headless_var.set(self.config.get("erp.headless", True))
return
# 管理员模式 - 加载所有配置
@@ -475,34 +574,62 @@ class SettingsTab(ttk.Frame):
self.verbose_var.set(self.config.get("extraction.verbose", True))
self.auto_convert_var.set(self.config.get("extraction.auto_convert", True))
self.merge_batches_var.set(self.config.get("extraction.merge_batches", True))
self.enable_db_persistence_var.set(self.config.get("extraction.enable_db_persistence", False))
self.enable_db_persistence_var.set(
self.config.get("extraction.enable_db_persistence", False)
)
# 校验设置
self.validation_data_source_var.set(self.config.get("validation.data_source", "database_full"))
self.validation_use_database_var.set(self.config.get("validation.use_database", True))
self.validation_output_filename_var.set(self.config.get("paths.validation_output", "物料状态校验结果.xlsx"))
self.validation_batch_size_var.set(self.config.get("validation.batch_size", 2000))
self.validation_match_mode_var.set(self.config.get("validation.match_mode", "substring"))
self.validation_enable_crud_var.set(self.config.get("validation.enable_crud_operations", False))
self.validation_default_manager_var.set(self.config.get("validation.default_manager", ""))
self.validation_data_source_var.set(
self.config.get("validation.data_source", "database_full")
)
self.validation_use_database_var.set(
self.config.get("validation.use_database", True)
)
self.validation_output_filename_var.set(
self.config.get("paths.validation_output", "物料状态校验结果.xlsx")
)
self.validation_batch_size_var.set(
self.config.get("validation.batch_size", 2000)
)
self.validation_match_mode_var.set(
self.config.get("validation.match_mode", "substring")
)
self.validation_enable_crud_var.set(
self.config.get("validation.enable_crud_operations", False)
)
self.validation_default_manager_var.set(
self.config.get("validation.default_manager", "")
)
# UI 设置
self.ui_font_family_var.set(self.config.get("ui.font_family", "Microsoft YaHei UI"))
self.ui_font_family_var.set(
self.config.get("ui.font_family", "Microsoft YaHei UI")
)
self.ui_font_size_var.set(self.config.get("ui.font_size", 10))
self.ui_input_width_var.set(self.config.get("ui.production_id_input_width", 20))
def save_settings(self):
"""保存界面设置到配置"""
# 判断是否为仅测试用户模式
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
is_user_only = (
self.session_manager and self.session_manager.get_user_type() == "User"
)
if is_user_only:
# User 用户模式 - 只保存路径设置和执行设置
# User 用户模式 - 只保存 ERP 凭据、路径设置和执行设置
# ERP 凭据
self.config.set("erp.username", self.user_erp_username_var.get())
self.config.set("erp.password", self.user_erp_password_var.get())
# 路径设置
self.config.set("paths.data_dir", self.data_dir_selector.get())
self.config.set("paths.default_output", self.default_output_var.get())
self.config.set("paths.validation_output", self.validation_output_filename_var.get())
self.config.set(
"paths.validation_output", self.validation_output_filename_var.get()
)
# 保存执行设置
self.config.set("execution.dryrun", self.user_dryrun_var.get())
# 无头模式
self.config.set("erp.headless", self.user_headless_var.get())
# 保存到文件
if self.config.save():
@@ -550,16 +677,26 @@ class SettingsTab(ttk.Frame):
self.config.set("extraction.verbose", self.verbose_var.get())
self.config.set("extraction.auto_convert", self.auto_convert_var.get())
self.config.set("extraction.merge_batches", self.merge_batches_var.get())
self.config.set("extraction.enable_db_persistence", self.enable_db_persistence_var.get())
self.config.set(
"extraction.enable_db_persistence", self.enable_db_persistence_var.get()
)
# 校验设置
self.config.set("validation.data_source", self.validation_data_source_var.get())
self.config.set("validation.use_database", self.validation_use_database_var.get())
self.config.set("paths.validation_output", self.validation_output_filename_var.get())
self.config.set(
"validation.use_database", self.validation_use_database_var.get()
)
self.config.set(
"paths.validation_output", self.validation_output_filename_var.get()
)
self.config.set("validation.batch_size", self.validation_batch_size_var.get())
self.config.set("validation.match_mode", self.validation_match_mode_var.get())
self.config.set("validation.enable_crud_operations", self.validation_enable_crud_var.get())
self.config.set("validation.default_manager", self.validation_default_manager_var.get())
self.config.set(
"validation.enable_crud_operations", self.validation_enable_crud_var.get()
)
self.config.set(
"validation.default_manager", self.validation_default_manager_var.get()
)
# UI 设置
self.config.set("ui.font_family", self.ui_font_family_var.get())
@@ -581,7 +718,7 @@ class SettingsTab(ttk.Frame):
# 获取主窗口
main_window = self.winfo_toplevel()
# 调用主窗口的 reload_config 方法(如果存在)
if hasattr(main_window, 'reload_config'):
if hasattr(main_window, "reload_config"):
main_window.reload_config()
except Exception:
pass
@@ -602,7 +739,7 @@ class SettingsTab(ttk.Frame):
database=self.config.get("database.database", ""),
user=self.config.get("database.username", ""),
password=self.config.get("database.password", ""),
connection_timeout=5
connection_timeout=5,
)
conn.close()
messagebox.showinfo("成功", "MySQL 数据库连接测试成功!")
@@ -621,9 +758,14 @@ class SettingsTab(ttk.Frame):
except ImportError:
if db_type == "mysql":
messagebox.showerror("错误", "未安装 mysql-connector-python请运行\npip install mysql-connector-python")
messagebox.showerror(
"错误",
"未安装 mysql-connector-python请运行\npip install mysql-connector-python",
)
else:
messagebox.showerror("错误", "未安装 pyodbc请运行\npip install pyodbc")
messagebox.showerror(
"错误", "未安装 pyodbc请运行\npip install pyodbc"
)
except Exception as e:
messagebox.showerror("错误", f"数据库连接失败:\n{str(e)}")
@@ -634,8 +776,11 @@ class SettingsTab(ttk.Frame):
def reset_defaults(self):
"""恢复默认设置"""
if messagebox.askyesno("确认", "确定要恢复默认设置吗?这将覆盖 .env 文件中的所有配置。"):
if messagebox.askyesno(
"确认", "确定要恢复默认设置吗?这将覆盖 .env 文件中的所有配置。"
):
from config.schema import AppConfig
self.config.config = AppConfig.from_env() # 重新加载默认配置
self.config.save()
self.load_settings()

View File

@@ -1,6 +1,7 @@
"""
User Selection Dialog - Allows Admin to choose which user identity to use
"""
import tkinter as tk
from tkinter import ttk, messagebox
from typing import Optional, Dict, Any, List
@@ -64,9 +65,7 @@ class UserSelectionDialog:
# Title
title_label = ttk.Label(
main_frame,
text="请选择要使用的用户身份",
font=('', 14, 'bold')
main_frame, text="请选择要使用的用户身份", font=("", 14, "bold")
)
title_label.pack(pady=(0, 20))
@@ -82,12 +81,15 @@ class UserSelectionDialog:
# Sort users: current user first, then by username
sorted_users = sorted(
self.users,
key=lambda u: (0 if u['username'] == self.current_username else 1, u['username'])
key=lambda u: (
0 if u["username"] == self.current_username else 1,
u["username"],
),
)
for user in sorted_users:
username = user['username']
user_type = user['user_type']
username = user["username"]
user_type = user["user_type"]
is_current = username == self.current_username
# Mark current user
@@ -99,7 +101,7 @@ class UserSelectionDialog:
list_frame,
text=display_text,
variable=self.selected_var,
value=username
value=username,
)
rb.pack(anchor=tk.W, pady=3, padx=5)
@@ -111,18 +113,12 @@ class UserSelectionDialog:
button_frame.pack(pady=(20, 0))
confirm_btn = ttk.Button(
button_frame,
text="确认",
command=self._on_confirm,
width=10
button_frame, text="确认", command=self._on_confirm, width=10
)
confirm_btn.pack(side=tk.LEFT, padx=5)
cancel_btn = ttk.Button(
button_frame,
text="取消",
command=self._on_cancel,
width=10
button_frame, text="取消", command=self._on_cancel, width=10
)
cancel_btn.pack(side=tk.LEFT, padx=5)
@@ -136,7 +132,7 @@ class UserSelectionDialog:
# Find the selected user
for user in self.users:
if user['username'] == selected_username:
if user["username"] == selected_username:
self.selected_user = user
break

View File

@@ -6,6 +6,8 @@ GUI 工具模块
提供 GUI 相关的工具类和函数。
"""
import functools
class RealtimeOutput:
"""实时输出流,每次写入立即回调通知"""
@@ -37,3 +39,93 @@ class RealtimeOutput:
def isatty(self):
"""返回 False表示不是终端"""
return False
def admin_only(func):
"""
管理员权限装饰器
用于标记需要管理员权限的方法。如果当前用户不是管理员,
方法将不执行任何操作并返回 None。
Usage:
@admin_only
def some_admin_function(self):
# 只有管理员才能执行的代码
pass
Note:
- 被装饰的方法必须属于一个有 session_manager 属性的对象
- session_manager 必须有 is_admin() 方法
Returns:
如果是管理员,返回原函数的结果;否则返回 None
"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
# 尝试从 self 获取 session_manager
session_manager = getattr(self, "session_manager", None)
# 如果没有 session_manager尝试从 main_window 获取
if session_manager is None:
main_window = getattr(self, "main_window", None)
if main_window:
session_manager = getattr(main_window, "session_manager", None)
# 检查是否为管理员
if session_manager and hasattr(session_manager, "is_admin"):
if session_manager.is_admin():
return func(self, *args, **kwargs)
# 非管理员,记录日志并返回 None
logger = getattr(self, "logger", None)
if logger:
logger.debug(f"权限拒绝: {func.__name__} 需要管理员权限")
return None
return wrapper
def require_session(func):
"""
会话验证装饰器
确保方法执行时有有效的会话。如果会话无效,
方法将不执行任何操作并返回 None。
Usage:
@require_session
def some_function(self):
# 需要有效会话才能执行的代码
pass
Returns:
如果有有效会话,返回原函数的结果;否则返回 None
"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
# 尝试从 self 获取 session_manager
session_manager = getattr(self, "session_manager", None)
# 如果没有 session_manager尝试从 main_window 获取
if session_manager is None:
main_window = getattr(self, "main_window", None)
if main_window:
session_manager = getattr(main_window, "session_manager", None)
# 检查会话是否有效
if session_manager and hasattr(session_manager, "is_authenticated"):
if session_manager.is_authenticated():
return func(self, *args, **kwargs)
# 会话无效,记录日志并返回 None
logger = getattr(self, "logger", None)
if logger:
logger.warning(f"会话无效: {func.__name__} 需要有效会话")
return None
return wrapper

View File

@@ -9,5 +9,13 @@ from .log_text import LogText
from .production_id_input import ProductionIdInput
from .log_handler import GuiTextHandler
from .delete_progress_window import DeleteProgressWindow
from .checkbox_treeview import CheckboxTreeview
__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler', 'DeleteProgressWindow']
__all__ = [
"FileSelector",
"LogText",
"ProductionIdInput",
"GuiTextHandler",
"DeleteProgressWindow",
"CheckboxTreeview",
]

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
CheckboxTreeview 组件
支持 checkbox 的 Treeview 组件,使用 Unicode 字符模拟 checkbox
- ☐ 未选中
- ☑ 选中
"""
import tkinter as tk
from tkinter import ttk
class CheckboxTreeview(ttk.Treeview):
"""支持 checkbox 的 Treeview 组件
使用 Unicode 字符模拟 checkbox
- ☐ 未选中
- ☑ 选中
Features:
- Checkbox 点击切换
- 排序功能(支持按选择状态和材料名称排序)
- 全选/取消全选
"""
def __init__(self, parent, on_checkbox_change=None, **kwargs):
"""初始化 CheckboxTreeview
Args:
parent: 父容器
on_checkbox_change: checkbox 状态改变时的回调函数
**kwargs: 传递给 Treeview 的参数
"""
super().__init__(parent, **kwargs)
self.checkboxes = {} # item_id -> bool
self.checkbox_column = "选择"
self.on_checkbox_change = on_checkbox_change # checkbox 状态改变回调
# 排序状态
self.sort_column = None # 当前排序列的列标识符
self.sort_direction = None # 'asc', 'desc', 或 None
self.sortable_columns = ["选择", "材料名称"] # 可排序的列白名单
self.original_headings = {} # 存储原始列标题文本(不含箭头)
# 存储原始列标题(延迟执行以确保标题已设置)
self.after(100, self._store_original_headings)
# 绑定点击事件
self.bind("<Button-1>", self._on_click)
# 绑定表头点击事件
self.bind("<ButtonRelease-1>", self._on_heading_click)
def _on_click(self, event):
"""处理点击事件,切换 checkbox 状态"""
# 获取点击位置对应的 item 和 column
region = self.identify_region(event.x, event.y)
# 仅处理单元格点击,不处理表头点击
if region == "cell":
column = self.identify_column(event.x)
item = self.identify_row(event.y)
# 检查是否点击了 checkbox 列(第一列)
if column == "#1" and item:
# 切换 checkbox 状态
current_state = self.checkboxes.get(item, False)
new_state = not current_state
self.set_checked(item, new_state)
# 通知父组件 checkbox 状态已改变
if self.on_checkbox_change:
self.on_checkbox_change(item, new_state)
return "break" # 阻止默认行为
def set_checked(self, item, checked: bool):
"""设置指定 item 的 checkbox 状态
Args:
item: Treeview item ID
checked: 是否选中
"""
self.checkboxes[item] = checked
# 更新显示
checkbox_char = "" if checked else ""
values = list(self.item(item, "values"))
if values:
values[0] = checkbox_char
self.item(item, values=values)
def get_checked_items(self) -> list:
"""获取所有选中的 item
Returns:
List of item IDs
"""
return [item for item, checked in self.checkboxes.items() if checked]
def check_all(self, checked: bool = True):
"""全选或取消全选
Args:
checked: True 为全选False 为取消全选
"""
for item in self.get_children():
self.set_checked(item, checked)
def insert(self, parent, index, values=None, **kwargs):
"""重写 insert 方法,初始化 checkbox 状态"""
if values is None:
values = []
# 确保第一个值是 checkbox
if not values or values[0] not in ["", ""]:
values = [""] + list(values)
item = super().insert(parent, index, values=values, **kwargs)
# 初始化 checkbox 状态为未选中
checkbox_char = values[0] if values else ""
self.checkboxes[item] = checkbox_char == ""
return item
def delete(self, *items):
"""重写 delete 方法,清理 checkbox 状态"""
for item in items:
if item in self.checkboxes:
del self.checkboxes[item]
super().delete(*items)
def _store_original_headings(self):
"""存储原始列标题文本(不含箭头)"""
for col in self["columns"]:
self.original_headings[col] = self.heading(col, "text")
def _get_column_id_from_column_index(self, column_index):
"""将列索引 ('#1', '#2') 转换为列标识符
Args:
column_index: 列索引字符串,如 '#1', '#2'
Returns:
列标识符,如 '选择', '材料名称'
"""
index = int(column_index[1:]) - 1
columns = self["columns"]
if 0 <= index < len(columns):
return columns[index]
return None
def _on_heading_click(self, event):
"""处理表头点击事件,触发排序"""
region = self.identify_region(event.x, event.y)
if region == "heading":
column = self.identify_column(event.x)
column_id = self._get_column_id_from_column_index(column)
# 仅对可排序列进行排序
if column_id in self.sortable_columns:
self._toggle_sort(column_id)
def _toggle_sort(self, column_id):
"""切换指定列的排序状态
Args:
column_id: 列标识符(如 '选择', '材料名称'
"""
# 确定新的排序方向
if self.sort_column == column_id:
# 同一列asc -> desc -> None
if self.sort_direction == "asc":
new_direction = "desc"
elif self.sort_direction == "desc":
new_direction = None
else:
new_direction = "asc"
else:
# 不同列:从升序开始
new_direction = "asc"
# 应用排序
if new_direction:
self._sort_by_column(column_id, new_direction)
self.sort_column = column_id
self.sort_direction = new_direction
else:
# 清除排序状态
self.sort_column = None
self.sort_direction = None
# 更新表头显示
self._update_heading_display()
def _sort_by_column(self, column_id, direction):
"""按指定列和方向排序
Args:
column_id: 列标识符
direction: 'asc''desc'
"""
# 收集所有项目及其数据和复选框状态
items_data = []
for item in self.get_children():
values = self.item(item, "values")
checkbox_state = self.checkboxes.get(item, False)
items_data.append(
{"item_id": item, "values": values, "checked": checkbox_state}
)
# 根据列和方向排序
if column_id == "选择":
# 按复选框状态排序(选中在前,未选中在后)
items_data.sort(key=lambda x: x["checked"], reverse=(direction == "desc"))
elif column_id == "材料名称":
# 按材料名称排序
items_data.sort(
key=lambda x: str(x["values"][1]) if len(x["values"]) > 1 else "",
reverse=(direction == "desc"),
)
# 重新排列项目顺序(使用 detach 和 move 保留项目ID和状态
for item_data in items_data:
self.move(item_data["item_id"], "", "end")
def _update_heading_display(self):
"""更新列标题显示(添加/移除排序箭头)"""
for col in self["columns"]:
original = self.original_headings.get(col, col)
if col == self.sort_column:
# 添加排序箭头
arrow = "" if self.sort_direction == "asc" else ""
self.heading(col, text=original + arrow)
else:
# 移除箭头,显示原始标题
self.heading(col, text=original)

View File

@@ -14,12 +14,14 @@ from datetime import datetime
# 尝试导入 tkinterweb 和 markdown2
try:
from tkinterweb import HtmlFrame
HAS_TKINTERWEB = True
except ImportError:
HAS_TKINTERWEB = False
try:
import markdown2
HAS_MARKDOWN2 = True
except ImportError:
HAS_MARKDOWN2 = False
@@ -34,7 +36,7 @@ class DeleteProgressWindow:
title: str = "执行删除",
managers: str = "",
dryrun: bool = False,
on_cancel: Optional[Callable] = None
on_cancel: Optional[Callable] = None,
):
"""
初始化删除进度窗口
@@ -100,14 +102,13 @@ class DeleteProgressWindow:
self.progress_frame.pack(fill=tk.X, pady=(0, 10))
self.progress_var = tk.StringVar(value="准备中...")
self.progress_label = ttk.Label(self.progress_frame, textvariable=self.progress_var)
self.progress_label = ttk.Label(
self.progress_frame, textvariable=self.progress_var
)
self.progress_label.pack(anchor="w")
self.progress_bar = ttk.Progressbar(
self.progress_frame,
mode='determinate',
length=660,
maximum=100
self.progress_frame, mode="determinate", length=660, maximum=100
)
self.progress_bar.pack(fill=tk.X, pady=5)
@@ -120,15 +121,15 @@ class DeleteProgressWindow:
height=10,
wrap=tk.WORD,
state=tk.DISABLED,
font=('Consolas', 9)
font=("Consolas", 9),
)
self.log_text.pack(fill=tk.BOTH, expand=True)
# 配置日志标签颜色
self.log_text.tag_configure('info', foreground='black')
self.log_text.tag_configure('success', foreground='green')
self.log_text.tag_configure('warning', foreground='orange')
self.log_text.tag_configure('error', foreground='red')
self.log_text.tag_configure("info", foreground="black")
self.log_text.tag_configure("success", foreground="green")
self.log_text.tag_configure("warning", foreground="orange")
self.log_text.tag_configure("error", foreground="red")
# 报告区域(完成后显示)- 初始隐藏
self.report_frame = ttk.LabelFrame(self.main_frame, text="执行报告", padding=5)
@@ -145,7 +146,7 @@ class DeleteProgressWindow:
height=20,
wrap=tk.WORD,
state=tk.DISABLED,
font=('Consolas', 9)
font=("Consolas", 9),
)
self.report_text.pack(fill=tk.BOTH, expand=True)
@@ -154,18 +155,12 @@ class DeleteProgressWindow:
button_frame.pack(fill=tk.X)
self.cancel_button = ttk.Button(
button_frame,
text="取消执行",
command=self._on_cancel
button_frame, text="取消执行", command=self._on_cancel
)
self.cancel_button.pack(side=tk.RIGHT)
# 关闭按钮(初始隐藏)
self.close_button = ttk.Button(
button_frame,
text="关闭",
command=self.close
)
self.close_button = ttk.Button(button_frame, text="关闭", command=self.close)
def _on_cancel(self):
"""处理取消操作"""
@@ -187,7 +182,7 @@ class DeleteProgressWindow:
"""
if total > 0:
percentage = int((current / total) * 100)
self.progress_bar['value'] = percentage
self.progress_bar["value"] = percentage
self.progress_var.set(message)
else:
self.progress_var.set(message)
@@ -263,8 +258,7 @@ class DeleteProgressWindow:
"""
# 使用 markdown2 转换
html_body = markdown2.markdown(
markdown_content,
extras=['tables', 'fenced-code-blocks']
markdown_content, extras=["tables", "fenced-code-blocks"]
)
# 添加样式
@@ -298,11 +292,14 @@ class DeleteProgressWindow:
border-collapse: collapse;
width: 100%;
margin: 10px 0;
table-layout: fixed;
}}
th, td {{
border: 1px solid #bdc3c7;
padding: 8px;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}}
th {{
background-color: #3498db;
@@ -340,59 +337,65 @@ class DeleteProgressWindow:
Returns:
HTML 内容
"""
lines = markdown_content.split('\n')
html_parts = ['<!DOCTYPE html><html><head><meta charset="UTF-8">',
'<style>',
'body { font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 12px; padding: 10px; }',
'h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }',
'h2 { color: #34495e; border-bottom: 1px solid #bdc3c7; margin-top: 20px; }',
'table { border-collapse: collapse; width: 100%; margin: 10px 0; }',
'th, td { border: 1px solid #bdc3c7; padding: 8px; text-align: left; }',
'th { background-color: #3498db; color: white; }',
'</style></head><body>']
lines = markdown_content.split("\n")
html_parts = [
'<!DOCTYPE html><html><head><meta charset="UTF-8">',
"<style>",
'body { font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 12px; padding: 10px; }',
"h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }",
"h2 { color: #34495e; border-bottom: 1px solid #bdc3c7; margin-top: 20px; }",
"table { border-collapse: collapse; width: 100%; margin: 10px 0; table-layout: fixed; }",
"th, td { border: 1px solid #bdc3c7; padding: 8px; text-align: left; word-wrap: break-word; overflow-wrap: break-word; }",
"th { background-color: #3498db; color: white; }",
"</style></head><body>",
]
in_table = False
for line in lines:
if line.startswith('# '):
html_parts.append(f'<h1>{line[2:]}</h1>')
elif line.startswith('## '):
html_parts.append(f'<h2>{line[3:]}</h2>')
elif line.startswith('| '):
if line.startswith("# "):
html_parts.append(f"<h1>{line[2:]}</h1>")
elif line.startswith("## "):
html_parts.append(f"<h2>{line[3:]}</h2>")
elif line.startswith("| "):
if not in_table:
html_parts.append('<table>')
html_parts.append("<table>")
in_table = True
# 检查是否是表头分隔行
if '|--' in line or '|-' in line:
if "|--" in line or "|-" in line:
continue
cells = [cell.strip() for cell in line.split('|')[1:-1]]
cells = [cell.strip() for cell in line.split("|")[1:-1]]
if cells:
# 第一行作为表头
if html_parts[-1] == '<table>':
html_parts.append('<tr>' + ''.join(f'<th>{c}</th>' for c in cells) + '</tr>')
if html_parts[-1] == "<table>":
html_parts.append(
"<tr>" + "".join(f"<th>{c}</th>" for c in cells) + "</tr>"
)
else:
html_parts.append('<tr>' + ''.join(f'<td>{c}</td>' for c in cells) + '</tr>')
elif line.startswith('- '):
html_parts.append(
"<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
)
elif line.startswith("- "):
if in_table:
html_parts.append('</table>')
html_parts.append("</table>")
in_table = False
html_parts.append(f'<li>{line[2:]}</li>')
elif line.strip() == '':
html_parts.append(f"<li>{line[2:]}</li>")
elif line.strip() == "":
if in_table:
html_parts.append('</table>')
html_parts.append("</table>")
in_table = False
html_parts.append('<br>')
html_parts.append("<br>")
else:
if in_table:
html_parts.append('</table>')
html_parts.append("</table>")
in_table = False
if line.strip():
html_parts.append(f'<p>{line}</p>')
html_parts.append(f"<p>{line}</p>")
if in_table:
html_parts.append('</table>')
html_parts.append("</table>")
html_parts.append('</body></html>')
return '\n'.join(html_parts)
html_parts.append("</body></html>")
return "\n".join(html_parts)
def _markdown_to_text(self, markdown_content: str) -> str:
"""
@@ -404,34 +407,34 @@ class DeleteProgressWindow:
Returns:
格式化后的文本
"""
lines = markdown_content.split('\n')
lines = markdown_content.split("\n")
result = []
for line in lines:
# 标题
if line.startswith('# '):
result.append('=' * 60)
if line.startswith("# "):
result.append("=" * 60)
result.append(line[2:])
result.append('=' * 60)
elif line.startswith('## '):
result.append('')
result.append("=" * 60)
elif line.startswith("## "):
result.append("")
result.append(line[3:])
result.append('-' * 40)
elif line.startswith('| '):
result.append("-" * 40)
elif line.startswith("| "):
# 表格行 - 保持原样
result.append(line)
elif line.startswith('|--') or line.startswith('|-'):
elif line.startswith("|--") or line.startswith("|-"):
# 表格分隔线 - 跳过
continue
elif line.startswith('- '):
elif line.startswith("- "):
# 列表项
result.append(' ' + line)
elif line.strip() == '':
result.append('')
result.append(" " + line)
elif line.strip() == "":
result.append("")
else:
result.append(line)
return '\n'.join(result)
return "\n".join(result)
def close(self):
"""关闭窗口"""
@@ -444,4 +447,4 @@ class DeleteProgressWindow:
def set_completed(self):
"""设置为完成状态"""
self.cancel_button.pack_forget()
self.close_button.pack(side=tk.RIGHT)
self.close_button.pack(side=tk.RIGHT)

View File

@@ -21,7 +21,7 @@ class FileSelector(ttk.Frame):
file_type: str = "file",
file_types: list = None,
initial_dir: str = "",
on_change: Optional[Callable] = None
on_change: Optional[Callable] = None,
):
"""
初始化文件选择器
@@ -64,15 +64,10 @@ class FileSelector(ttk.Frame):
if self.file_type == "file":
path = filedialog.askopenfilename(
title="选择文件",
initialdir=current_path,
filetypes=self.file_types
title="选择文件", initialdir=current_path, filetypes=self.file_types
)
else: # directory
path = filedialog.askdirectory(
title="选择目录",
initialdir=current_path
)
path = filedialog.askdirectory(title="选择目录", initialdir=current_path)
if path:
self.entry_var.set(path)

View File

@@ -3,6 +3,7 @@
"""
自定义 logging Handler将日志输出到 LogText 组件
"""
import logging
import re
from typing import Optional
@@ -29,11 +30,11 @@ class GuiTextHandler(logging.Handler):
# 映射 logging 级别到 LogText 级别
self.level_map = {
logging.INFO: 'INFO',
logging.WARNING: 'WARNING',
logging.ERROR: 'ERROR',
logging.DEBUG: 'DEBUG',
logging.CRITICAL: 'ERROR'
logging.INFO: "INFO",
logging.WARNING: "WARNING",
logging.ERROR: "ERROR",
logging.DEBUG: "DEBUG",
logging.CRITICAL: "ERROR",
}
def set_log_text(self, log_text: LogText):
@@ -57,7 +58,7 @@ class GuiTextHandler(logging.Handler):
try:
# 获取日志级别
level = self.level_map.get(record.levelno, 'INFO')
level = self.level_map.get(record.levelno, "INFO")
# 只获取消息内容不包含时间戳和级别LogText.log() 会添加)
message = record.getMessage()
@@ -78,6 +79,7 @@ class GuiTextHandler(logging.Handler):
# 尝试使用 after 确保在主线程更新
import tkinter as tk
try:
# 尝试获取主窗口
widget = self.log_text
@@ -112,9 +114,9 @@ class GuiTextHandler(logging.Handler):
清理后的消息
"""
# 常见的日志级别标记模式
level_pattern = r'^\[(?:INFO|WARNING|ERROR|DEBUG|CRITICAL|WARN|SUCCESS)\]\s*'
level_pattern = r"^\[(?:INFO|WARNING|ERROR|DEBUG|CRITICAL|WARN|SUCCESS)\]\s*"
match = re.match(level_pattern, message)
if match:
# 移除匹配到的级别前缀
return message[match.end():]
return message[match.end() :]
return message

View File

@@ -15,11 +15,11 @@ class LogText(tk.Frame):
# 日志级别颜色配置
LOG_COLORS = {
'INFO': '#000000', # 黑色
'SUCCESS': '#008000', # 绿色
'WARNING': '#FF8C00', # 深橙色
'ERROR': '#FF0000', # 红色
'DEBUG': '#808080', # 灰色
"INFO": "#000000", # 黑色
"SUCCESS": "#008000", # 绿色
"WARNING": "#FF8C00", # 深橙色
"ERROR": "#FF0000", # 红色
"DEBUG": "#808080", # 灰色
}
def __init__(self, parent, readonly=True, **kwargs):
@@ -67,19 +67,19 @@ class LogText(tk.Frame):
def _make_readonly(self):
"""通过绑定事件使文本框只读"""
# 允许复制、全选等常用操作,阻止其他编辑操作
self.text.bind('<Key>', self._handle_key)
self.text.bind('<Button-1>', self._allow_click) # 允许左键点击选择
self.text.bind("<Key>", self._handle_key)
self.text.bind("<Button-1>", self._allow_click) # 允许左键点击选择
def _handle_key(self, event):
"""处理按键事件,允许复制操作,阻止编辑"""
# 允许的快捷键
allowed_keys = [
'Control-c', # 复制
'Control-C', # 复制(大写)
'Control-a', # 全选
'Control-A', # 全选(大写)
'Control-x', # 剪切(虽然剪不了,但不报错)
'Control-X',
"Control-c", # 复制
"Control-C", # 复制(大写)
"Control-a", # 全选
"Control-A", # 全选(大写)
"Control-x", # 剪切(虽然剪不了,但不报错)
"Control-X",
]
# 检查是否是允许的快捷键
@@ -93,14 +93,14 @@ class LogText(tk.Frame):
return # 允许执行
# 其他所有按键都阻止
return 'break'
return "break"
def _allow_click(self, event):
"""允许点击和选择文本"""
# 不打断事件,允许正常的选择操作
return
def log(self, message: str, level: str = 'INFO') -> None:
def log(self, message: str, level: str = "INFO") -> None:
"""
添加日志消息
@@ -111,46 +111,46 @@ class LogText(tk.Frame):
# 确保 tags 已配置
self._ensure_tags_configured()
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_message = f"[{timestamp}] [{level}] {message}\n"
# 插入文本
tag = level.lower() if self._tags_configured else None
if tag:
try:
self.text.insert('end', log_message, (tag,))
self.text.insert("end", log_message, (tag,))
except Exception:
# 如果带标签插入失败,尝试不带标签
self.text.insert('end', log_message)
self.text.insert("end", log_message)
else:
self.text.insert('end', log_message)
self.text.insert("end", log_message)
# 自动滚动到底部
self.text.see('end')
self.text.see("end")
def info(self, message: str) -> None:
"""添加 INFO 级别日志"""
self.log(message, 'INFO')
self.log(message, "INFO")
def success(self, message: str) -> None:
"""添加 SUCCESS 级别日志"""
self.log(message, 'SUCCESS')
self.log(message, "SUCCESS")
def warning(self, message: str) -> None:
"""添加 WARNING 级别日志"""
self.log(message, 'WARNING')
self.log(message, "WARNING")
def error(self, message: str) -> None:
"""添加 ERROR 级别日志"""
self.log(message, 'ERROR')
self.log(message, "ERROR")
def debug(self, message: str) -> None:
"""添加 DEBUG 级别日志"""
self.log(message, 'DEBUG')
self.log(message, "DEBUG")
def clear(self) -> None:
"""清空日志"""
self.text.delete('1.0', 'end')
self.text.delete("1.0", "end")
def save_to_file(self, file_path: str) -> bool:
"""
@@ -163,8 +163,8 @@ class LogText(tk.Frame):
是否成功
"""
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(self.text.get('1.0', 'end-1c'))
with open(file_path, "w", encoding="utf-8") as f:
f.write(self.text.get("1.0", "end-1c"))
return True
except Exception as e:
self.error(f"保存日志失败: {e}")
@@ -182,5 +182,6 @@ class LogText(tk.Frame):
def apply_font(self, font_family: str, font_size: int):
"""应用字体设置"""
from tkinter import font as tk_font
font_spec = tk_font.Font(family=font_family, size=font_size)
self.text.configure(font=font_spec)

View File

@@ -35,12 +35,14 @@ class ProductionIdInput(ttk.Frame):
justify="center",
colors=("black", "#f0f0f0"),
bg="#f0f0f0",
width=3
width=3,
)
self.line_numbers.pack(fill="both", expand=True)
# 创建滚动条
self.scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.text_widget.yview)
self.scrollbar = ttk.Scrollbar(
self, orient=tk.VERTICAL, command=self.text_widget.yview
)
self.text_widget.configure(yscrollcommand=self._on_scroll)
# 布局:行号 | 文本框 | 滚动条
@@ -74,7 +76,10 @@ class ProductionIdInput(ttk.Frame):
def _on_focus_in(self, event):
"""获得焦点时隐藏占位符"""
if not self._updating_placeholder and self.text_widget.get("1.0", "end-1c") == self.placeholder:
if (
not self._updating_placeholder
and self.text_widget.get("1.0", "end-1c") == self.placeholder
):
self.text_widget.delete("1.0", tk.END)
# 确保文字颜色为黑色
self.text_widget.configure(foreground="black")

View File

@@ -20,7 +20,7 @@ class ProgressDialog:
title: str = "处理中...",
message: str = "请稍候",
can_cancel: bool = True,
on_cancel: Optional[Callable] = None
on_cancel: Optional[Callable] = None,
):
"""
初始化进度对话框
@@ -66,11 +66,7 @@ class ProgressDialog:
self.message_label.pack(pady=(20, 10), padx=20)
# 进度条
self.progress = ttk.Progressbar(
self.dialog,
mode='indeterminate',
length=360
)
self.progress = ttk.Progressbar(self.dialog, mode="indeterminate", length=360)
self.progress.pack(pady=10, padx=20)
self.progress.start(10)
@@ -80,9 +76,7 @@ class ProgressDialog:
button_frame.pack(pady=10)
self.cancel_button = ttk.Button(
button_frame,
text="取消",
command=self._on_cancel
button_frame, text="取消", command=self._on_cancel
)
self.cancel_button.pack()
@@ -106,8 +100,8 @@ class ProgressDialog:
value: 当前进度值
maximum: 最大值
"""
self.progress.config(mode='determinate', maximum=maximum)
self.progress['value'] = value
self.progress.config(mode="determinate", maximum=maximum)
self.progress["value"] = value
self.dialog.update_idletasks()
def close(self):

View File

@@ -5,6 +5,7 @@
将现有的 JSON 配置文件迁移到 .env 环境变量文件
"""
import os
import sys
import json
@@ -21,7 +22,7 @@ from config.schema import AppConfig
def migrate_json_to_env(
json_file: str = "config/user_settings.json",
env_file: str = ".env",
backup: bool = True
backup: bool = True,
) -> bool:
"""
迁移 JSON 配置到 .env 文件
@@ -46,7 +47,7 @@ def migrate_json_to_env(
# 检查 .env 文件是否已存在
if env_path.exists():
response = input(f"⚠️ .env 文件已存在: {env_path}\n是否覆盖? (y/N): ")
if response.lower() != 'y':
if response.lower() != "y":
print("❌ 迁移已取消")
return False
@@ -63,6 +64,7 @@ def migrate_json_to_env(
# 使用 ConfigLoader 将字典转换为配置对象
from config.loader import ConfigLoader
config = ConfigLoader._dict_to_config(json_data)
# 保存到 .env 文件
@@ -95,13 +97,13 @@ def migrate_json_to_env(
except Exception as e:
print(f"❌ 迁移失败: {e}")
import traceback
traceback.print_exc()
return False
def create_env_from_example(
example_file: str = ".env.example",
env_file: str = ".env"
example_file: str = ".env.example", env_file: str = ".env"
) -> bool:
"""
从 .env.example 创建 .env 文件
@@ -122,7 +124,7 @@ def create_env_from_example(
if env_path.exists():
response = input(f"⚠️ .env 文件已存在: {env_path}\n是否覆盖? (y/N): ")
if response.lower() != 'y':
if response.lower() != "y":
print("❌ 操作已取消")
return False
@@ -160,7 +162,9 @@ def main():
elif command == "migrate":
# 从 JSON 迁移
print("\n📋 模式: 从 JSON 配置迁移")
json_file = sys.argv[2] if len(sys.argv) > 2 else "config/user_settings.json"
json_file = (
sys.argv[2] if len(sys.argv) > 2 else "config/user_settings.json"
)
env_file = sys.argv[3] if len(sys.argv) > 3 else ".env"
migrate_json_to_env(json_file, env_file)
return
@@ -192,7 +196,9 @@ def main():
choice = input("\n请输入选项 (1-3): ").strip()
if choice == "1":
json_file = input("JSON 配置文件路径 (默认: config/user_settings.json): ").strip()
json_file = input(
"JSON 配置文件路径 (默认: config/user_settings.json): "
).strip()
if not json_file:
json_file = "config/user_settings.json"
@@ -201,7 +207,7 @@ def main():
env_file = ".env"
backup_choice = input("是否备份原 JSON 文件? (Y/n): ").strip().lower()
backup = backup_choice != 'n'
backup = backup_choice != "n"
migrate_json_to_env(json_file, env_file, backup)

View File

@@ -43,6 +43,7 @@ class DiscreteMaterialPlanCleaner:
headless=False,
verbose=True,
dryrun=False,
save_report: bool = True,
progress_callback: Optional[Callable[[int, int, str], None]] = None,
):
self.username = username
@@ -50,6 +51,7 @@ class DiscreteMaterialPlanCleaner:
self.headless = headless
self.verbose = verbose
self.dryrun = dryrun
self.save_report_enabled = save_report
self.progress_callback = progress_callback
# 参数规范化:支持 str、List[str]、None
@@ -65,16 +67,16 @@ class DiscreteMaterialPlanCleaner:
# 统计信息
self.stats = {
'total_orders': 0,
'processed_orders': 0,
'total_materials': 0, # 总物料数
'processed_materials': 0, # 已处理物料数
'deleted_materials': [], # [{order_id, material_code, material_name}]
'skipped_materials': [], # [{order_id, material_code, material_name, reason}]
'unmatched_materials': [], # [{order_id, material_code, material_name}] 不在删除列表的物料
'errors': [], # [{order_id, error_message}]
'start_time': None,
'end_time': None,
"total_orders": 0,
"processed_orders": 0,
"total_materials": 0, # 总物料数
"processed_materials": 0, # 已处理物料数
"deleted_materials": [], # [{order_id, material_code, material_name}]
"skipped_materials": [], # [{order_id, material_code, material_name, reason}]
"unmatched_materials": [], # [{order_id, material_code, material_name}] 不在删除列表的物料
"errors": [], # [{order_id, error_message}]
"start_time": None,
"end_time": None,
}
def _log(self, message, level="info"):
@@ -100,7 +102,7 @@ class DiscreteMaterialPlanCleaner:
total_materials: int,
order_id: str,
material_name: str,
action: str
action: str,
):
"""报告物料处理进度
@@ -166,7 +168,9 @@ class DiscreteMaterialPlanCleaner:
)
return order_ids
def process_order(self, inner_frame, order_id, order_index, page1, total_orders: int = 1):
def process_order(
self, inner_frame, order_id, order_index, page1, total_orders: int = 1
):
"""清理单个订单的数据
Args:
@@ -225,7 +229,7 @@ class DiscreteMaterialPlanCleaner:
if detail_status == "审批通过":
if detail_count > 0:
# 更新总物料数统计
self.stats['total_materials'] += detail_count
self.stats["total_materials"] += detail_count
# --- 点击修改并等待状态切换 (保留原逻辑) ---
detail_inner_frame.get_by_role("button", name="修改").click()
@@ -252,7 +256,7 @@ class DiscreteMaterialPlanCleaner:
# page2.pause() # 调试用,正式运行时可删除
while True:
material_idx += 1
self.stats['processed_materials'] += 1
self.stats["processed_materials"] += 1
# 稳定性检查:等待行号更新
current_row = self._get_input_value(child_form, r"^行号$")
@@ -262,25 +266,37 @@ class DiscreteMaterialPlanCleaner:
material_code = self._get_input_value(child_form, r"^材料编码")
material_name = self._get_input_value(child_form, r"^材料名称")
pending_qty = self._get_input_value(child_form, r"^累计待发数量$")
pending_qty = self._get_input_value(
child_form, r"^累计待发数量$"
)
# 报告物料进度
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "检查"
order_index,
total_orders,
material_idx,
detail_count,
order_id,
material_name,
"检查",
)
if material_code in self.to_delete_set:
self._log(f"发现匹配物料: {material_name} ({material_code})")
self._log(
f"发现匹配物料: {material_name} ({material_code})"
)
if (not pending_qty) and not (
row_num_int >= 7000 and row_num_int < 8000
):
# 报告删除进度
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "删除"
order_index,
total_orders,
material_idx,
detail_count,
order_id,
material_name,
"删除",
)
# 记录删除前的行号
old_row_number = current_row
@@ -308,75 +324,98 @@ class DiscreteMaterialPlanCleaner:
time.sleep(0.2)
else:
self._log(
f"⚠️ 等待删除完成超时({max_wait_time}秒)", "warn"
f"⚠️ 等待删除完成超时({max_wait_time}秒)",
"warn",
)
# 记录删除统计
if delete_success:
self.stats['deleted_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name
})
self.stats["deleted_materials"].append(
{
"order_id": order_id,
"material_code": material_code,
"material_name": material_name,
}
)
continue
elif row_num_int >= 7000 and row_num_int < 8000:
reason = f"行号 {row_num_int} 在 7000-8000 范围内"
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "跳过"
order_index,
total_orders,
material_idx,
detail_count,
order_id,
material_name,
"跳过",
)
self._log(f"⚠️ {reason},跳过删除", "warn")
self.stats['skipped_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name,
'reason': reason
})
self.stats["skipped_materials"].append(
{
"order_id": order_id,
"material_code": material_code,
"material_name": material_name,
"reason": reason,
}
)
elif pending_qty:
reason = f"待发数量为 {pending_qty}"
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "跳过"
order_index,
total_orders,
material_idx,
detail_count,
order_id,
material_name,
"跳过",
)
self._log(f"⚠️ {reason},跳过删除", "warn")
self.stats['skipped_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name,
'reason': reason
})
self.stats["skipped_materials"].append(
{
"order_id": order_id,
"material_code": material_code,
"material_name": material_name,
"reason": reason,
}
)
else:
reason = "不满足删除条件"
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "跳过"
order_index,
total_orders,
material_idx,
detail_count,
order_id,
material_name,
"跳过",
)
self._log(
f"⚠️ {reason},跳过物料 {material_name} ({material_code})",
"warn",
)
self.stats['skipped_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name,
'reason': reason
})
self.stats["skipped_materials"].append(
{
"order_id": order_id,
"material_code": material_code,
"material_name": material_name,
"reason": reason,
}
)
else:
self._log(
f" 物料 {material_name} ({material_code}) 不在删除列表中,不做处理"
)
# 记录到 unmatched_materials
self.stats['unmatched_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name
})
self.stats["unmatched_materials"].append(
{
"order_id": order_id,
"material_code": material_code,
"material_name": material_name,
}
)
if self._is_button_enabled(next_btn):
last_row_number = current_row
next_btn.click()
@@ -417,7 +456,7 @@ class DiscreteMaterialPlanCleaner:
def clean(self, production_id_file):
"""执行完整清理流程"""
# 初始化统计
self.stats['start_time'] = datetime.now()
self.stats["start_time"] = datetime.now()
# 0. 预加载数据库数据
self.preload_data()
@@ -450,20 +489,21 @@ class DiscreteMaterialPlanCleaner:
order_ids = self.get_production_order_numbers(production_id_file)
# 设置总订单数
self.stats['total_orders'] = len(order_ids)
self.stats["total_orders"] = len(order_ids)
# 遍历处理
for index, order_id in enumerate(order_ids):
self._log(f"进度: [{index+1}/{len(order_ids)}] 处理单号: {order_id}")
try:
self.process_order(inner_frame, order_id, index, page1, len(order_ids))
self.stats['processed_orders'] += 1
self.process_order(
inner_frame, order_id, index, page1, len(order_ids)
)
self.stats["processed_orders"] += 1
except Exception as e:
self._log(f"处理单号 {order_id} 时发生异常: {e}", "error")
self.stats['errors'].append({
'order_id': order_id,
'error_message': str(e)
})
self.stats["errors"].append(
{"order_id": order_id, "error_message": str(e)}
)
continue # 单个失败不影响整体执行
# 登出清理
@@ -473,7 +513,15 @@ class DiscreteMaterialPlanCleaner:
self._log("=" * 30 + " 任务全部完成 " + "=" * 30)
# 记录结束时间
self.stats['end_time'] = datetime.now()
self.stats["end_time"] = datetime.now()
# 自动保存报告(如果启用)
if self.save_report_enabled:
try:
report_path = self.save_report()
self._log(f"报告已保存至: {report_path}")
except Exception as e:
self._log(f"保存报告失败: {e}", "error")
def generate_report(self) -> str:
"""生成 Markdown 格式的执行报告
@@ -490,67 +538,120 @@ class DiscreteMaterialPlanCleaner:
# 概述
report_lines.append("## 概述")
report_lines.append("")
start_time = self.stats.get('start_time')
end_time = self.stats.get('end_time')
start_time = self.stats.get("start_time")
end_time = self.stats.get("end_time")
duration = None
if start_time and end_time:
duration = end_time - start_time
report_lines.append(f"- 开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
report_lines.append(
f"- 开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}"
)
report_lines.append(f"- 结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
report_lines.append(f"- 执行时长: {duration}")
report_lines.append(f"- 处理订单: {self.stats['processed_orders']}/{self.stats['total_orders']}")
report_lines.append(f"- 处理物料: {self.stats['processed_materials']}/{self.stats['total_materials']} ")
report_lines.append(
f"- 处理订单: {self.stats['processed_orders']}/{self.stats['total_orders']} "
)
report_lines.append(
f"- 处理物料: {self.stats['processed_materials']}/{self.stats['total_materials']}"
)
report_lines.append(f"- 删除物料: {len(self.stats['deleted_materials'])}")
report_lines.append(f"- 跳过物料: {len(self.stats['skipped_materials'])}")
report_lines.append(f"- 未处理物料: {len(self.stats['unmatched_materials'])}")
report_lines.append(
f"- 未处理物料: {len(self.stats['unmatched_materials'])}"
)
report_lines.append(f"- 错误数量: {len(self.stats['errors'])}")
report_lines.append(f"- 执行模式: {'预览模式 (dryrun)' if self.dryrun else '正常执行'}")
report_lines.append(
f"- 执行模式: {'预览模式 (dryrun)' if self.dryrun else '正常执行'}"
)
if self.manager_names:
report_lines.append(f"- 负责人: {', '.join(self.manager_names)}")
report_lines.append("")
# 删除明细
if self.stats['deleted_materials']:
if self.stats["deleted_materials"]:
report_lines.append("## 删除明细")
report_lines.append("")
report_lines.append("| 订单号 | 物料编码 | 物料名称 |")
report_lines.append("|--------|----------|----------|")
for item in self.stats['deleted_materials']:
report_lines.append(f"| {item['order_id']} | {item['material_code']} | {item['material_name']} |")
for item in self.stats["deleted_materials"]:
report_lines.append(
f"| {item['order_id']} | {item['material_code']} | {item['material_name']} |"
)
report_lines.append("")
# 跳过明细
if self.stats['skipped_materials']:
if self.stats["skipped_materials"]:
report_lines.append("## 跳过明细")
report_lines.append("")
report_lines.append("| 订单号 | 物料编码 | 物料名称 | 跳过原因 |")
report_lines.append("|--------|----------|----------|----------|")
for item in self.stats['skipped_materials']:
report_lines.append(f"| {item['order_id']} | {item['material_code']} | {item['material_name']} | {item['reason']} |")
for item in self.stats["skipped_materials"]:
report_lines.append(
f"| {item['order_id']} | {item['material_code']} | {item['material_name']} | {item['reason']} |"
)
report_lines.append("")
# 未处理物料明细
if self.stats['unmatched_materials']:
if self.stats["unmatched_materials"]:
report_lines.append("## 未处理物料")
report_lines.append("")
report_lines.append("| 订单号 | 物料编码 | 物料名称 |")
report_lines.append("|--------|----------|----------|")
for item in self.stats['unmatched_materials']:
report_lines.append(f"| {item['order_id']} | {item['material_code']} | {item['material_name']} |")
for item in self.stats["unmatched_materials"]:
report_lines.append(
f"| {item['order_id']} | {item['material_code']} | {item['material_name']} |"
)
report_lines.append("")
# 错误明细
if self.stats['errors']:
if self.stats["errors"]:
report_lines.append("## 错误明细")
report_lines.append("")
report_lines.append("| 订单号 | 错误信息 |")
report_lines.append("|--------|----------|")
for item in self.stats['errors']:
report_lines.append(f"| {item['order_id']} | {item['error_message']} |")
report_lines.append("")
for item in self.stats["errors"]:
report_lines.append(f"### 订单号: `{item['order_id']}`")
report_lines.append("")
report_lines.append("```")
report_lines.append(item['error_message'])
report_lines.append("```")
report_lines.append("")
return "\n".join(report_lines)
def save_report(self, output_dir: str = "data/reports") -> str:
"""保存报告到文件
Args:
output_dir: 报告保存目录,默认为 data/reports
Returns:
保存的文件路径
"""
# 确保目录存在
os.makedirs(output_dir, exist_ok=True)
# 生成时间戳文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"report_{timestamp}.md"
filepath = os.path.join(output_dir, filename)
# 生成报告内容
report_content = self.generate_report()
# 构建元数据YAML Front Matter
metadata = "---\n"
metadata += f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
metadata += f"负责人: {', '.join(self.manager_names) if self.manager_names else '全部'}\n"
metadata += f"执行模式: {'预览模式' if self.dryrun else '正常执行'}\n"
metadata += f"处理订单数: {self.stats['processed_orders']}\n"
metadata += f"删除物料数: {len(self.stats['deleted_materials'])}\n"
metadata += "---\n\n"
# 写入文件
with open(filepath, "w", encoding="utf-8") as f:
f.write(metadata + report_content)
return filepath
def main():
# 路径配置
@@ -565,6 +666,7 @@ def main():
)
cleaner.clean(id_file)
input("执行完毕,按回车键退出程序...")

View File

@@ -27,22 +27,24 @@ except ImportError:
# --- 全局日志配置 ---
# 调整格式:增加 [] 使其与 UI 控件的默认风格保持一致
LOG_FORMAT = '[%(asctime)s] [%(levelname)s] %(message)s'
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
LOG_FORMAT = "[%(asctime)s] [%(levelname)s] %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
logging.basicConfig(
level=logging.INFO,
format=LOG_FORMAT,
datefmt=DATE_FORMAT
)
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT, datefmt=DATE_FORMAT)
logger = logging.getLogger(__name__)
class DiscreteMaterialPlanExtractor:
"""离散备料计划维护数据提取器"""
def __init__(
self, username, password, headless=False, verbose=True, batch_size=100,
enable_db_persistence=False
self,
username,
password,
headless=False,
verbose=True,
batch_size=100,
enable_db_persistence=False,
):
self.username = username
self.password = password
@@ -53,10 +55,11 @@ class DiscreteMaterialPlanExtractor:
self.converter = ExcelConverter(verbose=verbose)
self.enable_db_persistence = enable_db_persistence
self.dao = None
if self.enable_db_persistence:
try:
from db.discrete_material_plan_dao import DiscreteMaterialPlanDAO
self.dao = DiscreteMaterialPlanDAO()
except ImportError:
self._log("无法加载数据库 DAO 模块,持久化功能将不可用", "error")
@@ -67,11 +70,7 @@ class DiscreteMaterialPlanExtractor:
"""
level = level.lower()
# 1. 记录到标准控制台
log_map = {
"info": logger.info,
"warn": logger.warning,
"error": logger.error
}
log_map = {"info": logger.info, "warn": logger.warning, "error": logger.error}
log_func = log_map.get(level, logger.info)
log_func(message)
@@ -80,7 +79,9 @@ class DiscreteMaterialPlanExtractor:
if self.progress_callback:
self._report_progress("log", 0, 0, message, log_level=level.upper())
def _report_progress(self, stage: str, current: int, total: int, message: str, **detail):
def _report_progress(
self, stage: str, current: int, total: int, message: str, **detail
):
"""标准化进度汇报"""
if self.progress_callback and ProgressInfo:
try:
@@ -98,20 +99,30 @@ class DiscreteMaterialPlanExtractor:
def get_production_order_numbers(self, production_id_file, report_progress=False):
"""读取总排号并查询数据库获取生产订单号"""
if report_progress:
self._report_progress("query", 1, 3, "正在读取总排号文件...", action="read_file")
self._report_progress(
"query", 1, 3, "正在读取总排号文件...", action="read_file"
)
production_ids = read_production_ids(production_id_file)
self._log(f"文件读取完成: 找到 {len(production_ids)} 个 Production ID")
if report_progress:
self._report_progress("query", 2, 3, "正在查询数据库获取生产订单号...", action="query_database")
self._report_progress(
"query",
2,
3,
"正在查询数据库获取生产订单号...",
action="query_database",
)
order_ids = query_production_order_numbers(production_ids)
self._log(f"数据库查询完成: 共匹配到 {len(order_ids)} 条生产订单号")
if report_progress:
self._report_progress("query", 3, 3, "订单号查询阶段结束", action="query_complete")
self._report_progress(
"query", 3, 3, "订单号查询阶段结束", action="query_complete"
)
return order_ids
def group_order_ids(self, order_ids, group_size=100):
@@ -121,9 +132,14 @@ class DiscreteMaterialPlanExtractor:
def download_batch(self, inner_frame, order_ids, batch_index, total_batches, page1):
"""执行单批次数据的下载流程"""
self._report_progress("download", batch_index * 7 + 1, total_batches * 7,
f"{batch_index + 1} 批: 正在填充订单号", action="fill_orders")
self._report_progress(
"download",
batch_index * 7 + 1,
total_batches * 7,
f"{batch_index + 1} 批: 正在填充订单号",
action="fill_orders",
)
textbox = inner_frame.get_by_role("textbox", name="来源生产订单号")
textbox.fill("")
textbox.fill(",".join(order_ids))
@@ -139,8 +155,12 @@ class DiscreteMaterialPlanExtractor:
inner_frame.get_by_role("row", name="序号").get_by_label("").click()
inner_frame.get_by_role("button", name="更多").hover()
inner_frame.get_by_text("输出", exact=True).click()
threshold_box = inner_frame.locator("div").filter(has_text=re.compile(r"^行数阈值$")).locator("input[type='text']")
threshold_box = (
inner_frame.locator("div")
.filter(has_text=re.compile(r"^行数阈值$"))
.locator("input[type='text']")
)
threshold_box.fill("300000")
download_path = f"D:/python/playwrite/data/temp_batch_{batch_index + 1}.xlsx"
@@ -165,36 +185,46 @@ class DiscreteMaterialPlanExtractor:
total_steps = len(file_paths) * 2 + 3
for i, path in enumerate(file_paths, 1):
self._report_progress("convert", 1 + (i-1)*2 + 1, total_steps, f"正在转换 Excel {i}/{len(file_paths)}")
self._report_progress(
"convert",
1 + (i - 1) * 2 + 1,
total_steps,
f"正在转换 Excel {i}/{len(file_paths)}",
)
df = self.converter.convert(path, output_file=None)
all_dfs.append(df)
self._log(f"文件 {i} 转换完成: 提取到 {len(df)} 条记录")
if all_dfs:
self._report_progress("convert", total_steps - 1, total_steps, "正在进行最终数据合并...")
self._report_progress(
"convert", total_steps - 1, total_steps, "正在进行最终数据合并..."
)
merged_df = pd.concat(all_dfs, ignore_index=True)
merged_df.to_excel(output_path, index=False)
for p in file_paths:
try: os.remove(p)
except: pass
try:
os.remove(p)
except:
pass
return output_path, merged_df
return None, None
def _save_to_database(self, df: pd.DataFrame):
"""将结果存入数据库并打印详细统计信息"""
if not self.dao: return
if not self.dao:
return
try:
self._report_progress("database", 1, 3, "正在将数据同步至数据库...")
# 使用 with 关键字确保资源安全释放
with self.dao as db:
stats = db.save_dataframe_with_replace(df)
# 保留并输出完整的处理细节:删除条数和新增条数
msg = f"数据库保存完成: 删除 {stats.get('deleted', 0)} 条, 新增 {stats.get('inserted', 0)}"
self._log(msg, "info")
except Exception as e:
self._log(f"数据库保存失败: {str(e)}", "error")
@@ -209,8 +239,10 @@ class DiscreteMaterialPlanExtractor:
input_box.press("Enter")
def extract(
self, production_id_file, output_file="D:/python/playwrite/data/离散备料计划维护_合并.xlsx",
progress_callback=None
self,
production_id_file,
output_file="D:/python/playwrite/data/离散备料计划维护_合并.xlsx",
progress_callback=None,
):
"""主入口:执行全流程数据提取任务"""
self.progress_callback = progress_callback
@@ -220,15 +252,22 @@ class DiscreteMaterialPlanExtractor:
with sync_playwright() as playwright:
self._report_progress("login", 1, 3, "启动浏览器并尝试登录 ERP...")
browser, context, page, main_frame = login(
playwright=playwright, username=self.username, password=self.password,
headless=self.headless, ignore_https_errors=True
playwright=playwright,
username=self.username,
password=self.password,
headless=self.headless,
ignore_https_errors=True,
)
self._log(
"======================================== 开始执行数据提取任务 ========================================"
)
self._log("======================================== 开始执行数据提取任务 ========================================")
main_frame.locator("i").first.click()
with page.expect_popup() as page1_info:
main_frame.get_by_title("离散备料计划维护", exact=True).first.click()
main_frame.get_by_title(
"离散备料计划维护", exact=True
).first.click()
page1 = page1_info.value
f_frame = page1.locator("#forwardFrame").content_frame
@@ -237,16 +276,22 @@ class DiscreteMaterialPlanExtractor:
work_frame = inner_frame_locator.content_frame
self.setup_query_interface(work_frame)
order_ids = self.get_production_order_numbers(production_id_file, report_progress=True)
order_ids = self.get_production_order_numbers(
production_id_file, report_progress=True
)
batch_list = list(self.group_order_ids(order_ids, self.batch_size))
for i, batch_ids in enumerate(batch_list):
self._log(f"正在处理第 {i+1} 批次 (共 {len(batch_list)} 批)")
try:
f_path = self.download_batch(work_frame, batch_ids, i, len(batch_list), page1)
f_path = self.download_batch(
work_frame, batch_ids, i, len(batch_list), page1
)
downloaded_files.append(f_path)
except Exception as e:
self._log(f"批次 {i+1} 处理异常,已跳过。详细错误: {e}", "error")
self._log(
f"批次 {i+1} 处理异常,已跳过。详细错误: {e}", "error"
)
continue
self._log("正在注销并关闭浏览器环境...")
@@ -255,28 +300,32 @@ class DiscreteMaterialPlanExtractor:
browser.close()
if downloaded_files:
final_path, final_df = self.convert_and_merge_files(downloaded_files, output_file)
final_path, final_df = self.convert_and_merge_files(
downloaded_files, output_file
)
if self.enable_db_persistence and final_df is not None:
self._save_to_database(final_df)
self._log(f"所有流程已顺利结束,结果文件: {final_path}")
self._report_progress("complete", 1, 1, "任务完成")
return final_path
self._log("未获得任何有效数据,任务终止", "warn")
return None
finally:
self.progress_callback = None
def main():
extractor = DiscreteMaterialPlanExtractor(
username="BLDpengqiangqiang",
password="your_password",
enable_db_persistence=True
enable_db_persistence=True,
)
id_file = os.path.join(os.path.dirname(__file__), "productionID.txt")
extractor.extract(id_file)
if __name__ == "__main__":
main()
main()

View File

@@ -23,19 +23,22 @@ from db.discrete_material_plan_dao import DiscreteMaterialPlanDAO
from db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO
from db.materials_to_be_deleted_records_dao import MaterialsToBeDeletedDAO
# ==================== DATA STRUCTURES ====================
@dataclass
class MaterialValidationResult:
"""Enhanced material validation result with complete record information"""
material_name: str
material_code: str
specification: Optional[str] = None
model: Optional[str] = None
manager_name: Optional[str] = None
is_marked_for_deletion: bool = False
matched_type_keyword: Optional[str] = None # Matched keyword from MaterialsTypeToBeDeleted
matched_type_keyword: Optional[str] = (
None # Matched keyword from MaterialsTypeToBeDeleted
)
class MaterialStatusValidator:
@@ -261,13 +264,11 @@ class MaterialStatusValidator:
Returns:
List[str]: 输入项列表(可能是总排号或生产订单号)
"""
with open(production_id_file, 'r', encoding='utf-8') as f:
with open(production_id_file, "r", encoding="utf-8") as f:
items = [line.strip() for line in f if line.strip()]
return items
def _get_source_numbers_from_inputs(
self, inputs: List[str]
) -> List[str]:
def _get_source_numbers_from_inputs(self, inputs: List[str]) -> List[str]:
"""
根据输入列表智能获取 SourceNumber生产订单号列表
@@ -281,7 +282,7 @@ class MaterialStatusValidator:
List[str]: 生产订单号列表
"""
production_ids = [] # 需要查询数据库的
order_numbers = [] # 直接使用的
order_numbers = [] # 直接使用的
for item in inputs:
input_type = self._identify_input_type(item)
@@ -298,7 +299,9 @@ class MaterialStatusValidator:
# 查询数据库获取总排号对应的生产订单号
if production_ids:
self._print(f"[INFO] 正在查询 {len(production_ids)} 个总排号对应的生产订单号...")
self._print(
f"[INFO] 正在查询 {len(production_ids)} 个总排号对应的生产订单号..."
)
contract_dao = ProductionContractDataDAO()
db_order_numbers = contract_dao.get_source_numbers_by_总排号(production_ids)
self._print(f"[INFO] 从数据库获取到 {len(db_order_numbers)} 个生产订单号")
@@ -307,7 +310,9 @@ class MaterialStatusValidator:
# 去重
unique_order_numbers = list(dict.fromkeys(order_numbers))
if len(unique_order_numbers) != len(order_numbers):
self._print(f"[INFO] 去重后得到 {len(unique_order_numbers)} 个唯一生产订单号")
self._print(
f"[INFO] 去重后得到 {len(unique_order_numbers)} 个唯一生产订单号"
)
return unique_order_numbers
@@ -326,7 +331,9 @@ class MaterialStatusValidator:
if source_numbers is None or not source_numbers:
self._print("[INFO] 查询所有材料的名称...")
else:
self._print(f"[INFO] 查询 {len(source_numbers)} 个生产订单对应的材料名称...")
self._print(
f"[INFO] 查询 {len(source_numbers)} 个生产订单对应的材料名称..."
)
dao = DiscreteMaterialPlanDAO()
material_names = dao.get_unique_material_names(source_numbers)
@@ -338,7 +345,7 @@ class MaterialStatusValidator:
self,
production_id_file: str = None,
full_table: bool = False,
output_file: str = None
output_file: str = None,
) -> str:
"""
使用数据库作为数据源执行校验
@@ -392,9 +399,7 @@ class MaterialStatusValidator:
# 3. 获取材料名称
material_names = self._get_material_names_from_db(source_numbers)
else:
raise ValueError(
"必须指定 full_table=True 或提供 production_id_file 参数"
)
raise ValueError("必须指定 full_table=True 或提供 production_id_file 参数")
# 从数据库获取待删除物料
self._print("\n从数据库获取待删除物料...")
@@ -426,7 +431,9 @@ class MaterialStatusValidator:
self,
material_records: List[Dict[str, Any]],
type_keywords: List[Dict[str, Any]],
marked_codes_dict: Dict[str, str] # Changed: MaterialCode -> ManagerName mapping
marked_codes_dict: Dict[
str, str
], # Changed: MaterialCode -> ManagerName mapping
) -> List[MaterialValidationResult]:
"""
Match materials with detailed information.
@@ -442,14 +449,16 @@ class MaterialStatusValidator:
results = []
for record in material_records:
material_name = record.get('MaterialName', '') or ''
material_code = record.get('MaterialCode', '') or ''
specification = record.get('Specification', '') or None
model = record.get('Model', '') or None
material_name = record.get("MaterialName", "") or ""
material_code = record.get("MaterialCode", "") or ""
specification = record.get("Specification", "") or None
model = record.get("Model", "") or None
# Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match)
# This has highest priority - if MaterialCode exists, use its ManagerName
manager_name = marked_codes_dict.get(material_code) if material_code else None
manager_name = (
marked_codes_dict.get(material_code) if material_code else None
)
is_marked = manager_name is not None
matched_keyword = None
@@ -457,10 +466,10 @@ class MaterialStatusValidator:
# (MaterialName contains match)
if not manager_name:
for type_record in type_keywords:
type_material_name = type_record.get('MaterialName', '')
type_material_name = type_record.get("MaterialName", "")
if type_material_name and type_material_name in material_name:
matched_keyword = type_material_name
manager_name = type_record.get('ManagerName')
manager_name = type_record.get("ManagerName")
break
result = MaterialValidationResult(
@@ -470,7 +479,7 @@ class MaterialStatusValidator:
model=model,
manager_name=manager_name,
is_marked_for_deletion=is_marked,
matched_type_keyword=matched_keyword
matched_type_keyword=matched_keyword,
)
results.append(result)
@@ -480,7 +489,7 @@ class MaterialStatusValidator:
self,
production_id_file: str = None,
full_table: bool = False,
output_file: str = None
output_file: str = None,
) -> tuple:
"""
Enhanced database validation with complete record information.
@@ -510,7 +519,9 @@ class MaterialStatusValidator:
# Get material records (complete records, not just MaterialName)
if full_table:
self._print("\n模式: 全表校验")
self._print("[INFO] 查询 DiscreteMaterialPlanData 表中的所有完整记录(启用 MaterialCode 去重)...")
self._print(
"[INFO] 查询 DiscreteMaterialPlanData 表中的所有完整记录(启用 MaterialCode 去重)..."
)
dao = DiscreteMaterialPlanDAO()
# Get original count for deduplication statistics
@@ -521,7 +532,9 @@ class MaterialStatusValidator:
self._print(f"[INFO] 获取到 {len(material_records)} 条记录")
if dedup_count > 0:
self._print(f"[INFO] 基于 MaterialCode 去重:移除了 {dedup_count} 条重复记录")
self._print(
f"[INFO] 基于 MaterialCode 去重:移除了 {dedup_count} 条重复记录"
)
elif production_id_file:
self._print("\n模式: 输入过滤校验")
self._print(f"[INFO] 读取输入文件: {production_id_file}")
@@ -542,7 +555,9 @@ class MaterialStatusValidator:
return output_file, []
# 3. Get complete material records with deduplication
self._print(f"[INFO] 查询 {len(source_numbers)} 个生产订单对应的完整物料记录(启用 MaterialCode 去重)...")
self._print(
f"[INFO] 查询 {len(source_numbers)} 个生产订单对应的完整物料记录(启用 MaterialCode 去重)..."
)
dao = DiscreteMaterialPlanDAO()
# Get original count for deduplication statistics
@@ -554,7 +569,9 @@ class MaterialStatusValidator:
self._print(f"[INFO] 获取到 {len(material_records)} 条记录")
if dedup_count > 0:
self._print(f"[INFO] 基于 MaterialCode 去重:移除了 {dedup_count} 条重复记录")
self._print(
f"[INFO] 基于 MaterialCode 去重:移除了 {dedup_count} 条重复记录"
)
# 如果没有找到物料记录,给出友好提示
if not material_records:
@@ -563,7 +580,9 @@ class MaterialStatusValidator:
self._print("[ERROR] 1. 这些生产订单的物料数据还没有提取到数据库")
self._print("[ERROR] 2. 请先运行【正式备料计划数据提取】工具")
self._print("[ERROR] 3. 提取时勾选【持久化到数据库】选项")
self._print(f"[ERROR] 4. 将这些输入项的物料数据保存到 DiscreteMaterialPlanData 表")
self._print(
f"[ERROR] 4. 将这些输入项的物料数据保存到 DiscreteMaterialPlanData 表"
)
else:
raise ValueError("必须指定 full_table=True 或提供 production_id_file 参数")
@@ -580,15 +599,17 @@ class MaterialStatusValidator:
# Build dictionary: MaterialCode -> ManagerName
marked_codes_dict = {
r['MaterialCode']: r['ManagerName']
r["MaterialCode"]: r["ManagerName"]
for r in marked_records
if r.get('MaterialCode') and r.get('ManagerName')
if r.get("MaterialCode") and r.get("ManagerName")
}
self._print(f"获取到 {len(marked_codes_dict)} 个已标记的物料代码")
# Match materials
self._print("\n匹配物料...")
results = self.match_materials_detailed(material_records, type_keywords, marked_codes_dict)
results = self.match_materials_detailed(
material_records, type_keywords, marked_codes_dict
)
# Output to Excel
self._print("\n输出结果...")
@@ -596,15 +617,17 @@ class MaterialStatusValidator:
# Convert to DataFrame for Excel export
df_data = []
for r in results:
df_data.append({
"材料名称": r.material_name,
"材料代码": r.material_code,
"规格": r.specification or '',
"型号": r.model or '',
"负责人": r.manager_name or '',
"已标记删除": "" if r.is_marked_for_deletion else "",
"匹配的关键词": r.matched_type_keyword or ''
})
df_data.append(
{
"材料名称": r.material_name,
"材料代码": r.material_code,
"规格": r.specification or "",
"型号": r.model or "",
"负责人": r.manager_name or "",
"已标记删除": "" if r.is_marked_for_deletion else "",
"匹配的关键词": r.matched_type_keyword or "",
}
)
result_df = pd.DataFrame(df_data)
result_df.to_excel(output_file, index=False)