This commit is contained in:
Aiden_
2025-10-31 21:24:08 +08:00
parent cc9f3e21c9
commit 5b32208194
12 changed files with 2047 additions and 23 deletions

View File

@@ -3,7 +3,8 @@
"allow": [
"Bash(pip install:*)",
"Bash(python3:*)",
"Bash(source venv/bin/activate)"
"Bash(source venv/bin/activate)",
"Bash(sudo rm:*)"
],
"deny": [],
"ask": []

Binary file not shown.

View File

@@ -70,7 +70,9 @@ AccountingEntries.xlsx (Final accounting entry table)
- Creates debit/credit entries following Chinese accounting standards
- Merges cells for same ReceivedAmount groups
- Marks validation failures with pink background (#FAD1D4)
- Applies fixed exchange rate to currency conversions
- Marks debit/credit imbalance with yellow background (#fff799)
- Applies fixed exchange rate to currency conversions (3 decimal places)
- Sets "核算项目" to empty for 到账金额 debit entries
3. **`analyze_excel.py`** - Structure Analysis Utility
- Debugging tool to inspect merged cells
@@ -103,20 +105,25 @@ AccountingEntries.xlsx (Final accounting entry table)
1. **Debit Entry (到账金额)** - 1 record per ReceivedAmount
- Account: `1002.02` - 银行存款 - 中行USD
- Currency: 美元 (USD)
- Amount: `ReceivedAmount × EXCHANGE_RATE`
- Amount: `ReceivedAmount × EXCHANGE_RATE` (3 decimal places)
- 核算项目: Empty (left blank)
2. **Debit Entry (手续费)** - Only if HandlingFee > 0
- Account: `5603.03` - 财务费用-手续费
- Currency: 人民币 (RMB)
- Amount: `HandlingFee × EXCHANGE_RATE`
- Amount: `HandlingFee × EXCHANGE_RATE` (3 decimal places)
3. **Credit Entries (订单明细)** - 1 record per Order
- Account: `1122` - 应收账款
- Currency: 美元 (USD)
- Amount: `Order.Amount × EXCHANGE_RATE`
- Amount: `Order.Amount × EXCHANGE_RATE` (3 decimal places)
- **Display Order.Amount in "应收账款" column**
- Skip orders where Amount is null
**Balance Verification**:
- After generating debit and credit entries for each record, the system verifies: `|SUM(Debit) - SUM(Credit)| < 0.001`
- If imbalanced, all entries for that record are marked with yellow background (#fff799)
### Special Processing Logic
#### Merged Cell Handling (process_excel.py:33-69)
@@ -126,11 +133,14 @@ AccountingEntries.xlsx (Final accounting entry table)
- Merged cell value read from top-left corner (min_row, min_col)
#### Validation and Error Marking
- **checkRes calculation**: `abs((ReceivedAmount + HandlingFee) - Sum(Order[].Amount)) < 0.01`
- **Error marking**: Pink background (#FAD1D4) applied to all entries where checkRes = false
- **checkRes calculation**: `abs((ReceivedAmount + HandlingFee) - Sum(Order[].Amount)) < 0.01` (from extraction phase)
- **Balance verification**: Per-record debit/credit validation with tolerance: `abs(SUM(Debit) - SUM(Credit)) < 0.001`
- **Error marking priority**:
1. Yellow background (#fff799) for debit/credit imbalance
2. Pink background (#FAD1D4) for checkRes = false
- Background color applied **before** cell merging to ensure visibility
#### Cell Merging Strategy (generate_accounting_entries.py:178-206)
#### Cell Merging Strategy (generate_accounting_entries.py:179-215)
- Groups entries by `(ReceivedAmount, HandlingFee)` key
- Merges "到账金额" (column A) and "手续费" (column B) for consecutive rows
- Centers content vertically and horizontally
@@ -201,17 +211,24 @@ python3 generate_accounting_entries.py
3. **Order filtering**: Skip rows where OrderNum is None or empty string
4. **Amount precision**: All calculations rounded to 2 decimal places
4. **Amount precision**:
- Exchange rate calculations: **3 decimal places** (e.g., 25656.992)
- Debit/credit balance tolerance: **0.001**
5. **UTF-8 encoding**: All files use UTF-8 encoding
5. **核算项目 (Accounting dimension)**:
- 到账金额 (Received amount debit): **Empty/blank**
- 手续费 (Fee debit): **Empty/blank**
- 订单明细 (Order credit): Account name
6. **Error handling**:
6. **UTF-8 encoding**: All files use UTF-8 encoding
7. **Error handling**:
- File not found: Exit with error message
- Invalid sheet: Exit with error message
- Invalid data: Log and skip row
- checkRes=false: Mark but continue processing
7. **Performance**: Handles 300+ rows of Excel data generating 500+ accounting entries in <10 seconds
8. **Performance**: Handles 300+ rows of Excel data generating 500+ accounting entries in <10 seconds
## Testing Guidance
@@ -231,6 +248,15 @@ python3 generate_accounting_entries.py
## Version History
- **v1.3** (2025-10-20): Enhanced accounting entry validation
- Changed amount precision to **3 decimal places** for exchange rate calculations
- Added per-record debit/credit balance verification with 0.001 tolerance
- Added yellow background (#fff799) marking for imbalanced entries
- Set 核算项目 to empty for 到账金额 debit entries
- Updated balance verification tolerance to 0.001
- **v1.2** (2025-10-17): Added exchange rate file support (`exchange_rate.txt`), intelligent rate validation, improved error handling
- **v1.1** (2025-01-17): Optimized accounting rules - removed redundant debit entries, simplified single-order logic
- **v1.0** (2025-01-17): Initial release with extraction, generation, validation, and error marking features

49
config.ini Normal file
View File

@@ -0,0 +1,49 @@
# 财务数据处理系统配置文件
[Excel]
# Excel文件配置
input_file = data/data.xlsx
sheet_name = Sheet1
data_start_row = 2
# 列索引配置 (1-based)
col_received_amount = 6 # F列: 实收金额
col_handling_fee = 7 # G列: 手续费
col_order_num = 8 # H列: 订单号
col_amount = 9 # I列: 金额
col_account_name = 15 # O列: 账户名
[Output]
# 输出文件配置
json_output = res.json
excel_output = AccountingEntries.xlsx
validation_report = validation_report.txt
[Processing]
# 数据处理配置
amount_tolerance = 0.01 # 金额匹配容差
skip_empty_orders = true # 跳过空订单
[Accounting]
# 会计分录配置
default_exchange_rate = 7.1072
exchange_rate_file = exchange_rate.txt
# 科目代码配置
account_code_bank = 1002.02
account_code_fee = 5603.03
account_code_receivable = 1122
# 科目名称配置
account_name_bank = 银行存款 - 中行USD
account_name_fee = 财务费用-手续费
account_name_receivable = 应收账款
[Logging]
# 日志配置
log_level = INFO
log_format = %(asctime)s - %(levelname)s - %(message)s
log_file = processing.log

210
config_loader.py Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
配置文件加载工具
支持从config.ini读取配置参数
"""
import configparser
import os
from typing import Any, Optional
from dataclasses import dataclass
@dataclass
class ExcelConfig:
"""Excel配置"""
input_file: str
sheet_name: str
data_start_row: int
col_received_amount: int
col_handling_fee: int
col_order_num: int
col_amount: int
col_account_name: int
@dataclass
class OutputConfig:
"""输出配置"""
json_output: str
excel_output: str
validation_report: str
@dataclass
class ProcessingConfig:
"""处理配置"""
amount_tolerance: float
skip_empty_orders: bool
@dataclass
class AccountingConfig:
"""会计配置"""
default_exchange_rate: float
exchange_rate_file: str
account_code_bank: str
account_code_fee: str
account_code_receivable: str
account_name_bank: str
account_name_fee: str
account_name_receivable: str
@dataclass
class LoggingConfig:
"""日志配置"""
log_level: str
log_format: str
log_file: str
class ConfigLoader:
"""配置加载器"""
def __init__(self, config_file: str = 'config.ini'):
"""
初始化配置加载器
参数:
config_file: 配置文件路径
"""
self.config_file = config_file
self.config = configparser.ConfigParser()
if os.path.exists(config_file):
self.config.read(config_file, encoding='utf-8')
else:
raise FileNotFoundError(f"配置文件不存在: {config_file}")
def _get_value(self, section: str, key: str, default: Any = None,
value_type: type = str) -> Any:
"""
获取配置值
参数:
section: 配置节名
key: 配置键名
default: 默认值
value_type: 值类型
返回:
配置值
"""
try:
if value_type == bool:
return self.config.getboolean(section, key)
elif value_type == int:
return self.config.getint(section, key)
elif value_type == float:
return self.config.getfloat(section, key)
else:
return self.config.get(section, key)
except (configparser.NoSectionError, configparser.NoOptionError):
return default
def load_excel_config(self) -> ExcelConfig:
"""加载Excel配置"""
return ExcelConfig(
input_file=self._get_value('Excel', 'input_file', 'data/data.xlsx'),
sheet_name=self._get_value('Excel', 'sheet_name', 'Sheet1'),
data_start_row=self._get_value('Excel', 'data_start_row', 2, int),
col_received_amount=self._get_value('Excel', 'col_received_amount', 6, int),
col_handling_fee=self._get_value('Excel', 'col_handling_fee', 7, int),
col_order_num=self._get_value('Excel', 'col_order_num', 8, int),
col_amount=self._get_value('Excel', 'col_amount', 9, int),
col_account_name=self._get_value('Excel', 'col_account_name', 15, int)
)
def load_output_config(self) -> OutputConfig:
"""加载输出配置"""
return OutputConfig(
json_output=self._get_value('Output', 'json_output', 'res.json'),
excel_output=self._get_value('Output', 'excel_output', 'AccountingEntries.xlsx'),
validation_report=self._get_value('Output', 'validation_report', 'validation_report.txt')
)
def load_processing_config(self) -> ProcessingConfig:
"""加载处理配置"""
return ProcessingConfig(
amount_tolerance=self._get_value('Processing', 'amount_tolerance', 0.01, float),
skip_empty_orders=self._get_value('Processing', 'skip_empty_orders', True, bool)
)
def load_accounting_config(self) -> AccountingConfig:
"""加载会计配置"""
return AccountingConfig(
default_exchange_rate=self._get_value('Accounting', 'default_exchange_rate', 7.1072, float),
exchange_rate_file=self._get_value('Accounting', 'exchange_rate_file', 'exchange_rate.txt'),
account_code_bank=self._get_value('Accounting', 'account_code_bank', '1002.02'),
account_code_fee=self._get_value('Accounting', 'account_code_fee', '5603.03'),
account_code_receivable=self._get_value('Accounting', 'account_code_receivable', '1122'),
account_name_bank=self._get_value('Accounting', 'account_name_bank', '银行存款 - 中行USD'),
account_name_fee=self._get_value('Accounting', 'account_name_fee', '财务费用-手续费'),
account_name_receivable=self._get_value('Accounting', 'account_name_receivable', '应收账款')
)
def load_logging_config(self) -> LoggingConfig:
"""加载日志配置"""
return LoggingConfig(
log_level=self._get_value('Logging', 'log_level', 'INFO'),
log_format=self._get_value('Logging', 'log_format',
'%(asctime)s - %(levelname)s - %(message)s'),
log_file=self._get_value('Logging', 'log_file', 'processing.log')
)
def load_all(self) -> dict:
"""
加载所有配置
返回:
包含所有配置的字典
"""
return {
'excel': self.load_excel_config(),
'output': self.load_output_config(),
'processing': self.load_processing_config(),
'accounting': self.load_accounting_config(),
'logging': self.load_logging_config()
}
def main():
"""测试配置加载"""
try:
loader = ConfigLoader()
configs = loader.load_all()
print("配置加载成功:")
print("-" * 60)
print("\nExcel配置:")
print(f" 输入文件: {configs['excel'].input_file}")
print(f" 工作表: {configs['excel'].sheet_name}")
print(f" 数据起始行: {configs['excel'].data_start_row}")
print("\n输出配置:")
print(f" JSON输出: {configs['output'].json_output}")
print(f" Excel输出: {configs['output'].excel_output}")
print("\n处理配置:")
print(f" 金额容差: {configs['processing'].amount_tolerance}")
print(f" 跳过空订单: {configs['processing'].skip_empty_orders}")
print("\n会计配置:")
print(f" 默认汇率: {configs['accounting'].default_exchange_rate}")
print(f" 银行科目代码: {configs['accounting'].account_code_bank}")
print("\n日志配置:")
print(f" 日志级别: {configs['logging'].log_level}")
print(f" 日志文件: {configs['logging'].log_file}")
except Exception as e:
print(f"配置加载失败: {e}")
if __name__ == '__main__':
main()

Binary file not shown.

View File

@@ -64,8 +64,9 @@ def create_accounting_entries(data: List[Dict[str, Any]], exchange_rate: float)
会计分录列表
"""
entries = []
record_groups = {} # 用于按记录分组,以便后续验证借贷平衡
for record in data:
for record_idx, record in enumerate(data):
received_amount = record["ReceivedAmount"]
handling_fee = record["HandlingFee"]
orders = record["Order"]
@@ -75,6 +76,10 @@ def create_accounting_entries(data: List[Dict[str, Any]], exchange_rate: float)
if received_amount is None or not orders:
continue
# 使用记录索引作为键,避免相同(received_amount, handling_fee)的重复覆盖
record_key = record_idx
record_groups[record_key] = []
# 1. ReceivedAmount 借方记录
# 科目代码: 1002.02, 科目名称: 银行存款 - 中行USD
for order in orders:
@@ -94,14 +99,16 @@ def create_accounting_entries(data: List[Dict[str, Any]], exchange_rate: float)
"借/贷": "",
"科目代码(*)": "1002.02",
"科目名称(*)": "银行存款 - 中行USD",
"核算项目": account_name,
"核算项目": "", # 修改为空
"币别": "美元",
"汇率": exchange_rate,
"原币金额": received_amount,
"金额": round(received_amount * exchange_rate, 2),
"_check_res": check_res # 添加checkRes标记
"金额": round(received_amount * exchange_rate, 3),
"_check_res": check_res, # 添加checkRes标记
"_record_key": record_key # 记录所属的record_key
}
entries.append(entry_debit)
record_groups[record_key].append(entry_debit)
break # 只记录一次借方
# 2. 手续费借方记录 (如果手续费>0)
@@ -127,10 +134,12 @@ def create_accounting_entries(data: List[Dict[str, Any]], exchange_rate: float)
"币别": "人民币",
"汇率": "",
"原币金额": "",
"金额": round(handling_fee * exchange_rate, 2),
"_check_res": check_res # 添加checkRes标记
"金额": round(handling_fee * exchange_rate, 3),
"_check_res": check_res, # 添加checkRes标记
"_record_key": record_key # 记录所属的record_key
}
entries.append(entry_fee)
record_groups[record_key].append(entry_fee)
# 3. Order列表中每一项的贷方记录
# 科目代码: 1122, 科目名称: 应收账款
@@ -160,10 +169,46 @@ def create_accounting_entries(data: List[Dict[str, Any]], exchange_rate: float)
"币别": "美元",
"汇率": exchange_rate,
"原币金额": amount,
"金额": round(amount * exchange_rate, 2),
"_check_res": check_res # 添加checkRes标记
"金额": round(amount * exchange_rate, 3),
"_check_res": check_res, # 添加checkRes标记
"_record_key": record_key # 记录所属的record_key
}
entries.append(entry_order)
record_groups[record_key].append(entry_order)
# 验证借贷平衡并标记不平衡的记录
for record_key, record_entries in record_groups.items():
debit_sum = 0
credit_sum = 0
credit_count = 0 # 记录贷方条目数量,用于动态调整容差
for entry in record_entries:
if entry["借/贷"] == "":
# 根据科目代码计算金额
if entry["科目代码(*)"] == "1002.02": # 到账金额
debit_sum += entry["原币金额"] * entry["汇率"]
else: # 手续费 5603.03
# 手续费的原币金额就是handling_fee
handling_fee = entry["手续费"]
debit_sum += handling_fee * exchange_rate
else: # 贷
credit_sum += entry["原币金额"] * entry["汇率"]
credit_count += 1
# 检查借贷是否平衡 (使用原币金额计算然后保留3位小数进行比较)
debit_sum_rounded = round(debit_sum, 3)
credit_sum_rounded = round(credit_sum, 3)
# 容差 = 0.0015 + 0.0005 * 贷方条目数
# 基础容差0.0015处理浮点数精度和汇率计算误差
# 每个贷方条目额外贡献0.0005的容差(四舍五入误差)
tolerance = 0.0015 + 0.0005 * credit_count
is_balanced = abs(debit_sum_rounded - credit_sum_rounded) <= tolerance
# 如果不平衡,为所有条目添加标记
if not is_balanced:
for entry in record_entries:
entry["_balance_error"] = True
return entries
@@ -197,7 +242,8 @@ def save_to_excel(entries: List[Dict[str, Any]], output_file: str):
cell.alignment = Alignment(horizontal="center", vertical="center")
# 写入数据
error_fill = PatternFill(start_color="FAD1D4", end_color="FAD1D4", fill_type="solid")
error_fill = PatternFill(start_color="FAD1D4", end_color="FAD1D4", fill_type="solid") # checkRes错误 - 粉色
balance_error_fill = PatternFill(start_color="fff799", end_color="fff799", fill_type="solid") # 借贷不平衡 - 黄色
for row_idx, entry in enumerate(entries, start=2):
check_res = entry.get("_check_res", True)
@@ -221,7 +267,13 @@ def save_to_excel(entries: List[Dict[str, Any]], output_file: str):
# 先设置所有背景颜色(在合并单元格之前)
for row_idx, entry in enumerate(entries, start=2):
check_res = entry.get("_check_res", True)
if not check_res:
balance_error = entry.get("_balance_error", False)
# 优先级:借贷不平衡(黄色) > checkRes错误(粉色)
if balance_error:
for col_idx in range(1, 15):
ws.cell(row=row_idx, column=col_idx).fill = balance_error_fill
elif not check_res:
for col_idx in range(1, 15):
ws.cell(row=row_idx, column=col_idx).fill = error_fill
@@ -257,7 +309,13 @@ def save_to_excel(entries: List[Dict[str, Any]], output_file: str):
# 合并后重新应用背景颜色(确保合并单元格也有背景色)
for row_idx, entry in enumerate(entries, start=2):
check_res = entry.get("_check_res", True)
if not check_res:
balance_error = entry.get("_balance_error", False)
# 优先级:借贷不平衡(黄色) > checkRes错误(粉色)
if balance_error:
for col_idx in range(1, 15):
ws.cell(row=row_idx, column=col_idx).fill = balance_error_fill
elif not check_res:
for col_idx in range(1, 15):
ws.cell(row=row_idx, column=col_idx).fill = error_fill

483
process_excel_optimized.py Normal file
View File

@@ -0,0 +1,483 @@
#!/usr/bin/env python3
"""
财务Excel数据处理程序 - 优化版本
读取data/data.xlsx中的Sheet1表格,提取财务数据并输出到res.json
优化内容:
1. 性能优化: 缓存合并单元格查询,减少重复计算
2. 代码质量: 改进类型提示,增强错误处理
3. 功能增强: 添加数据统计和验证报告
4. 可维护性: 提取常量配置,改进日志记录
"""
import json
import logging
from dataclasses import dataclass, asdict
from openpyxl import load_workbook
from typing import List, Dict, Any, Optional, Tuple, Set
from datetime import datetime
# ==================== 常量配置 ====================
# Excel列索引常量
COL_RECEIVED_AMOUNT = 6 # F列: 实收金额
COL_HANDLING_FEE = 7 # G列: 手续费
COL_ORDER_NUM = 8 # H列: 订单号
COL_AMOUNT = 9 # I列: 金额
COL_ACCOUNT_NAME = 15 # O列: 账户名
# 数据起始行
DATA_START_ROW = 2
# 金额匹配容差
AMOUNT_TOLERANCE = 0.01
# 日志配置
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# ==================== 数据类定义 ====================
@dataclass
class Order:
"""订单数据类"""
OrderNum: Optional[str]
Amount: Optional[float]
AccountName: Optional[str]
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"OrderNum": self.OrderNum,
"Amount": self.Amount,
"AccountName": self.AccountName
}
@dataclass
class FinancialRecord:
"""财务记录数据类"""
ReceivedAmount: Optional[float]
HandlingFee: float
Order: List[Order]
checkRes: bool
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"ReceivedAmount": self.ReceivedAmount,
"HandlingFee": self.HandlingFee,
"Order": [order.to_dict() for order in self.Order],
"checkRes": self.checkRes
}
@dataclass
class ProcessingStats:
"""数据处理统计"""
total_records: int = 0
valid_records: int = 0
invalid_records: int = 0
total_orders: int = 0
check_failed_records: int = 0
skipped_empty_orders: int = 0
processing_time: float = 0.0
def print_report(self):
"""打印统计报告"""
logger.info("=" * 60)
logger.info("数据处理统计报告")
logger.info("=" * 60)
logger.info(f"总记录数: {self.total_records}")
logger.info(f"有效记录: {self.valid_records}")
logger.info(f"无效记录: {self.invalid_records}")
logger.info(f"订单总数: {self.total_orders}")
logger.info(f"金额验证失败: {self.check_failed_records}")
logger.info(f"跳过空订单: {self.skipped_empty_orders}")
logger.info(f"处理耗时: {self.processing_time:.2f}")
logger.info("=" * 60)
# ==================== 合并单元格缓存 ====================
class MergedCellCache:
"""合并单元格缓存类,优化查询性能"""
def __init__(self, merged_ranges):
"""初始化缓存"""
self.merged_ranges = merged_ranges
# 创建行列索引缓存
self._cache: Dict[Tuple[int, int], Any] = {}
self._build_cache()
def _build_cache(self):
"""构建缓存索引"""
for merged_range in self.merged_ranges:
for row in range(merged_range.min_row, merged_range.max_row + 1):
for col in range(merged_range.min_col, merged_range.max_col + 1):
self._cache[(row, col)] = merged_range
def get_merged_range(self, row: int, col: int) -> Optional[Any]:
"""获取指定单元格所属的合并范围"""
return self._cache.get((row, col))
def is_merged(self, row: int, col: int) -> bool:
"""检查单元格是否在合并区域内"""
return (row, col) in self._cache
# ==================== 核心处理函数 ====================
def get_cell_value(ws, row: int, col: int) -> Any:
"""
获取单元格值,处理公式计算结果
参数:
ws: 工作表对象
row: 行号
col: 列号
返回:
单元格值
"""
try:
cell = ws.cell(row, col)
return cell.value
except Exception as e:
logger.warning(f"获取单元格({row}, {col})值失败: {e}")
return None
def get_merged_cell_value(ws, row: int, col: int, cache: MergedCellCache) -> Any:
"""
获取合并单元格的值(优化版本)
如果单元格在合并区域内,返回合并区域左上角单元格的值
参数:
ws: 工作表对象
row: 行号
col: 列号
cache: 合并单元格缓存对象
返回:
单元格值
"""
merged_range = cache.get_merged_range(row, col)
if merged_range:
# 在合并区域内,返回左上角的值
return ws.cell(merged_range.min_row, merged_range.min_col).value
# 不在合并区域内,直接返回单元格值
return ws.cell(row, col).value
def get_f_column_ranges(ws, merged_ranges, start_row: int = DATA_START_ROW) -> List[Tuple[int, int]]:
"""
获取F列的所有数据区域优化版本
参数:
ws: 工作表对象
merged_ranges: 合并单元格范围列表
start_row: 起始行号
返回:
[(起始行, 结束行), ...]
"""
# 找到F列的所有合并单元格区域
f_merges = []
for merge in merged_ranges:
if merge.min_col == COL_RECEIVED_AMOUNT and \
merge.max_col == COL_RECEIVED_AMOUNT and \
merge.min_row >= start_row:
f_merges.append((merge.min_row, merge.max_row))
# 创建合并单元格行的集合,用于快速查找
merged_rows: Set[int] = set()
for start, end in f_merges:
merged_rows.update(range(start, end + 1))
# 处理非合并单元格
all_ranges = []
for row in range(start_row, ws.max_row + 1):
if row not in merged_rows:
f_value = ws.cell(row, COL_RECEIVED_AMOUNT).value
if f_value is not None:
all_ranges.append((row, row))
# 添加合并单元格区域并排序
all_ranges.extend(f_merges)
all_ranges.sort(key=lambda x: x[0])
return all_ranges
def extract_orders(ws, start_row: int, end_row: int, cache: MergedCellCache,
stats: ProcessingStats) -> List[Order]:
"""
提取指定行范围内的订单数据(优化版本)
参数:
ws: 工作表对象
start_row: 起始行
end_row: 结束行
cache: 合并单元格缓存
stats: 统计对象
返回:
订单列表
"""
orders = []
for row in range(start_row, end_row + 1):
# 提取订单数据
order_num = get_merged_cell_value(ws, row, COL_ORDER_NUM, cache)
amount = get_merged_cell_value(ws, row, COL_AMOUNT, cache)
account_name = get_merged_cell_value(ws, row, COL_ACCOUNT_NAME, cache)
# 跳过金额为空的行
if amount is None:
stats.skipped_empty_orders += 1
continue
order = Order(
OrderNum=str(order_num).strip() if order_num else None,
Amount=float(amount) if amount is not None else None,
AccountName=str(account_name).strip() if account_name else None
)
orders.append(order)
stats.total_orders += 1
return orders
def validate_amount(received_amount: Optional[float], handling_fee: float,
orders: List[Order]) -> bool:
"""
验证金额是否匹配
参数:
received_amount: 实收金额
handling_fee: 手续费
orders: 订单列表
返回:
是否匹配
"""
order_amount_sum = sum(
order.Amount for order in orders
if order.Amount is not None
)
received_plus_fee = (received_amount if received_amount is not None else 0) + handling_fee
return abs(received_plus_fee - order_amount_sum) < AMOUNT_TOLERANCE
def process_excel_data(file_path: str) -> Tuple[List[FinancialRecord], ProcessingStats]:
"""
处理Excel文件提取所有财务记录优化版本
参数:
file_path: Excel文件路径
返回:
(记录列表, 统计信息)
"""
start_time = datetime.now()
stats = ProcessingStats()
logger.info(f"开始加载Excel文件: {file_path}")
try:
# 加载Excel文件
wb = load_workbook(file_path, data_only=True)
ws = wb['Sheet1']
# 获取合并单元格范围并创建缓存
merged_ranges = list(ws.merged_cells.ranges)
cache = MergedCellCache(merged_ranges)
logger.info(f"创建合并单元格缓存,共{len(merged_ranges)}个合并区域")
# 获取F列的所有数据区域
f_ranges = get_f_column_ranges(ws, merged_ranges, DATA_START_ROW)
logger.info(f"找到{len(f_ranges)}个F列数据区域")
results = []
for idx, (start_row, end_row) in enumerate(f_ranges, 1):
stats.total_records += 1
# 获取实收金额和手续费
received_amount = get_cell_value(ws, start_row, COL_RECEIVED_AMOUNT)
handling_fee = get_cell_value(ws, start_row, COL_HANDLING_FEE)
# 手续费为空时记为0
if handling_fee is None:
handling_fee = 0
# 提取订单列表
orders = extract_orders(ws, start_row, end_row, cache, stats)
# 跳过没有订单的记录
if not orders:
stats.invalid_records += 1
logger.warning(f"记录#{idx} (行{start_row}-{end_row}): 无有效订单,跳过")
continue
# 验证金额
check_res = validate_amount(received_amount, handling_fee, orders)
if not check_res:
stats.check_failed_records += 1
logger.warning(
f"记录#{idx} (行{start_row}-{end_row}): "
f"金额验证失败 - 实收:{received_amount}, 手续费:{handling_fee}, "
f"订单总额:{sum(o.Amount for o in orders if o.Amount)}"
)
record = FinancialRecord(
ReceivedAmount=received_amount,
HandlingFee=handling_fee,
Order=orders,
checkRes=check_res
)
results.append(record)
stats.valid_records += 1
# 计算处理时间
end_time = datetime.now()
stats.processing_time = (end_time - start_time).total_seconds()
logger.info(f"数据提取完成,共{stats.valid_records}条有效记录")
return results, stats
except Exception as e:
logger.error(f"处理Excel文件时发生错误: {e}", exc_info=True)
raise
def save_to_json(data: List[FinancialRecord], output_file: str):
"""
将数据保存为JSON文件
参数:
data: 要保存的数据
output_file: 输出文件路径
"""
try:
# 转换为字典格式
data_dict = [record.to_dict() for record in data]
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(data_dict, f, ensure_ascii=False, indent=2)
logger.info(f"数据已保存到: {output_file}")
logger.info(f"总共提取 {len(data)} 条记录")
except Exception as e:
logger.error(f"保存JSON文件时发生错误: {e}", exc_info=True)
raise
def generate_validation_report(data: List[FinancialRecord], output_file: str = 'validation_report.txt'):
"""
生成数据验证报告
参数:
data: 财务记录列表
output_file: 报告文件路径
"""
try:
with open(output_file, 'w', encoding='utf-8') as f:
f.write("=" * 80 + "\n")
f.write("财务数据验证报告\n")
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write("=" * 80 + "\n\n")
# 统计信息
total = len(data)
failed = sum(1 for r in data if not r.checkRes)
passed = total - failed
f.write(f"总记录数: {total}\n")
f.write(f"验证通过: {passed} ({passed/total*100:.1f}%)\n")
f.write(f"验证失败: {failed} ({failed/total*100:.1f}%)\n\n")
# 失败记录详情
if failed > 0:
f.write("-" * 80 + "\n")
f.write("验证失败记录详情:\n")
f.write("-" * 80 + "\n\n")
for idx, record in enumerate(data, 1):
if not record.checkRes:
order_sum = sum(o.Amount for o in record.Order if o.Amount)
received_plus_fee = (record.ReceivedAmount or 0) + record.HandlingFee
diff = received_plus_fee - order_sum
f.write(f"记录 #{idx}:\n")
f.write(f" 实收金额: {record.ReceivedAmount}\n")
f.write(f" 手续费: {record.HandlingFee}\n")
f.write(f" 实收+手续费: {received_plus_fee}\n")
f.write(f" 订单总额: {order_sum}\n")
f.write(f" 差额: {diff:.2f}\n")
f.write(f" 订单数量: {len(record.Order)}\n")
f.write("\n")
logger.info(f"验证报告已保存到: {output_file}")
except Exception as e:
logger.error(f"生成验证报告时发生错误: {e}", exc_info=True)
def main():
"""主函数"""
input_file = 'data/data.xlsx'
output_file = 'res.json'
logger.info("=" * 60)
logger.info("财务Excel数据处理程序 - 优化版本")
logger.info("=" * 60)
logger.info(f"输入文件: {input_file}")
logger.info(f"输出文件: {output_file}")
try:
# 提取数据
data, stats = process_excel_data(input_file)
# 保存到JSON
save_to_json(data, output_file)
# 生成验证报告
generate_validation_report(data)
# 显示统计信息
stats.print_report()
# 显示前3条记录预览
if data:
logger.info("\n前3条记录预览:")
preview = [record.to_dict() for record in data[:3]]
print(json.dumps(preview, ensure_ascii=False, indent=2))
logger.info("\n处理完成!")
except Exception as e:
logger.error(f"程序执行失败: {e}")
import traceback
traceback.print_exc()
return 1
return 0
if __name__ == '__main__':
exit(main())

864
res.json
View File

@@ -1560,5 +1560,869 @@
}
],
"checkRes": true
},
{
"ReceivedAmount": 10950,
"HandlingFee": 0,
"Order": [
{
"OrderNum": null,
"Amount": 10950,
"AccountName": "Ship Procurement Services S"
}
],
"checkRes": true
},
{
"ReceivedAmount": 3676.5,
"HandlingFee": 0,
"Order": [
{
"OrderNum": null,
"Amount": 3676.5,
"AccountName": "13 MSC"
}
],
"checkRes": true
},
{
"ReceivedAmount": 5493,
"HandlingFee": 25,
"Order": [
{
"OrderNum": "XLRQD305C25",
"Amount": 100,
"AccountName": "18 Raffles"
},
{
"OrderNum": "XLRQD363C25",
"Amount": 1335,
"AccountName": "18 Raffles"
},
{
"OrderNum": "XLRQD436C25",
"Amount": 995,
"AccountName": "18 Raffles"
},
{
"OrderNum": "XLRQD456C25",
"Amount": 3088,
"AccountName": "18 Raffles"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2184.5,
"HandlingFee": 55.5,
"Order": [
{
"OrderNum": "XLRQD184C25",
"Amount": 2240,
"AccountName": "05 EG"
}
],
"checkRes": true
},
{
"ReceivedAmount": 3511,
"HandlingFee": 51,
"Order": [
{
"OrderNum": "XLRQD239J25",
"Amount": 2762,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD239J25-1",
"Amount": 800,
"AccountName": "02 BERN"
}
],
"checkRes": true
},
{
"ReceivedAmount": 17068,
"HandlingFee": 12,
"Order": [
{
"OrderNum": "XLRQD354C25",
"Amount": 3280,
"AccountName": "06 FLEE"
},
{
"OrderNum": "XLRQD432C25",
"Amount": 400,
"AccountName": "06 FLEE"
},
{
"OrderNum": "XLRQD433C25",
"Amount": 2000,
"AccountName": "06 FLEE"
},
{
"OrderNum": "XLRQD461C25",
"Amount": 11400,
"AccountName": "06 FLEE"
}
],
"checkRes": true
},
{
"ReceivedAmount": 36936,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD157J25",
"Amount": 2200,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD252J25",
"Amount": 16700,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD253J25",
"Amount": 14295,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD267J25",
"Amount": 3741,
"AccountName": "02 BERN"
}
],
"checkRes": true
},
{
"ReceivedAmount": 188578.58,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD543A25",
"Amount": 2280,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD561A25",
"Amount": 370,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD476A25",
"Amount": 920,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD506A25",
"Amount": 29700,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD533A25",
"Amount": 466.4,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD465A25",
"Amount": 9074,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD504A25",
"Amount": 410,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD577A25",
"Amount": 990,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD554A25",
"Amount": 30.8,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD553A25",
"Amount": 455,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD501A25",
"Amount": 5752,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD434A25",
"Amount": 531.2,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD574A25",
"Amount": 600,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD491A25",
"Amount": 23760,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD556A25",
"Amount": 980,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD546A25",
"Amount": 606,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD495A25",
"Amount": 2250,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD488A25",
"Amount": 7574,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD534A25",
"Amount": 5766.4,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD560A25",
"Amount": 1384,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD487A25",
"Amount": 2045.15,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD520A25",
"Amount": 5388,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD527A25",
"Amount": 25136,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD555A25",
"Amount": 1160,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD567A25",
"Amount": 11361,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD485A25",
"Amount": 8106,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD424A25",
"Amount": 20311.33,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD459A25",
"Amount": 148,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD423A25",
"Amount": 13.3,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD541A25",
"Amount": 10407,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD240A25",
"Amount": 540,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD547A25",
"Amount": 8763,
"AccountName": "13 MSC"
},
{
"OrderNum": "XLRQD005SEV25",
"Amount": 1300,
"AccountName": "13 MSC"
}
],
"checkRes": true
},
{
"ReceivedAmount": 12005.97,
"HandlingFee": 4.03,
"Order": [
{
"OrderNum": "XLRQD269J25",
"Amount": 5390,
"AccountName": "18 RIZZO-RB"
},
{
"OrderNum": "XLRQD257J25",
"Amount": 6620,
"AccountName": "18 RIZZO-RB"
}
],
"checkRes": true
},
{
"ReceivedAmount": 13640,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD301H25",
"Amount": 13640,
"AccountName": "ONESEA"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2121,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD300H25",
"Amount": 2121,
"AccountName": "ONESEA"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2552,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD037G25",
"Amount": 2552,
"AccountName": "05 Wallem Group"
}
],
"checkRes": true
},
{
"ReceivedAmount": 6882.5,
"HandlingFee": 37.5,
"Order": [
{
"OrderNum": "XLRQD320T25",
"Amount": 2500,
"AccountName": "26 中化"
},
{
"OrderNum": "XLRQD267T25",
"Amount": 4420,
"AccountName": "26 中化"
}
],
"checkRes": true
},
{
"ReceivedAmount": 5564,
"HandlingFee": 0,
"Order": [
{
"OrderNum": null,
"Amount": 5564,
"AccountName": "13 MSC"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2783,
"HandlingFee": 12,
"Order": [
{
"OrderNum": "XLRQD425C25",
"Amount": 2795,
"AccountName": "06 FLEE"
}
],
"checkRes": true
},
{
"ReceivedAmount": 15920,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD396C25",
"Amount": 8000,
"AccountName": "Executive Ship Management Pte"
},
{
"OrderNum": "XLRQD434C25",
"Amount": 7920,
"AccountName": "Executive Ship Management Pte"
}
],
"checkRes": true
},
{
"ReceivedAmount": 208,
"HandlingFee": 12,
"Order": [
{
"OrderNum": "XLRQD451C25",
"Amount": 220,
"AccountName": "06 FLEE"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2623.5,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD284J25",
"Amount": 2623.5,
"AccountName": "16 Paci"
}
],
"checkRes": true
},
{
"ReceivedAmount": 7769,
"HandlingFee": 51,
"Order": [
{
"OrderNum": "XLRQD247J25",
"Amount": 7820,
"AccountName": "02 BERN"
}
],
"checkRes": true
},
{
"ReceivedAmount": 84775,
"HandlingFee": 11,
"Order": [
{
"OrderNum": "XLRQD245H25",
"Amount": 39600,
"AccountName": "01 ANGLO-A"
},
{
"OrderNum": "XLRQD311H25",
"Amount": 2120,
"AccountName": "01 ANGLO-A"
},
{
"OrderNum": "XLRQD306H25",
"Amount": 1542,
"AccountName": "01 ANGLO-A"
},
{
"OrderNum": "XLRQD179H25",
"Amount": 38979,
"AccountName": "01 ANGLO-A"
},
{
"OrderNum": null,
"Amount": 2545,
"AccountName": null
}
],
"checkRes": true
},
{
"ReceivedAmount": 8052,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD298J25",
"Amount": 3660,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD302J25-1",
"Amount": 973,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD302J25",
"Amount": 2246,
"AccountName": "02 BERN"
},
{
"OrderNum": "XLRQD302J25-2",
"Amount": 1173,
"AccountName": "02 BERN"
}
],
"checkRes": true
},
{
"ReceivedAmount": 23200,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD050Y25",
"Amount": 23200,
"AccountName": "01 Aryansh"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2575,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD351C25",
"Amount": 2575,
"AccountName": "Technomar Shipping Inc"
}
],
"checkRes": true
},
{
"ReceivedAmount": 5538,
"HandlingFee": 32,
"Order": [
{
"OrderNum": null,
"Amount": 5570,
"AccountName": "XTMANAGEMENTLTD"
}
],
"checkRes": true
},
{
"ReceivedAmount": 6250,
"HandlingFee": 0,
"Order": [
{
"OrderNum": null,
"Amount": 6250,
"AccountName": "03 CHAR"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2349,
"HandlingFee": 11,
"Order": [
{
"OrderNum": null,
"Amount": 2360,
"AccountName": "01 ANGLO-A"
}
],
"checkRes": true
},
{
"ReceivedAmount": 11869,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD021D25",
"Amount": 5034,
"AccountName": "15 Ocea"
},
{
"OrderNum": "XLRQD031D25",
"Amount": 1950,
"AccountName": "15 Ocea"
},
{
"OrderNum": "XLRQD047D25",
"Amount": 4885,
"AccountName": "15 Ocea"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2480,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD632A25",
"Amount": 2480,
"AccountName": "MSC CYPRUS"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2755.5,
"HandlingFee": 11,
"Order": [
{
"OrderNum": "XLRQD114N25",
"Amount": 1739,
"AccountName": "01 A2RX"
},
{
"OrderNum": "XLRQD113N25",
"Amount": 1027.5,
"AccountName": "01 A2RX"
}
],
"checkRes": true
},
{
"ReceivedAmount": 8100,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD243J25",
"Amount": 8100,
"AccountName": "16 PACC"
}
],
"checkRes": true
},
{
"ReceivedAmount": 4890,
"HandlingFee": 0,
"Order": [
{
"OrderNum": null,
"Amount": 4890,
"AccountName": "WO TIN SHIPPING"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2700,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD225G25",
"Amount": 2700,
"AccountName": "Emarat Maritime"
}
],
"checkRes": true
},
{
"ReceivedAmount": 7698.5,
"HandlingFee": 41.5,
"Order": [
{
"OrderNum": "XLRQD352T25",
"Amount": 7740,
"AccountName": "09 Italia"
}
],
"checkRes": true
},
{
"ReceivedAmount": 5328,
"HandlingFee": 12,
"Order": [
{
"OrderNum": "XLRQD307C25",
"Amount": 3600,
"AccountName": "06 FLEE"
},
{
"OrderNum": "XLRQD478C25",
"Amount": 1740,
"AccountName": "06 FLEE"
}
],
"checkRes": true
},
{
"ReceivedAmount": 2075,
"HandlingFee": 11,
"Order": [
{
"OrderNum": "XLRQD680A25",
"Amount": 2086,
"AccountName": "15 OSM SHIP"
}
],
"checkRes": true
},
{
"ReceivedAmount": 4550,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD041Y25",
"Amount": 4550,
"AccountName": "19 SPM"
}
],
"checkRes": true
},
{
"ReceivedAmount": 12747,
"HandlingFee": 33,
"Order": [
{
"OrderNum": "XLRQD437C25",
"Amount": 3550,
"AccountName": "16 PAC"
},
{
"OrderNum": "XLRQD395C25",
"Amount": 1980,
"AccountName": "16 PAC"
},
{
"OrderNum": "XLRQD411C25",
"Amount": 3220,
"AccountName": "16 PAC"
},
{
"OrderNum": "XLRQD412C25",
"Amount": 2700,
"AccountName": "16 PAC"
},
{
"OrderNum": "XLRQD441C25",
"Amount": 1030,
"AccountName": "16 PAC"
},
{
"OrderNum": "XLRQD514C25",
"Amount": 300,
"AccountName": "16 PAC"
}
],
"checkRes": true
},
{
"ReceivedAmount": 5758,
"HandlingFee": 32,
"Order": [
{
"OrderNum": "XLRQD075R25",
"Amount": 5790,
"AccountName": "11 Kaizen"
}
],
"checkRes": true
},
{
"ReceivedAmount": 7284,
"HandlingFee": 36,
"Order": [
{
"OrderNum": "XLRQD354T25",
"Amount": 2560,
"AccountName": "16 Pacific"
},
{
"OrderNum": "XLRQD347T25",
"Amount": 4760,
"AccountName": "16 Pacific"
}
],
"checkRes": true
},
{
"ReceivedAmount": 40900,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD253T25",
"Amount": 3000,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD220T25",
"Amount": 4720,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD246T25",
"Amount": 6000,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD262T25",
"Amount": 3000,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD227T25",
"Amount": 1500,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD252T25",
"Amount": 7500,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD254T25",
"Amount": 4500,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD298T25",
"Amount": 5120,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD322T25",
"Amount": 3000,
"AccountName": "24台湾万海航运"
},
{
"OrderNum": "XLRQD235T25",
"Amount": 2560,
"AccountName": "24台湾万海航运"
}
],
"checkRes": true
},
{
"ReceivedAmount": 65900,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD136G25",
"Amount": 65900,
"AccountName": "Capital Executive Ship Management Corp"
}
],
"checkRes": true
},
{
"ReceivedAmount": 7815.3,
"HandlingFee": 0,
"Order": [
{
"OrderNum": "XLRQD284J25",
"Amount": 2623.5,
"AccountName": "16 Paci"
},
{
"OrderNum": "XLRQD295J25",
"Amount": 5191.8,
"AccountName": "16 Paci"
}
],
"checkRes": true
}
]

2
run.cmd Normal file
View File

@@ -0,0 +1,2 @@
python process_excel.py
python generate_accounting_entries.py

331
test_process_excel.py Normal file
View File

@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""
财务Excel数据处理程序的单元测试
"""
import unittest
import json
import os
from unittest.mock import Mock, patch, MagicMock
from openpyxl import Workbook
from openpyxl.worksheet.worksheet import Worksheet
# 导入被测试的模块
try:
from process_excel_optimized import (
Order, FinancialRecord, ProcessingStats,
MergedCellCache, get_cell_value, validate_amount
)
USE_OPTIMIZED = True
except ImportError:
USE_OPTIMIZED = False
print("警告: 无法导入优化版本,部分测试将被跳过")
class TestOrder(unittest.TestCase):
"""测试Order数据类"""
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_order_creation(self):
"""测试订单创建"""
order = Order(
OrderNum="TEST001",
Amount=1000.50,
AccountName="测试账户"
)
self.assertEqual(order.OrderNum, "TEST001")
self.assertEqual(order.Amount, 1000.50)
self.assertEqual(order.AccountName, "测试账户")
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_order_to_dict(self):
"""测试订单转换为字典"""
order = Order(
OrderNum="TEST001",
Amount=1000.50,
AccountName="测试账户"
)
result = order.to_dict()
self.assertIsInstance(result, dict)
self.assertEqual(result["OrderNum"], "TEST001")
self.assertEqual(result["Amount"], 1000.50)
self.assertEqual(result["AccountName"], "测试账户")
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_order_with_none_values(self):
"""测试订单号为空的情况"""
order = Order(
OrderNum=None,
Amount=1000.50,
AccountName="测试账户"
)
self.assertIsNone(order.OrderNum)
self.assertEqual(order.Amount, 1000.50)
class TestFinancialRecord(unittest.TestCase):
"""测试FinancialRecord数据类"""
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_record_creation(self):
"""测试财务记录创建"""
orders = [
Order("TEST001", 1000.0, "账户1"),
Order("TEST002", 2000.0, "账户2")
]
record = FinancialRecord(
ReceivedAmount=2975.0,
HandlingFee=25.0,
Order=orders,
checkRes=True
)
self.assertEqual(record.ReceivedAmount, 2975.0)
self.assertEqual(record.HandlingFee, 25.0)
self.assertEqual(len(record.Order), 2)
self.assertTrue(record.checkRes)
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_record_to_dict(self):
"""测试财务记录转换为字典"""
orders = [Order("TEST001", 1000.0, "账户1")]
record = FinancialRecord(
ReceivedAmount=1000.0,
HandlingFee=0.0,
Order=orders,
checkRes=True
)
result = record.to_dict()
self.assertIsInstance(result, dict)
self.assertEqual(result["ReceivedAmount"], 1000.0)
self.assertIsInstance(result["Order"], list)
self.assertEqual(len(result["Order"]), 1)
class TestProcessingStats(unittest.TestCase):
"""测试ProcessingStats统计类"""
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_stats_initialization(self):
"""测试统计对象初始化"""
stats = ProcessingStats()
self.assertEqual(stats.total_records, 0)
self.assertEqual(stats.valid_records, 0)
self.assertEqual(stats.invalid_records, 0)
self.assertEqual(stats.total_orders, 0)
self.assertEqual(stats.check_failed_records, 0)
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_stats_update(self):
"""测试统计数据更新"""
stats = ProcessingStats()
stats.total_records = 10
stats.valid_records = 8
stats.invalid_records = 2
stats.total_orders = 25
self.assertEqual(stats.total_records, 10)
self.assertEqual(stats.valid_records, 8)
self.assertEqual(stats.invalid_records, 2)
self.assertEqual(stats.total_orders, 25)
class TestMergedCellCache(unittest.TestCase):
"""测试MergedCellCache缓存类"""
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_cache_creation(self):
"""测试缓存创建"""
# 创建模拟的合并单元格范围
mock_range = Mock()
mock_range.min_row = 2
mock_range.max_row = 4
mock_range.min_col = 6
mock_range.max_col = 6
cache = MergedCellCache([mock_range])
# 测试缓存是否正确识别合并单元格
self.assertTrue(cache.is_merged(2, 6))
self.assertTrue(cache.is_merged(3, 6))
self.assertTrue(cache.is_merged(4, 6))
self.assertFalse(cache.is_merged(5, 6))
self.assertFalse(cache.is_merged(2, 7))
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_cache_get_merged_range(self):
"""测试获取合并范围"""
mock_range = Mock()
mock_range.min_row = 2
mock_range.max_row = 4
mock_range.min_col = 6
mock_range.max_col = 6
cache = MergedCellCache([mock_range])
# 测试获取合并范围
result = cache.get_merged_range(3, 6)
self.assertIsNotNone(result)
# 测试非合并单元格
result = cache.get_merged_range(10, 10)
self.assertIsNone(result)
class TestValidateAmount(unittest.TestCase):
"""测试金额验证函数"""
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_validate_exact_match(self):
"""测试金额完全匹配"""
orders = [
Order("TEST001", 1000.0, "账户1"),
Order("TEST002", 975.0, "账户2")
]
result = validate_amount(1975.0, 0.0, orders)
self.assertTrue(result)
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_validate_with_handling_fee(self):
"""测试包含手续费的金额验证"""
orders = [
Order("TEST001", 2000.0, "账户1")
]
result = validate_amount(1975.0, 25.0, orders)
self.assertTrue(result)
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_validate_within_tolerance(self):
"""测试在容差范围内的金额"""
orders = [
Order("TEST001", 1000.005, "账户1")
]
# 差额在0.01容差范围内
result = validate_amount(1000.0, 0.0, orders)
self.assertTrue(result)
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_validate_mismatch(self):
"""测试金额不匹配"""
orders = [
Order("TEST001", 1000.0, "账户1"),
Order("TEST002", 500.0, "账户2")
]
result = validate_amount(1000.0, 0.0, orders)
self.assertFalse(result)
@unittest.skipIf(not USE_OPTIMIZED, "需要优化版本")
def test_validate_with_none_amount(self):
"""测试包含空金额的订单"""
orders = [
Order("TEST001", 1000.0, "账户1"),
Order("TEST002", None, "账户2") # 空金额应被忽略
]
result = validate_amount(1000.0, 0.0, orders)
self.assertTrue(result)
class TestIntegration(unittest.TestCase):
"""集成测试"""
def test_json_output_format(self):
"""测试JSON输出格式"""
# 检查res.json是否存在
if not os.path.exists('res.json'):
self.skipTest("res.json文件不存在")
# 读取JSON文件
with open('res.json', 'r', encoding='utf-8') as f:
data = json.load(f)
# 验证数据结构
self.assertIsInstance(data, list)
if len(data) > 0:
record = data[0]
# 验证必需字段
self.assertIn("ReceivedAmount", record)
self.assertIn("HandlingFee", record)
self.assertIn("Order", record)
self.assertIn("checkRes", record)
# 验证Order结构
self.assertIsInstance(record["Order"], list)
if len(record["Order"]) > 0:
order = record["Order"][0]
self.assertIn("OrderNum", order)
self.assertIn("Amount", order)
self.assertIn("AccountName", order)
class TestConfigIntegration(unittest.TestCase):
"""配置文件集成测试"""
def test_config_file_exists(self):
"""测试配置文件是否存在"""
self.assertTrue(
os.path.exists('config.ini'),
"config.ini配置文件应该存在"
)
def test_exchange_rate_file(self):
"""测试汇率文件"""
if not os.path.exists('exchange_rate.txt'):
self.skipTest("exchange_rate.txt文件不存在")
with open('exchange_rate.txt', 'r', encoding='utf-8') as f:
content = f.read().strip()
# 验证可以转换为浮点数
try:
rate = float(content)
self.assertGreater(rate, 0)
self.assertLess(rate, 100)
except ValueError:
self.fail("汇率文件内容无法转换为数字")
def run_tests():
"""运行所有测试"""
# 创建测试套件
loader = unittest.TestLoader()
suite = unittest.TestSuite()
# 添加所有测试类
suite.addTests(loader.loadTestsFromTestCase(TestOrder))
suite.addTests(loader.loadTestsFromTestCase(TestFinancialRecord))
suite.addTests(loader.loadTestsFromTestCase(TestProcessingStats))
suite.addTests(loader.loadTestsFromTestCase(TestMergedCellCache))
suite.addTests(loader.loadTestsFromTestCase(TestValidateAmount))
suite.addTests(loader.loadTestsFromTestCase(TestIntegration))
suite.addTests(loader.loadTestsFromTestCase(TestConfigIntegration))
# 运行测试
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# 返回测试结果
return result.wasSuccessful()
if __name__ == '__main__':
success = run_tests()
exit(0 if success else 1)