Files
playwrite/db/discrete_material_plan_dao.py
Misaka caac411e17 feat: add MySQL database support alongside SQL Server
This commit implements multi-database support, allowing the system to switch
between SQL Server and MySQL databases seamlessly.

## New Features
- Database type selection (SQL Server or MySQL) via configuration
- Automatic table name conversion between formats ([dbo].[table] → dbo_table)
- Automatic parameter placeholder handling (? for SQL Server, %s for MySQL)
- GUI settings tab now includes database type dropdown and MySQL configuration

## Database Abstraction Layer
- db/base_connection.py: Abstract base class for database connections
- db/sqlserver_connection.py: SQL Server implementation
- db/mysql_connection.py: MySQL implementation using mysql-connector-python
- db/connection_factory.py: Factory pattern for creating connections
- db/table_name_converter.py: Table name format conversion utility

## DAO Base Class
- db/base_dao.py: Base DAO with helper methods for SQL conversion and placeholders

## Updated Components
- config/schema.py: Extended with DatabaseType enum and MySQL/SQLServer config classes
- config/defaults.py: Added MySQL default configuration
- config/loader.py: Updated to handle new database structure
- db/connection.py: Refactored to use factory pattern and load user config
- All DAO files: Updated to inherit from BaseDAO with automatic conversion

## Dependencies
- Added mysql-connector-python>=8.0.0 to requirements.txt

## Configuration
To use MySQL, set db_type to "mysql" in config/user_settings.json:
{
  "database": {
    "db_type": "mysql",
    "mysql": {
      "host": "192.168.31.83",
      "port": 3306,
      "database": "BLD_DB",
      "username": "remote_user",
      "password": "3.1415926Beeke"
    }
  }
}

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 21:50:54 +08:00

419 lines
16 KiB
Python

"""
Data Access Object for DiscreteMaterialPlanData table.
This module provides CRUD operations for persisting discrete material plan
data to SQL Server/MySQL database. It handles mapping between Chinese DataFrame
columns (from ExcelConverter) and English database columns.
"""
from db.base_dao import BaseDAO
from db.connection import get_connection
from typing import List, Dict, Any
import pandas as pd
from config.schema import DatabaseType
class DiscreteMaterialPlanDAO(BaseDAO):
"""Data Access Object for DiscreteMaterialPlanData table"""
def save_dataframe_with_replace(self, df: pd.DataFrame) -> Dict[str, int]:
"""
Save DataFrame using REPLACE strategy (DELETE + INSERT).
This method implements a replace strategy where existing records
matching the plan numbers in the DataFrame are deleted before
inserting new records.
Args:
df: DataFrame with discrete material plan data (Chinese column names)
Returns:
Dictionary with 'deleted' and 'inserted' counts
Example:
>>> dao = DiscreteMaterialPlanDAO()
>>> with dao:
... stats = dao.save_dataframe_with_replace(df)
... print(f"Deleted: {stats['deleted']}, Inserted: {stats['inserted']}")
"""
if df.empty:
return {'deleted': 0, 'inserted': 0}
# Remove duplicates based on PlanNumber and SequenceNumber
original_count = len(df)
df = df.drop_duplicates(subset=['备料计划单号', '序号'], keep='first')
duplicates_removed = original_count - len(df)
if duplicates_removed > 0:
print(f"[INFO] 检测到 {duplicates_removed} 条重复记录(相同计划单号和序号),已自动去重")
with get_connection() as db:
# Get unique plan numbers
plan_numbers = df['备料计划单号'].unique().tolist()
# Delete existing records
deleted = self._delete_by_plan_numbers(db, plan_numbers)
# Insert new records in batches
inserted = self._batch_insert(db, df)
return {'deleted': deleted, 'inserted': inserted}
def _delete_by_plan_numbers(self, db, plan_numbers: List[str]) -> int:
"""
Delete records by plan numbers.
Args:
db: Database connection object
plan_numbers: List of plan numbers to delete
Returns:
Number of records deleted
"""
if not plan_numbers:
return 0
# SQL Server has a limit on IN clause parameters
# Delete in batches to avoid exceeding the limit
batch_size = 1000 # Safe limit for IN clause
total_deleted = 0
for i in range(0, len(plan_numbers), batch_size):
batch = plan_numbers[i:i + batch_size]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
# 根据数据库类型选择表名
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
sql = f"DELETE FROM {table_name} WHERE PlanNumber IN ({placeholders})"
deleted = db.execute_update(sql, tuple(batch))
total_deleted += deleted
return total_deleted
def _batch_insert(self, db, df: pd.DataFrame, batch_size: int = 72) -> int:
"""
Batch insert records (max 72 per batch due to SQL Server 2100 param limit).
SQL Server has a limit of 2100 parameters per query. With 29 fields,
the maximum batch size is floor(2100 / 29) = 72 records per batch.
Args:
db: Database connection object
df: DataFrame to insert
batch_size: Number of records per batch (default: 72)
Returns:
Total number of records inserted
"""
# 根据数据库类型选择表名
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
placeholder = self._get_placeholder()
sql = f"""
INSERT INTO {table_name} (
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
) VALUES ({self._build_placeholders(28)})
"""
total_inserted = 0
records = self._convert_df_to_records(df)
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
for record in batch:
db.execute_update(sql, record)
total_inserted += 1
return total_inserted
def _convert_df_to_records(self, df: pd.DataFrame) -> List[tuple]:
"""
Convert DataFrame to list of tuples for batch insert.
Maps Chinese DataFrame column names to English database column names
and converts each row to a tuple in the correct order.
Handles NaN/None values by converting them to None for NULL fields.
Args:
df: DataFrame with Chinese column names
Returns:
List of tuples, one per record
"""
# Column order must match INSERT statement
column_order = [
'工厂', '备料状态', '备料计划单号', '来源单号', '备料类型', '产品编码',
'产品名称', '产品单位', '产品计划数量', '用料部门', '备注', '制单人',
'制单日期', '审批人', '审批日期', '序号', '材料编码', '材料名称',
'规格', '型号', '图号', '物料材质', '计划数量', '单位', '需用日期',
'发料仓库', '单位用量', '累计出库数量', 'BOM版本'
]
# Numeric columns with their default values and data types
numeric_columns = {
'产品计划数量': (0, int),
'序号': (0, int),
'计划数量': (0, int),
'单位用量': (0.0, float),
'累计出库数量': (0, int),
}
records = []
for _, row in df.iterrows():
record = []
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 col in numeric_columns:
# Use default value for numeric columns
record.append(numeric_columns[col][0])
else:
# Use None (NULL) for string columns
record.append(None)
else:
# Convert numeric columns to proper type
if col in numeric_columns:
try:
default_value, target_type = numeric_columns[col]
if target_type == float:
record.append(float(value))
else:
record.append(int(value))
except (ValueError, TypeError):
# If conversion fails, use default value
record.append(numeric_columns[col][0])
else:
# Keep string columns as is
record.append(value)
records.append(tuple(record))
return records
def query_by_plan_number(self, plan_number: str) -> List[Dict]:
"""
Query all records for a specific plan number.
Args:
plan_number: Plan number to query
Returns:
List of dictionaries representing records
"""
with get_connection() as db:
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,))
def query_by_plan_numbers(self, plan_numbers: List[str]) -> List[Dict]:
"""
Query records for multiple plan numbers.
Args:
plan_numbers: List of plan numbers to query
Returns:
List of dictionaries representing records
"""
if not plan_numbers:
return []
placeholder = self._get_placeholder()
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))
def query_by_production_order(self, order_id: str) -> List[Dict]:
"""
Query all records for a specific production order.
Args:
order_id: Production order ID (SourceNumber)
Returns:
List of dictionaries representing records
"""
with get_connection() as db:
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,))
def count_by_plan_number(self, plan_number: str) -> int:
"""
Count records for a specific plan number.
Args:
plan_number: Plan number to count
Returns:
Number of records
"""
with get_connection() as db:
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
def count_all(self) -> int:
"""
Count all records in the table.
Returns:
Total number of records
"""
with get_connection() as db:
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
def delete_by_plan_numbers(self, plan_numbers: List[str]) -> int:
"""
Delete all records for specified plan numbers.
Args:
plan_numbers: List of plan numbers to delete
Returns:
Number of records deleted
"""
with get_connection() as db:
return self._delete_by_plan_numbers(db, plan_numbers)
def get_statistics(self) -> Dict[str, Any]:
"""
Get comprehensive statistics about the data.
Returns:
Dictionary with statistics including total records,
unique plans, unique orders, and date range
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
sql = f"""
SELECT
COUNT(*) as total_records,
COUNT(DISTINCT PlanNumber) as unique_plans,
COUNT(DISTINCT SourceNumber) as unique_orders,
MIN(CreateDate) as earliest_record,
MAX(CreateDate) as latest_record
FROM {table_name}
"""
result = db.execute_query(sql)
return result[0] if result else {}
# ==================== ENHANCED QUERY METHODS ====================
def query_all(self) -> List[Dict]:
"""
Query all records from DiscreteMaterialPlanData table.
Returns:
List of dictionaries representing all records
"""
with get_connection() as db:
table_name = self._convert_sql('[dbo].[DiscreteMaterialPlanData]')
sql = f"SELECT * FROM {table_name}"
return db.execute_query(sql)
def query_by_source_numbers(self, source_numbers: List[str]) -> List[Dict]:
"""
Query records by SourceNumber list (生产订单号).
Args:
source_numbers: List of SourceNumber values to query
Returns:
List of dictionaries representing records
"""
if not source_numbers:
return []
# SQL Server parameter limit requires batching
batch_size = 2000
all_results = []
for i in range(0, len(source_numbers), 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]')
sql = f"SELECT * FROM {table_name} WHERE SourceNumber IN ({placeholders})"
with get_connection() as db:
results = db.execute_query(sql, tuple(batch))
all_results.extend(results)
return all_results
def get_unique_material_names(self, source_numbers: List[str] = None) -> List[str]:
"""
Get unique material names, optionally filtered by SourceNumber.
Args:
source_numbers: Optional list of SourceNumber values to filter by
Returns:
List of unique material names
"""
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')]
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]
placeholder = self._get_placeholder()
placeholders = ','.join([placeholder for _ in batch])
sql = f"""
SELECT DISTINCT MaterialName
FROM {table_name}
WHERE SourceNumber IN ({placeholders})
AND MaterialName IS NOT NULL
"""
with get_connection() as db:
results = db.execute_query(sql, tuple(batch))
batch_materials = [r['MaterialName'] for r in results if r.get('MaterialName')]
all_material_names.update(batch_materials)
return list(all_material_names)
def get_material_names_by_总排号(self, 总排号_list: List[str]) -> List[str]:
"""
Get unique material names by 总排号 list.
This method combines query from production contract data and discrete material plan.
Args:
总排号_list: List of 总排号 values
Returns:
List of unique material names
"""
from db.production_contract_data_dao import ProductionContractDataDAO
# First get SourceNumbers from production contract data
contract_dao = ProductionContractDataDAO()
source_numbers = contract_dao.get_source_numbers_by_总排号(总排号_list)
# Then get material names filtered by these SourceNumbers
return self.get_unique_material_names(source_numbers)