diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a4628a1..174ca5f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/AccountingEntries.xlsx b/AccountingEntries.xlsx index fbd5756..ad37156 100644 Binary files a/AccountingEntries.xlsx and b/AccountingEntries.xlsx differ diff --git a/CLAUDE.md b/CLAUDE.md index e7e28e8..a421bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/__pycache__/generate_accounting_entries.cpython-312.pyc b/__pycache__/generate_accounting_entries.cpython-312.pyc index a77569d..eac9a0b 100644 Binary files a/__pycache__/generate_accounting_entries.cpython-312.pyc and b/__pycache__/generate_accounting_entries.cpython-312.pyc differ diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..baca3c8 --- /dev/null +++ b/config.ini @@ -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 + + + diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..e2d7743 --- /dev/null +++ b/config_loader.py @@ -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() + + + diff --git a/data/data.xlsx b/data/data.xlsx index ebfa9aa..7c2bcba 100644 Binary files a/data/data.xlsx and b/data/data.xlsx differ diff --git a/generate_accounting_entries.py b/generate_accounting_entries.py index 13519ab..f1e6ae0 100644 --- a/generate_accounting_entries.py +++ b/generate_accounting_entries.py @@ -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 diff --git a/process_excel_optimized.py b/process_excel_optimized.py new file mode 100644 index 0000000..5d4d0b8 --- /dev/null +++ b/process_excel_optimized.py @@ -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()) + + + diff --git a/res.json b/res.json index 04c68a6..c71d786 100644 --- a/res.json +++ b/res.json @@ -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 } ] \ No newline at end of file diff --git a/run.cmd b/run.cmd new file mode 100644 index 0000000..6b9ece3 --- /dev/null +++ b/run.cmd @@ -0,0 +1,2 @@ +python process_excel.py +python generate_accounting_entries.py \ No newline at end of file diff --git a/test_process_excel.py b/test_process_excel.py new file mode 100644 index 0000000..31f1159 --- /dev/null +++ b/test_process_excel.py @@ -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) + + +